FLYER Student · PRD · Sprint 110

🍬 FLYER Study — kiến trúc & toàn bộ tính năng

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.

Phiên bản
1.0
Ngày
24/08/2026
Owner
Tùng (Product) + team Study FE
Repo
flyer/webs · apps/study
Trạng thái
Đang chạy (production)
📈 Số liệu production (24/08/2026 · read-replica) 226.202 bài STUDY / 30 ngày 11.993 HS active / 30 ngày 19,7 bài/HS/tháng 916 level · 142 unit · 19.559 part
🔗 Điểm truy cập study.flyer.us ↗ bản trong shell exam ↗ Local dev: localhost:3007

1Bối cảnh & mục tiêu

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:

  1. Kéo tần suất mở ứng dụng — bài học ngắn 3–7 phút, không cần đề thi hoàn chỉnh, để HS mở ứng dụng hàng ngày thay vì chỉ trước khi thi.
  2. Trung chuyển vào luyện đề — sau vài chục lesson Study, HS có nền tảng đủ để bắt đầu mock test trên exam.flyer.us.
  3. Kênh cho GV giao bài tự động — Study path là "chương trình" mà giáo viên có thể xếp lịch giao (thấy trên tính năng Giao bài tự động).

Tại sao chỉ có 3 màn?

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).

Hệ quả cho developer: bất kỳ tính năng nào cần header xu/streak/heart đều KHÔNG nằm ở repo này — phải sửa exam wrapper. Ngược lại, thay đổi renderer câu hỏi hay logic map luôn ở đây.

2Người dùng & user stories

Persona

PersonaNhu cầuNgữ 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ơiCha 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/KETVào từ home hoặc từ giao bài; theme Pirate sau khi qua ải gate
Phụ huynhTheo dõi tiến độ, nhắc con họcKhông dùng Study trực tiếp — xem báo cáo trên shell
Giáo viênGiao Study path cho lớp theo tuầnTạo lịch trên teacher.flyer.us; HS vào Study qua deep-link kèm assignTestCode

User stories chính

  1. HS mới, tôi muốn vào Study không cần đăng nhập, để không bỏ giữa chừng vì gặp form.
  2. HS, tôi muốn nhìn thấy vị trí hiện tại trên bản đồ khi mở ứng dụng, để biết bài tiếp theo là gì.
  3. HS, tôi muốn hoàn thành 1 bài trong dưới 5 phút và nhận sao, để có động lực làm bài kế tiếp.
  4. HS làm sai câu, tôi muốn được cho làm lại câu đó cuối lượt, để hiểu chỗ sai trước khi qua bài.
  5. HS, tôi muốn mở rương ở checkpoint đặc biệt, để có phần thưởng nhỏ ăn mừng.
  6. HS được GV giao bài, tôi muốn mở link Zalo → ứng dụng native → Study bài đúng, để không phải tìm.
  7. HS iPad, tôi muốn nghe được cả 2 CDN (Cloudflare/Cloudfront) khi 1 bên nghẽn, để không bị treo audio.

3Phạm vi kỹ thuật

Có trong repo apps/study

RouteNội dungComponent chínhKích thước
/[locale]Bản đồ level (home)ExamLessonPageView562 LOC
/[locale]/[id]Phòng học bài (id = checkPointId)ExamLessonDetailsView1.420 LOC
/[locale]/result/[id]Màn kết quả (id = userAttemptId)ExamLessonResultView + ModalViewResult758 LOC
/api/pingHealth check trả pong
/api/imgproxy/[...path]Proxy ảnh qua imgproxy service; fallback về /_next/image

Vay từ exam shell (KHÔNG có trong repo Study)

Stack ngắn gọn

Framework

  • Next.js 16.2 (Ứng dụng Router) + Turbopack
  • React 19, TypeScript
  • Node 20.15.1 (runtime container)
  • Output standalone → Docker image port 3000

Runtime

  • Apollo Client 3.13 + Apollo Nextjs 0.12
  • Jotai (state), next-intl (i18n)
  • Tailwind 4 + Antd 6 (mixed)
  • Framer Motion + Rive + Lottie

4Luồng sản phẩm

