Study là mảng "học đều mỗi ngày" của flyer.us — bản đồ cấp độ kiểu Duolingo, mỗi ô là một bài học ngắn 3–7 phút. Repo @flyer/study chỉ ship 3 màn (bản đồ + phòng học + màn kết quả); còn lại tận dụng shell của exam.flyer.us (hồ sơ, kim cương, chuỗi ngày, cửa hàng). Đây là PRD 100% mô tả những gì có sẵn trong codebase branch main ngày 24/08/2026.
FLYER có nhiều dòng sản phẩm cho học sinh: exam.flyer.us (thi và luyện đề chứng chỉ Cambridge/IELTS), Bingo AI (chat & gọi thoại với gia sư ảo), và Study — mảng "học đều mỗi ngày" mà tài liệu này mô tả.
Study được đặt cược cho ba mục tiêu:
Study là một micro-frontend: chỉ chuyên phần bản đồ + bộ chạy bài học + màn kết quả. Mọi thứ khác — hồ sơ, kim cương, chuỗi ngày, cửa hàng, đăng nhập, bảng xếp hạng — nằm ở shell exam.flyer.us bọc ngoài. Study nhúng vào exam qua subdomain study.exam.flyer.us và truy cập cookie phiên cấp .flyer.us. Domain study.flyer.us là bản standalone (HS mở link trực tiếp).
| Persona | Nhu cầu | Ngữ cảnh vào Study |
|---|---|---|
| HS 6–10 tuổi (Pre-A1 → A1) | Học từ vựng & ngữ pháp cơ bản qua trò chơi | Cha mẹ hoặc GV bật hộ; theme Candy World |
| HS 11–15 tuổi (A2 → B1) | Luyện đều để chuẩn bị thi Movers/Flyers/KET | Vào từ home hoặc từ giao bài; theme Pirate sau khi qua ải gate |
| Phụ huynh | Theo dõi tiến độ, nhắc con học | Không dùng Study trực tiếp — xem báo cáo trên shell |
| Giáo viên | Giao Study path cho lớp theo tuần | Tạo lịch trên teacher.flyer.us; HS vào Study qua deep-link kèm assignTestCode |
apps/study| Route | Nội dung | Component chính | Kích thước |
|---|---|---|---|
/[locale] | Bản đồ level (home) | ExamLessonPageView | 562 LOC |
/[locale]/[id] | Phòng học bài (id = checkPointId) | ExamLessonDetailsView | 1.420 LOC |
/[locale]/result/[id] | Màn kết quả (id = userAttemptId) | ExamLessonResultView + ModalViewResult | 758 LOC |
/api/ping | Health check trả pong | — | — |
/api/imgproxy/[...path] | Proxy ảnh qua imgproxy service; fallback về /_next/image | — | — |
RequireLoginModal gọi khi bắt đầu bài; luồng OTP/Google/Facebook nằm ở exam.standalone → Docker image port 3000┌────────────────────────────────────────────────────────────┐
│ study.flyer.us hoặc study.exam.flyer.us │
└─────────────────────────┬──────────────────────────────────┘
│
┌─────▼─────────┐
│ / (home) │
│ Level map │ ExamLessonPageView
│ + Rive marker │ revalidate = 3600s
└─────┬─────────┘
┌──────────────────┼──────────────────┐
│ │ │
Regular level Chest level BingoSubject level
│ │ │
│ ┌─────▼─────┐ │
│ │ Chest │ │
│ │ ceremony │ ExamChestsView
│ │ (chest→ │ (show-chest → robot-talk
│ │ robot → │ → bingo-talk → select-target)
│ │ bingo→ │ │
│ │ target) │ │
│ └─────┬─────┘ │
│ │ │
│ │ ▼
│ │ AIConversationSession
│ │ (redirect sang Bingo AI)
│ │
▼ ▼
┌────────────────────────────┐
│ /[id] Lesson runner │
│ - duolingoStartAttempt │
│ - loop parts + retry parts │ ExamLessonDetailsView
│ - DuolingoSubmitPartAttempt│ useLearningFlow
│ - DuolingoEndAttempt │ 1.420 LOC
└────────────┬───────────────┘
│
▼
┌────────────────────────────┐
│ /result/[id] │
│ Sao, độ chính xác, mục tiêu│ ExamLessonResultView
│ + review từng câu │ ModalViewResult (758 LOC)
└────────────────────────────┘
DuolingoPaths từ v2 GraphQL (kèm X-Service-Token), transform về shape viewerDuolingoPaths. Rive /animations/map_mover.riv load ngay (preload trong layout).RequireLoginModal. Nếu là Chest → chuyển tới ExamChestsView. Nếu là BingoSubject → gọi ViewerAIConversationSessionCreate rồi redirect ra Bingo.StudyCheckPointQuery lấy list part; gọi duolingoStartAttempt lấy userAttemptId. Nếu URL có ?target[]= thì gọi GetAttemptObjectivesLesson.useLearningFlow tăng currentPartIndexAtom; sau khi trả lời gọi DuolingoSubmitPartAttempt. Part sai được đẩy vào retryPartsAtom.retryParts đến khi rỗng. Ngoại lệ: checkpoint Flashcards không retry.DuolingoEndAttempt, chuyển đến /result/[userAttemptId].UserAttemptLessonQuery, hiển thị sao/độ chính xác/mục tiêu; mở ModalViewResult review từng câu (tôn trọng assignTest.isHiddenAnswer).Nếu URL có ?assignTestCode= và HS đang xem trên mobile browser (không phải trong WebView), component OpenInExamApp tự deep-link mở ứng dụng native Exam vào đúng bài — tránh lỗi giao diện responsive.
DuolingoPartType — 14 renderer front-end; (b) DuolingoChallengeType — 30 subtype để backend gợi ý loại tương tác cụ thể; (c) DuolingoStoryElementType — 6 element bên trong Story Mode. Map giữa 3 tầng nằm ở ModalViewResult.tsx:22-40.DuolingoPartType — mỗi loại một thư mục renderer| Enum | Renderer folder | Mô tả ngắn | Nhập liệu |
|---|---|---|---|
DRAG_DROP | part-type/DragDrop | Kéo từ thả vào ô đúng | Chuột / cảm ứng |
FILL_IN_BLANK | part-type/FillInBlank | Điền ô trống trong câu | Bàn phím |
FILL_LETTER | part-type/FillLetter | Điền chữ cái vào từ (Pre-A1) | Bàn phím ảo |
FLASHCARD | part-type/FlashCard | Thẻ đơn từ vựng, không retry | Nhấn biết/không |
GAP_FILL | part-type/GapFill | Chọn từ điền vào chỗ trống | Nhấn chọn |
INTERACTIVE_VIDEO | part-type/InteractiveVideo | YouTube kèm câu hỏi giữa video | Play/Pause + trả lời |
MATCHING | part-type/MatchingPart | Ghép cặp từ ↔ nghĩa/ảnh | Nhấn nối |
MULTIPLE_CHOICE | part-type/MultipleChoice | Trắc nghiệm 4 đáp án | Nhấn chọn |
OPEN_ENDED | part-type/OpenEnded | Trả lời tự do (text) | Bàn phím |
SCRAMBLED_SENTENCE | part-type/ScrambledSentence | Xếp lại câu từ mảnh | Kéo thả |
SELECT_OBJECT | part-type/SelectObject | Chỉ vào vật đúng trong ảnh | Nhấn ảnh |
SPEAKING | part-type/Speaking | Ghi âm bằng mic, chấm phát âm | Micro |
STORY | part-type/StoryMode | Truyện tương tác nhiều bước | Xem & trả lời từng đoạn |
WORD_ORDERING | part-type/WordOrdering | Sắp xếp từ đúng thứ tự | Nhấn/kéo từ |
DuolingoChallengeType — subtype backendMỗi challenge subtype cụ thể hoá cách trình bày một part. Ví dụ MULTIPLE_CHOICE có thể ánh xạ về SELECT (chọn text), IMAGE_SELECT (chọn ảnh), LISTEN_TAP (nghe rồi chọn từ). Đầy đủ 30 giá trị:
| Nhóm | Challenge subtype |
|---|---|
| Đọc/nghe cơ bản | LISTEN, LISTEN_MATCH, LISTEN_TAP, LISTEN_ISOLATION, SELECT, SELECT_TRANSCRIPTION |
| Ảnh | IMAGE_SELECT, IMAGE_MATCH, IMAGE_TAP, IMAGE_TAP_COMPLETE, IMAGE_FILL, IMAGE_SPEAK |
| Ghép/kéo/sắp xếp | MATCH, DRAG_DROP, WORD_ORDERING, TAP_COMPLETE |
| Điền chữ / dịch | FILL_LETTER, TRANSLATE, TRANSLATE_TAP |
| Nói | SPEAK, DIALOGUE, ASSIST |
| Flashcard | FLASHCARDS, FLASHCARDS_FILL |
| Truyện | STORY_TAP, STORY_TAP_COMPLETE |
| Trắc nghiệm khác | OPEN_ENDED, SELECT_OBJECT, INTERACTIVE_VIDEO, TRUE_FALSE, YES_NO |
DuolingoStoryElementType — element bên trong Story ModeStory Mode là kiểu bài đặc biệt hiển thị 1 câu chuyện có nhân vật; mỗi bước là một element:
| Element | Vai trò | Component |
|---|---|---|
HEADER | Tiêu đề đoạn | HeaderStory |
LINE | Lời thoại nhân vật | LineCharacter |
MULTIPLE_CHOICE | Câu hỏi trắc nghiệm trong truyện | MultipleChoiceStoryMode |
GAP_FILL | Điền từ vào chỗ trống | GapFillStoryMode |
POINT_TO_PHRASE | Chỉ vào cụm từ đúng | PointToPhrase |
SELECT_PHRASE | Chọn 1 cụm từ trong đoạn | chung StoryMode |
DuolingoLevelType)| Type | Mô tả | Hành vi |
|---|---|---|
Regular | Bài học thường | Nhấn vào → Lesson runner |
Chest | Rương phần thưởng | Nhấn vào → chuỗi ceremony show-chest → robot-talk → bingo-talk → select-target |
BingoSubject | Level hội thoại AI | Nhấn vào → tạo AIConversationSession → redirect Bingo |
Vòng lặp trong useLearningFlow.ts:
1. Chạy lần lượt từng part theo thứ tự listDuolingoPartsAtom.
2. Sau mỗi part:
- Nếu đúng → thêm vào listPartIdAnswersCorrectAtom, next.
- Nếu sai → thêm vào retryPartsAtom, next.
3. Sau khi hết listDuolingoParts:
- Nếu retryParts rỗng → isLessonCompleted = true.
- Nếu còn → chạy vòng retry, mỗi part chỉ ra khỏi vòng khi đúng.
4. Ngoại lệ: checkpoint gồm 100% FLASHCARD KHÔNG chạy vòng retry
(vì flashcard không có "đúng/sai" theo nghĩa chấm điểm).
5. isAfterFlashCard: cờ để render banner "làm tốt lắm" sau đoạn flashcard.
| Chỉ số | Nguồn | Hiển thị |
|---|---|---|
| Sao (0–3) | userAttempt.starsEarned | Hero màn kết quả + huy hiệu ở map |
| Độ chính xác % | Số part đúng lần đầu / tổng part | Counter đếm lên (Counter.tsx) |
| Mục tiêu (objectives) | userAttempt.objectives.passed | Badge, có countdown thời gian nếu là mục tiêu thời gian |
UserAttemptObjectiveKey)COMPLETE_IN_5_MIN — Hoàn thành bài trong 5 phút. Hiển thị đồng hồ đếm ngược react-countdown trên UI real-time.EXACT_THREE_FIRST_TRY — Đúng 3 câu đầu tiên trong lượt 1.Live tracker query GetAttemptObjectivesLessonTargetRealTime chạy khi có ?target[]= trong URL (giao bài của GV).
| Theme | Điều kiện | Số ô mỗi khối |
|---|---|---|
| Candy World | Level index < PIRATE_MAP_CONFIG.GATE_LEVEL_INDEX (dev 9, prod 99) | 7 checkpoint / section |
| Pirate Map | Sau khi qua ải gate | 5 checkpoint / section |
Bên cạnh đó có StudyThemeProvider đọc checkPoint.testLevel.slug để chọn palette con: starters, movers, flyers, ket, pet, toefl-primary, hoặc default.
Tất cả traffic đi qua rewrite /api/v2/graphql → ${SERVICE_API_V2}/v2/graphql. Server-side fetch (bản đồ) dùng thêm header X-Service-Token (env X_SERVICE_TOKEN) để bỏ qua auth cookie khi SSR.
| Loại | Tên | Dùng ở đâu |
|---|---|---|
| Query (SSR) | DuolingoPaths | Home page — server render map |
| Query | ViewerDuolingoPaths | Home page — hydrate map ở client |
| Query | AuthProviderViewerSessionQuery | Xác định user hiện tại |
| Query | StudyCheckPointQuery | Lesson runner load part list |
| Query | UserAttemptLessonQuery | Result view load kết quả |
| Query | GetAttemptObjectivesLesson | Lấy mục tiêu của attempt |
| Query | GetAttemptObjectivesLessonTargetRealTime | Track live target khi có GV giao |
| Query | GetAttemptLessonStudyMode | Study mode fetcher |
| Mutation | StudyStartAttemptMutation (=duolingoStartAttempt) | Bắt đầu bài, trả userAttemptId |
| Mutation | DuolingoSubmitPartAttempt | Nộp 1 part |
| Mutation | DuolingoEndAttempt | Kết thúc bài, tính điểm |
| Mutation | ViewerAIConversationSessionCreate | Tạo session Bingo AI khi nhấn level chat |
| Mutation | AuthProviderViewerLogOutMutation | Đăng xuất |
| Mutation | AuthProviderViewerSwitchLearnerProfileMutation | Đổi profile HS (1 tài khoản nhiều bé) |
| Mutation | AuthProviderCreateAnonymousLearnerProfileMutation | Tạo profile ẩn danh khi HS chưa đăng nhập |
| Mutation | AuthProviderUpdateAnonymousLearnerProfileMutation | Cập nhật profile ẩn danh (thêm avatar, tên) |
Viewer — bọc trong coin, star, frameImage, avatarUrl, subscriptionExpiresAt, isSubscribing (dùng cho shell hiển thị, KHÔNG hiển thị trong Study).LearnerProfile — hồ sơ HS.subChallenges — dùng khi 1 part có nhiều sub-question.| Bảng | Vai trò | Số dòng thực |
|---|---|---|
duolingo_paths | Path (cấp cao nhất, hiện chỉ 2: Starters cũ + Candy World Pre A1) | 2 |
duolingo_units | Đơn vị (chương) trong path | 142 |
duolingo_levels | Level (bài) trong unit | 916 |
duolingo_parts | Part (câu hỏi) — đơn vị chạy | 19.559 |
check_point_duolingo_parts | Bảng nối checkpoint ↔ part; cùng 1 CP có thể xuất hiện ở nhiều level | (N/A) |
duolingo_level_check_points | Bảng nối level ↔ checkpoint | (N/A) |
learner_duolingo_levels | Tiến độ mỗi HS ở mỗi level | (N/A) |
learner_duolingo_rollup_levels | Rollup theo group | (N/A) |
learner_all_duolingo_paths | Tất cả path một HS từng chạm | (N/A) |
user_attempts (type_attempt='STUDY') | Mỗi lượt chạy bài Study | 226k / 30d |
| Thư viện | Vai trò | Vị trí dùng |
|---|---|---|
| HTMLAudioElement + hls.js | Nghe HLS .m3u8 từ stream.flyer.us; fast-fail 6s → fallback progressive-mp3 | lib/audio-source.ts; single-track control qua GlobalAudioStore |
| Howler | SFX ngắn (pressButton, typing, invalidKey) chạy Web Audio, không chồng lấn audio chính | lib/soundEffects.ts |
| wavesurfer.js | Sóng âm animate trên nút từ vựng | component/ButtonWord |
| RecordRTC | Ghi âm mic (Speaking part) với StereoAudioRecorder | part-type/Speaking |
| Rive | Animation nhân vật đi trên bản đồ | ExamLessonPageView + /animations/map_mover.riv |
| Lottie | Micro-animation: result, ButtonWord, AudioPlayer, TooltipWithImage, voice_animation trong Story | ~7 chỗ, dynamic-import cho SSR safety |
| react-youtube | Video YouTube nhúng cho INTERACTIVE_VIDEO | part-type/InteractiveVideo |
| Framer Motion | Transition rương/tooltip; đi qua LazyMotionWrapper để tree-shake | toàn ứng dụng |
GlobalAudioStore trong hooks/useGlobalAudio.ts giữ 1 track chính; ai nhấn play tự pause người khác. Tránh cảnh 2 câu hỏi cùng phát audio khi HS nhấn nhanh.
localStorage.cdn_choice = 'cloudflare' | 'cloudfront' (mặc định cloudflare) cho phép người dùng chuyển CDN khi bị nghẽn — dùng để debug khi kêu iPad lag audio ở khu vực nhất định.
AuthProvider gọi AuthProviderCreateAnonymousLearnerProfileMutation, trả về ID.NEXT_PUBLIC_ANONYMOUS_LEARNER_PROFILE_ID ở domain root (.flyer.us hoặc .flyer.vn) → chia sẻ được cho study.exam.flyer.us.RequireLoginModal nếu chưa có phiên thật.AuthProviderViewerSwitchLearnerProfileMutation.URL từ GV: study.flyer.us/vi/{checkPointId}?assignTestCode=XYZ&target[]=COMPLETE_IN_5_MIN&target[]=EXACT_THREE_FIRST_TRY&levelId=…&bookLessonId=…
assignTestCode gắn bài với 1 bản giao cụ thể; kết quả nộp về cùng attempt trên bảng assign_test_student (bên teacher).target[] mở live tracker mục tiêu.levelId + bookLessonId điều hướng ngữ cảnh (biết bài này thuộc unit/lesson nào để về đúng chỗ khi xong).assignTestCode + không phải WebView → OpenInExamApp deep-link mở ứng dụng native để tránh giao diện web trên mobile.| Ngôn ngữ | Prefix URL | File dịch |
|---|---|---|
| Tiếng Anh (default) | không prefix | src/locales/en.json |
| Tiếng Việt | /vi/… | src/locales/vi.json |
Middleware src/middleware.ts chỉ dùng createMiddleware(routing) của next-intl, KHÔNG có logic auth ở edge. localePrefix: 'as-needed' nên URL EN không có /en/.
| Bước | Chi tiết |
|---|---|
| Build | pnpm gqlgen && next build --turbopack; output .next/standalone |
| Container | Node 20.15.1-slim, chạy node ./server.js UID 1001, port 3000 |
| Env quan trọng | NEXT_PUBLIC_APP_API, SERVICE_API_V2, NEXT_PUBLIC_BASE_MEDIA_URL, NEXT_PUBLIC_EXAM_US_URL, NEXT_PUBLIC_PIRATE_GATE_INDEX, X_SERVICE_TOKEN |
| Ảnh | imgproxy service tùy chọn qua IMGPROXY_URL; loader custom (loader.js) fallback về Next Image nếu proxy fail |
| Deploy target | Kubernetes cluster flyer-apps; sibling service imgproxy nằm cùng cluster |
| Rủi ro | Mức | Ghi chú |
|---|---|---|
| ExamLessonDetailsView 1.420 LOC | Cao | File chạy runner khổng lồ, khó refactor. Mỗi part type mới đều phải chạm. |
| Checkpoint dùng chung nhiều level | Trung bình | Cùng 1 checkPointId xuất hiện ở nhiều duolingo_level; khi báo cáo phải cẩn thận không count double. |
| 5.890 vocab A1 mồ côi | Cao | Content A1 đã build (MC + flashcard) nhưng 99% chưa nối vào path nào → A1 chưa có Study path live. Xem academic.flyer.vn. |
Chỉ 2 duolingo_paths | Trung bình | Starters (old) + Candy World Pre-A1. Movers/Flyers/KET/PET có test_level nhưng chưa có path chính thức → level xuất hiện qua rollup. |
| Không có leaderboard/streak/hearts trong Study | Cần theo dõi | Đợi wrapper exam. Nếu user vào study.flyer.us standalone thì thiếu retention hook. |
| Anonymous cookie 1 năm | Trung bình | HS ẩn danh có thể học liên tục 1 năm không đăng nhập; khi hết TTL mất tiến độ. Nên gate ở mốc cụ thể (10 bài?) để nhắc đăng nhập. |
| Middleware không kiểm auth | Trung bình | Auth chạy 100% client-side → SSR page trả HTML "map trống" nếu bot không có cookie. Không phải bug bảo mật nhưng SEO/preview kém. |
ExitConfirmationPopup chống thoát nhầm.revalidate = 3600 để bản đồ có ISR, không bombard server.study.flyer.us đủ nghiện, không phụ thuộc exam.ExamLessonDetailsView: mỗi part type một sub-component với contract chuẩn (onSubmit, onProgress).COMPLETE_IN_5_MIN: streak/day, phối hợp AI Bingo, mục tiêu speaking.← Danh mục PRD · Kiểm chứng số liệu production · System Design teacher.flyer.us · Roadmap chính