┌────────────────────────────────────────────────────────────┐
│                 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)
  └────────────────────────────┘

Chi tiết từng bước

  1. Vào home: server-side fetch DuolingoPaths từ v2 GraphQL (kèm X-Service-Token), transform về shape viewerDuolingoPaths. Rive /animations/map_mover.riv load ngay (preload trong layout).
  2. Chọn level: nếu chưa đăng nhập → mở RequireLoginModal. Nếu là Chest → chuyển tới ExamChestsView. Nếu là BingoSubject → gọi ViewerAIConversationSessionCreate rồi redirect ra Bingo.
  3. Vào phòng học: query StudyCheckPointQuery lấy list part; gọi duolingoStartAttempt lấy userAttemptId. Nếu URL có ?target[]= thì gọi GetAttemptObjectivesLesson.
  4. Chạy từng part: useLearningFlow tăng currentPartIndexAtom; sau khi trả lời gọi DuolingoSubmitPartAttempt. Part sai được đẩy vào retryPartsAtom.
  5. Lượt retry: khi qua hết part lần 1, chạy lại từng part trong retryParts đến khi rỗng. Ngoại lệ: checkpoint Flashcards không retry.
  6. Kết thúc: gọi DuolingoEndAttempt, chuyển đến /result/[userAttemptId].
  7. Màn kết quả: query 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).

Deep-link về ứng dụng native khi có assignTestCode

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.

5Kho part type — 14 renderer × 30 challenge subtype × 6 story element

Study có ba tầng phân loại câu hỏi: (a) 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.

a) 14 DuolingoPartType — mỗi loại một thư mục renderer

EnumRenderer folderMô tả ngắnNhập liệu
DRAG_DROPpart-type/DragDropKéo từ thả vào ô đúngChuột / cảm ứng
FILL_IN_BLANKpart-type/FillInBlankĐiền ô trống trong câuBàn phím
FILL_LETTERpart-type/FillLetterĐiền chữ cái vào từ (Pre-A1)Bàn phím ảo
FLASHCARDpart-type/FlashCardThẻ đơn từ vựng, không retryNhấn biết/không
GAP_FILLpart-type/GapFillChọn từ điền vào chỗ trốngNhấn chọn
INTERACTIVE_VIDEOpart-type/InteractiveVideoYouTube kèm câu hỏi giữa videoPlay/Pause + trả lời
MATCHINGpart-type/MatchingPartGhép cặp từ ↔ nghĩa/ảnhNhấn nối
MULTIPLE_CHOICEpart-type/MultipleChoiceTrắc nghiệm 4 đáp ánNhấn chọn
OPEN_ENDEDpart-type/OpenEndedTrả lời tự do (text)Bàn phím
SCRAMBLED_SENTENCEpart-type/ScrambledSentenceXếp lại câu từ mảnhKéo thả
SELECT_OBJECTpart-type/SelectObjectChỉ vào vật đúng trong ảnhNhấn ảnh
SPEAKINGpart-type/SpeakingGhi âm bằng mic, chấm phát âmMicro
STORYpart-type/StoryModeTruyện tương tác nhiều bướcXem & trả lời từng đoạn
WORD_ORDERINGpart-type/WordOrderingSắp xếp từ đúng thứ tựNhấn/kéo từ

b) 30 DuolingoChallengeType — subtype backend

Mỗ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ómChallenge subtype
Đọc/nghe cơ bảnLISTEN, LISTEN_MATCH, LISTEN_TAP, LISTEN_ISOLATION, SELECT, SELECT_TRANSCRIPTION
ẢnhIMAGE_SELECT, IMAGE_MATCH, IMAGE_TAP, IMAGE_TAP_COMPLETE, IMAGE_FILL, IMAGE_SPEAK
Ghép/kéo/sắp xếpMATCH, DRAG_DROP, WORD_ORDERING, TAP_COMPLETE
Điền chữ / dịchFILL_LETTER, TRANSLATE, TRANSLATE_TAP
NóiSPEAK, DIALOGUE, ASSIST
FlashcardFLASHCARDS, FLASHCARDS_FILL
TruyệnSTORY_TAP, STORY_TAP_COMPLETE
Trắc nghiệm khácOPEN_ENDED, SELECT_OBJECT, INTERACTIVE_VIDEO, TRUE_FALSE, YES_NO

c) 6 DuolingoStoryElementType — element bên trong Story Mode

Story 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:

ElementVai tròComponent
HEADERTiêu đề đoạnHeaderStory
LINELời thoại nhân vậtLineCharacter
MULTIPLE_CHOICECâu hỏi trắc nghiệm trong truyệnMultipleChoiceStoryMode
GAP_FILLĐiền từ vào chỗ trốngGapFillStoryMode
POINT_TO_PHRASEChỉ vào cụm từ đúngPointToPhrase
SELECT_PHRASEChọn 1 cụm từ trong đoạnchung StoryMode

d) Ba loại level trên bản đồ (DuolingoLevelType)

TypeMô tảHành vi
RegularBài học thườngNhấn vào → Lesson runner
ChestRương phần thưởngNhấn vào → chuỗi ceremony show-chest → robot-talk → bingo-talk → select-target
BingoSubjectLevel hội thoại AINhấn vào → tạo AIConversationSession → redirect Bingo

6Business rules & chấm điểm

Luồng chạy bài (Learning Flow Engine)

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ấm điểm & sao

Chỉ sốNguồnHiển thị
Sao (0–3)userAttempt.starsEarnedHero màn kết quả + huy hiệu ở map
Độ chính xác %Số part đúng lần đầu / tổng partCounter đếm lên (Counter.tsx)
Mục tiêu (objectives)userAttempt.objectives.passedBadge, có countdown thời gian nếu là mục tiêu thời gian

Danh sách mục tiêu (UserAttemptObjectiveKey)

Live tracker query GetAttemptObjectivesLessonTargetRealTime chạy khi có ?target[]= trong URL (giao bài của GV).

Hai theme bản đồ

ThemeĐiều kiệnSố ô mỗi khối
Candy WorldLevel index < PIRATE_MAP_CONFIG.GATE_LEVEL_INDEX (dev 9, prod 99)7 checkpoint / section
Pirate MapSau khi qua ải gate5 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.

7Data & GraphQL

Endpoint

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.

Full list operation (đúng như trong codegen)

LoạiTênDùng ở đâu
Query (SSR)DuolingoPathsHome page — server render map
QueryViewerDuolingoPathsHome page — hydrate map ở client
QueryAuthProviderViewerSessionQueryXác định user hiện tại
QueryStudyCheckPointQueryLesson runner load part list
QueryUserAttemptLessonQueryResult view load kết quả
QueryGetAttemptObjectivesLessonLấy mục tiêu của attempt
QueryGetAttemptObjectivesLessonTargetRealTimeTrack live target khi có GV giao
QueryGetAttemptLessonStudyModeStudy mode fetcher
MutationStudyStartAttemptMutation (=duolingoStartAttempt)Bắt đầu bài, trả userAttemptId
MutationDuolingoSubmitPartAttemptNộp 1 part
MutationDuolingoEndAttemptKết thúc bài, tính điểm
MutationViewerAIConversationSessionCreateTạo session Bingo AI khi nhấn level chat
MutationAuthProviderViewerLogOutMutationĐăng xuất
MutationAuthProviderViewerSwitchLearnerProfileMutationĐổi profile HS (1 tài khoản nhiều bé)
MutationAuthProviderCreateAnonymousLearnerProfileMutationTạo profile ẩn danh khi HS chưa đăng nhập
MutationAuthProviderUpdateAnonymousLearnerProfileMutationCập nhật profile ẩn danh (thêm avatar, tên)

Fragment

Bảng backend liên quan (Postgres · read-replica cho analytics)

BảngVai tròSố dòng thực
duolingo_pathsPath (cấp cao nhất, hiện chỉ 2: Starters cũ + Candy World Pre A1)2
duolingo_unitsĐơn vị (chương) trong path142
duolingo_levelsLevel (bài) trong unit916
duolingo_partsPart (câu hỏi) — đơn vị chạy19.559
check_point_duolingo_partsBảng nối checkpoint ↔ part; cùng 1 CP có thể xuất hiện ở nhiều level(N/A)
duolingo_level_check_pointsBảng nối level ↔ checkpoint(N/A)
learner_duolingo_levelsTiến độ mỗi HS ở mỗi level(N/A)
learner_duolingo_rollup_levelsRollup theo group(N/A)
learner_all_duolingo_pathsTấ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 Study226k / 30d

8Media stack — nhiều layer cùng lúc

Thư việnVai tròVị trí dùng
HTMLAudioElement + hls.jsNghe HLS .m3u8 từ stream.flyer.us; fast-fail 6s → fallback progressive-mp3lib/audio-source.ts; single-track control qua GlobalAudioStore
HowlerSFX ngắn (pressButton, typing, invalidKey) chạy Web Audio, không chồng lấn audio chínhlib/soundEffects.ts
wavesurfer.jsSóng âm animate trên nút từ vựngcomponent/ButtonWord
RecordRTCGhi âm mic (Speaking part) với StereoAudioRecorderpart-type/Speaking
RiveAnimation nhân vật đi trên bản đồExamLessonPageView + /animations/map_mover.riv
LottieMicro-animation: result, ButtonWord, AudioPlayer, TooltipWithImage, voice_animation trong Story~7 chỗ, dynamic-import cho SSR safety
react-youtubeVideo YouTube nhúng cho INTERACTIVE_VIDEOpart-type/InteractiveVideo
Framer MotionTransition rương/tooltip; đi qua LazyMotionWrapper để tree-shaketoàn ứng dụng

Cơ chế "1 audio 1 lần"

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.

Dual-CDN

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.

9Auth, giao bài & i18n

Auth — HS không đăng nhập vẫn học được (đến khi nộp)

  1. Khi vào lần đầu, AuthProvider gọi AuthProviderCreateAnonymousLearnerProfileMutation, trả về ID.
  2. ID được lưu vào cookie NEXT_PUBLIC_ANONYMOUS_LEARNER_PROFILE_ID ở domain root (.flyer.us hoặc .flyer.vn) → chia sẻ được cho study.exam.flyer.us.
  3. HS có thể lướt map + xem bài. Khi nhấn bắt đầu bài → RequireLoginModal nếu chưa có phiên thật.
  4. Đăng nhập rồi có thể đổi giữa nhiều learner (1 tài khoản nhiều bé) qua AuthProviderViewerSwitchLearnerProfileMutation.

Giao bài (assignTest interop)

URL từ GV: study.flyer.us/vi/{checkPointId}?assignTestCode=XYZ&target[]=COMPLETE_IN_5_MIN&target[]=EXACT_THREE_FIRST_TRY&levelId=…&bookLessonId=…

i18n

Ngôn ngữPrefix URLFile dịch
Tiếng Anh (default)không prefixsrc/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/.

Deploy

BướcChi tiết
Buildpnpm gqlgen && next build --turbopack; output .next/standalone
ContainerNode 20.15.1-slim, chạy node ./server.js UID 1001, port 3000
Env quan trọngNEXT_PUBLIC_APP_API, SERVICE_API_V2, NEXT_PUBLIC_BASE_MEDIA_URL, NEXT_PUBLIC_EXAM_US_URL, NEXT_PUBLIC_PIRATE_GATE_INDEX, X_SERVICE_TOKEN
Ảnhimgproxy service tùy chọn qua IMGPROXY_URL; loader custom (loader.js) fallback về Next Image nếu proxy fail
Deploy targetKubernetes cluster flyer-apps; sibling service imgproxy nằm cùng cluster

10Rủi ro, edge case & tương lai

Rủi ro / nợ kỹ thuật hiện tại

Rủi roMứcGhi chú
ExamLessonDetailsView 1.420 LOCCaoFile 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 levelTrung bìnhCù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ôiCaoContent 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_pathsTrung bìnhStarters (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 StudyCầ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ămTrung bìnhHS ẩ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 authTrung bìnhAuth 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.

Edge case đã xử lý

Ngoài phạm vi / tương lai


← Danh mục PRD · Kiểm chứng số liệu production · System Design teacher.flyer.us · Roadmap chính