Skip to content

Guided Journey · Khi nào dùng, giải phẫu và luật nội dung ​

Một phần của Guided Journey pattern (SRC-1253). Khi nào dùng mẫu Guided Journey, giải phẫu từng phần, luật nội dung, tương tác và a11y, hiện thực, ví dụ, nên và không nên.

1. Khi nào dùng ​

Dùng khi learner cần tự trả lời một câu hỏi lớn ("Mình có kịp không?", "Mình nên bắt đầu từ đâu?") và câu trả lời ghép được từ 3 đến 9 câu nhỏ, mỗi câu một quyết định.

Nhịp của mẫu là Show, Understand, Explore: cho thấy câu hỏi, giúp hiểu câu trả lời, rồi mở đường đi tiếp. Đây là một công cụ để learner tự khám phá mình, không phải một trang nói về Nemo. Chữ trên trang nói về learner; Nemo chỉ xuất hiện như người tính hộ.

Không dùng khi:

  • nội dung là thông tin tra cứu một chiều (bảng giá, lịch, điều khoản): dùng trang thường;
  • chỉ có một câu hỏi: dùng một form hoặc một card;
  • các bước không phụ thuộc nhau và learner nên thấy tất cả cùng lúc: dùng lưới card;
  • trình tự dài hơn khoảng 9 bước: tách thành nhiều journey (như hub learn chia 6 journey thành 3 tầng);
  • là luồng làm bài thi có tính giờ: dùng khung học §6b của DS-001.

2. Giải phẫu ​

Trên learn (mọi trang AI Teen và mọi trang learn.nemo12.com/ielts), "site nav" và thanh (b) là JourneyShell của §9 (SRC-1263): NavBar mang các JOURNEY, thanh tiến độ ngay dưới nêu TÊN từng bước của journey đang đứng (mỗi bước một pill số + tên, SRC-1272), và ở learn /ielts thêm Status Card 3-5 dòng ở góc trên phải vùng nội dung (AI Teen không có, SRC-1272). Sơ đồ dưới là một trang câu hỏi trên www; thanh tròn-và-mũi-tên dưới đây là bản www.

text
+---------------------------------------------------------------+
|  site nav  (learn: NavBar = journeys, xem §9)                  |
+---------------------------------------------------------------+
|  (b) STICKY PROGRESS  (1)-->(2)-->(3)-->(4)..>(5)..>(6)        |
|       filled = done, ring = current, grey = later               |
+---------------------------------------------------------------+
|  (a) HERO                                                      |
|      DISCOVER YOUR IELTS PATH            <- label (small caps)  |
|      Mình cần gì để đạt IELTS?           <- h1 = a question     |
|                                                                |
|  (c) NUMBERED QUESTION LIST                                    |
|  (1) +-----------------------------------------------+         |
|   |  | Mình cần IELTS để làm gì?      v   [ Sửa ]    |  view    |
|   |  | Xét tuyển đại học Việt Nam  <- own answer    |          |
|   |  |-----------------------------------------------| divider  |
|   |  |   (Xét tuyển...) (Ứng tuyển du học) ...       |          |
|   |  +-----------------------------------------------+         |
|  (2) +-----------------------------------------------+         |
|   |  | Mình cần band bao nhiêu?                      |  current |
|   |  | Chọn một con số, Nemo dùng nó...  <- hint     |          |
|   |  |-----------------------------------------------|          |
|   |  |   [-]   6.5   [+]          [Xác nhận]         | (d)      |
|   |  |   +---------------- reveal ---------------+   |          |
|   |  |   | computed answer + source link         |   |          |
|   |  +-----------------------------------------------+         |
|  (3) +-----------------------------------------------+         |
|      | Mình đang ở band mấy?   (dim, locked)         |  later   |
|      +-----------------------------------------------+         |
|                                                                |
|  (g) AHA PORTRAIT                                              |
|      +===============================================+         |
|      | Lộ trình IELTS của bạn  . YOUR IELTS PATH     |         |
|      | Mục tiêu  Band 6.5   |  Hiện tại  Band 5.0    |         |
|      | Khoảng cách 1.5 band |  Thời gian ...         |         |
|      | [ ONE primary CTA ]                            |         |
|      +===============================================+         |
+---------------------------------------------------------------+

(a) Hero ​

Hai dòng, căn giữa: một nhãn nhỏ chữ hoa (tên tiếng Anh của journey, ví dụ Discover Your IELTS Path) và một h1 là một câu hỏi của learner. Không dòng giới thiệu (chủ dự án 06.10.2026, SRC-1260: các câu hỏi tự nói learner sẽ làm gì), không ảnh hero.

(b) Thanh tiến độ dính (sticky progress) ​

  • Các nút tròn đánh số 1..N, màu accent (cam): đặc = đã xong (bg-accent text-white), viền = đang làm (border-2 border-accent text-accent), xám = chưa tới (border border-border text-muted-foreground).
  • Giữa hai nút là icon ArrowRight (lucide); mũi tên chuyển sang text-accent khi bước trước nó đã xong.
  • Dính ngay dưới thanh nav của site: position: sticky, top đo bằng chiều cao của header.n12-nav-deep khi trang đã chạy, đo lại khi resize; nền bg-background/95 backdrop-blur và một đường border-b.
  • Ở 375px chỉ hiện nhãn của card đang mở (khi trang truyền activeId; không thì của bước hiện tại), các bước khác chỉ còn vòng số; chữ tối thiểu 12px.
  • Nhấn card đang mở (activeId, SRC-1266): nút của card đang mở có vòng ring-2 ring-accent, đậm đủ; mọi nút khác mờ opacity-50 (nút đã xong vẫn đặc, chỉ mờ đi), aria-label của nút đang mở thêm ", đang mở". Done/current vẫn do câu trả lời quyết định; cái được nhấn là card đang mở.
  • Bấm một nút: card ấy thành card đang mở (nếu bước đã mở được) rồi cuộn tới nó. Nút không nhảy anchor gốc của trình duyệt (preventDefault, cập nhật #step-id bằng history.replaceState).
  • Bù thanh dính khi cuộn (SRC-1266): scrollToCard(id) đo lúc chạy chiều cao header.n12-nav-deep
    • thanh tiến độ ([data-journey-bar]) (stickyOffset), rồi cuộn tới cardScrollTop(top, scrollY, offset) = mép trên card nằm 12px dưới đáy thanh, ở 1280 lẫn 375. Gọi sau ~60ms để card cũ đã gập xong. Mở trang bằng /ielts#target hay /ielts/consider#price cũng đi đường này (cuộn tức thì). scroll-mt-36 trên card là phương án dự phòng khi chưa có JS. Mỗi nút là một liên kết #step-id có aria-current="step" cho bước hiện tại và aria-label đầy đủ ("2. Mục tiêu · TARGET, đã xong"), để trạng thái không chỉ nói bằng màu. Cả thanh là nav aria-label="Tiến độ".
  • Nguồn: component dùng chung JourneyProgressBar (+ ProgressNodes) ở apps/web/src/site/JourneyProgressBar.tsx (SRC-1256), nhận steps (id, nhãn ngắn), markOf (done/current/later) và onNode; trang gọi tự định nghĩa done/current. Dùng ở hai trang: /ielts (done = đã trả lời, PR #455, gỡ nhầm ở #456, khôi phục ở #464) và /ielts/consider (done = learner đã tự mở thẻ ít nhất một lần, lưu trong sessionStorage; current = thẻ đầu tiên chưa mở; nhãn 1-2 chữ như "Lên điểm", "Học phí"). Đừng bỏ thanh này với lý do "danh sách số đã báo vị trí": chủ dự án giữ nó có chủ ý.

(c) Danh sách câu hỏi đánh số ​

Component NumberedQuestion (apps/web/src/site/NumberedQuestionList.tsx):

  • vòng số size-9 bên trái, đường nối dọc mảnh (w-px bg-border) xuống mục kế, trừ mục cuối;
  • một card bo góc (rounded-card border border-border bg-card) có header là nút disclosure: tiêu đề là câu hỏi bằng giọng nội tâm của learner, dưới nó đúng một dòng tóm tắt, bên phải là chevron xoay 180 độ khi mở;
  • dòng tóm tắt là gợi ý trước khi trả lời (Nemo sẽ cho bạn biết gì) và là câu trả lời / kết quả của chính learner sau khi trả lời (stepSummary);
  • một divider (border-t border-border, data-part="card-divider") tách hai dòng đầu (tiêu đề + tóm tắt) khỏi phần còn lại của card khi card mở (SRC-1260; /ielts/consider cũng có);
  • đúng MỘT card đang mở (SRC-1266): card đang mở (do bấm card, bấm nút trên thanh, hoặc đi tiếp sau khi trả lời) mở và đậm đủ; mọi card khác gập lại và mờ (dim, opacity-50). Mở card khác thì card ấy thành card đang mở. Bấm vào chính card đang mở thì gập nó, lúc ấy không card nào mờ. Card chưa tới bị mờ và khoá (disabled). Chế độ sửa chỉ có ở card đang mở: mở card khác là "Huỷ";
  • ô Sửa cố định (actions, data-part="card-actions"): góc trên bên phải của header, ngang hàng tiêu đề, ngay sau chevron và nằm ngoài nút disclosure (không lồng nút trong nút). Ô có bề rộng giữ sẵn (w-[7.75rem]) đủ cho "Lưu" + "Huỷ", nên đổi "Sửa" thành "Lưu" + "Huỷ" không xê dịch chữ;
  • vòng số theo card đang mở (focus): vòng số của card đang mở sáng lên (ring-2 ring-accent), các vòng số khác mờ (opacity-50). Không truyền focus thì vòng số giữ nguyên. Luật này thay luật scroll focus của #474 (SRC-1260): cuộn trang một mình không còn đổi chỗ nhấn; card đang mở quyết định cả vòng số bên trái lẫn nút trên thanh.

Chế độ của một card trả lời (SRC-1260) ​

Chế độKhi nàoInputNút
lockedbước trước chưa xongkhông hiệnkhông
answercard đang mở, chưa trả lờibật, chọn vào bản nháp"Xác nhận" cuối card (tắt khi chưa đủ)
closedkhông phải card đang mở (mờ)không hiệnchevron
viewcard đang mở, đã trả lờihiện giá trị đã chọn nhưng tắt (chỉ xem) + Reveal"Sửa" trong ô cố định
editsau khi bấm "Sửa"bật, chọn vào bản nháp"Lưu" + "Huỷ" đúng chỗ "Sửa"
  • Chọn chưa phải lưu: mọi input (kể cả chip ở câu 1) chỉ đổi bản nháp; "Xác nhận" / "Lưu" mới ghi vào visitor state và tính lại các bước sau. "Huỷ" bỏ bản nháp, giá trị đã lưu hiện lại. Gập card đang sửa cũng là "Huỷ". Mỗi lúc chỉ một card ở chế độ edit.
  • Bước tính toán không có input (§f) không có ô Sửa; nó có đúng một nút "Đã hiểu" để gập lại và cuộn tới bước kế. Bước có input phụ (ví dụ số giờ mỗi tuần ở timeline) theo bảng trên như mọi bước.

(d) Hé lộ dần: Answer, Reveal, Answer ​

Mỗi bước: learner chọn, ngay dưới đó hiện một khối Reveal (bg-muted/60, rounded-2xl, motion opacity 0 -> 1, y 8 -> 0) chứa điều Nemo vừa tính ra từ câu trả lời ấy. Sau đó card đóng lại, bước kế mở ra và được cuộn vào tầm nhìn (block: "nearest", cuộn mượt trừ khi reduced-motion).

(e) Trí nhớ giữa các bước ​

  • Bước sau nhắc lại câu trả lời trước (khoảng cách tính từ band mục tiêu và band hiện tại), không bao giờ hỏi lại.
  • Câu trả lời lưu trong visitor state (www: sổ n12.visitor.v2, entry ielts, field discover, bản bố mẹ field parentDiscover, §2h; luật ở visitor-state), được kiểm lại mỗi lần đọc. Quay lại trang là thấy đúng chỗ mình đã dừng.
  • Sửa một câu trước thì mọi bước phía sau tính lại từ cùng một hàm thuần; không có số nào lưu cứng.

(f) Bước tính toán không cần nhập ​

Một số bước chỉ có Reveal (ví dụ feasibility: "Mình có kịp không?"). Bước ấy mở cùng lúc với bước cung cấp dữ liệu cuối cùng cho nó và được tính là xong khi đủ dữ liệu.

(g) Kết thúc "Aha" ​

Khi mọi bước xong, hiện một card chân dung (Card viền border-2 border-accent): tiêu đề "của bạn", một lưới dl gom mọi câu trả lời và kết quả, và một CTA chính duy nhất (buttonVariants({ variant: "accent" })). Thêm một dòng nhỏ nói dữ liệu lưu ở đâu và cách sửa.

(h) Tham số giọng: một journey, nhiều người hỏi (SRC-1273) ​

Khi cùng một chuỗi câu hỏi có hai người đọc (learner tự hỏi, bố mẹ hỏi về con), không chép component. Component journey nhận một prop persona và mọi câu khác nhau theo người đọc nằm trong một bảng giọng; phần còn lại dùng chung tuyệt đối.

Đổi theo giọng (bảng giọng)Dùng chung (không bao giờ tách)
nhãn, h1 (câu hỏi trang của đường dẫn ấy), câu hỏi và gợi ý từng bướcdanh sách bước và thứ tự
chữ trong Reveal ("bạn" / "con", "Bạn tự chấm" / "Bố mẹ tự đánh giá")thanh tiến độ, một card đang mở, chế độ xem/sửa, ô Sửa
tiêu đề chân dung và CTA chính duy nhất (nhãn + đích)bù thanh dính khi cuộn, anchor, prerender
model thuần: kiểm câu trả lời, migration, mọi phép tính và nguồn
  • Giọng mặc định là bản đang chạy, và bản ấy phải render y hệt từng byte sau khi thêm tham số (test so với bản trước, hoặc snapshot).
  • Mỗi giọng lưu riêng. Câu trả lời lưu theo persona (www: cùng mục visitor state, mỗi persona một trường), để hai người dùng chung một máy không ghi đè nhau. Bố mẹ ước của con và con tự chấm là hai câu trả lời khác nhau, không phải một.
  • Tự đánh giá ghi đúng người đánh giá: "bạn tự chấm" cho learner, "Bố mẹ tự đánh giá" cho bố mẹ.
  • Dữ liệu từ tài khoản (learn) nói về tài khoản trên máy này, không gán cho "con" hay "bạn" khi không biết ai đang đăng nhập.
  • Hiện thực: IeltsDiscover (persona, bảng VOICE), PARENT_STEP_COPY + stepCopy(persona) trong ieltsDiscoverModel.ts; App.tsx gắn key theo persona để đi qua lại giữa hai trang không mang trạng thái giao diện sang. Luật lưu: visitor-state.

3. Luật nội dung ​

  • Giọng nội tâm của learner (học sinh 16-17 tuổi): "Mình cần band bao nhiêu?", không phải "Khám phá lộ trình đột phá cùng Nemo". Không ngôn ngữ marketing. Trang cho bố mẹ thì là giọng bố mẹ hỏi về con ("Con cần band bao nhiêu?"), cùng luật không marketing (§2h).
  • Một quyết định mỗi card. Hai câu hỏi thì hai card.
  • Tóm tắt một dòng. Dài hơn một dòng là dấu hiệu card đang chứa hai quyết định.
  • Trạng thái rỗng thật thà. Thiếu dữ liệu thì nói thiếu và chỉ đường lấy nó ("Chờ bài chẩn đoán"); không bao giờ bịa số, không đánh dấu "xong" một bước thiếu dữ liệu.
  • Số tính ra phải có nguồn, kèm liên kết (Cambridge PDF, nhịp học NEMO), và một câu nói rõ đó là mức tham chiếu, không phải lời hứa. Tự chấm của learner luôn ghi là "bạn tự chấm", không phải phép đo.
  • Ngôn ngữ theo bề mặt (bảng ngôn ngữ trong CLAUDE.md): www nói tiếng Việt, tên tiếng Anh chỉ là nhãn đứng sau (SRC-829); learn /ielts/** có giao diện tiếng Anh, chữ hướng dẫn của Nemo bằng tiếng Việt (SRC-834, SRC-953).
  • Không em dash; ngày luôn DD.MM.YYYY (hàm ddmmyyyy trong apps/learn/src/ielts/journeys/model.ts).

4. Tương tác và a11y ​

  • Lựa chọn là radio group (RadioChips: role="radiogroup", role="radio", aria-checked, roving tabIndex); phím mũi tên di chuyển và chọn, Tab rời nhóm. Số band dùng nút -/+ và output aria-live="polite".
  • Header card là button có aria-expanded + aria-controls, nên Tab/Enter/Space chạy không cần code thêm.
  • Thứ tự Tab trong một card: header, ô Sửa ("Sửa", hoặc "Huỷ" rồi "Lưu"), rồi input và nút cuối card. Bấm "Sửa" thì focus nhảy vào input đầu tiên đang bật; "Lưu" / "Huỷ" đưa focus về "Sửa". Nút trong ô Sửa và nút cuối card cao tối thiểu 44px (min-h-11, min-h-12).
  • Không còn listener cuộn: chỗ nhấn (vòng số, nút trên thanh) đi theo card đang mở, không theo vị trí cuộn (SRC-1266 thay luật scroll focus của SRC-1260). Chuyển opacity có motion-reduce:transition-none.
  • Motion qua preset m/Appear của Motion.tsx; MotionConfig reducedMotion="user" tự tắt chuyển động, cuộn trang cũng kiểm prefers-reduced-motion.
  • Mọi câu hỏi được prerender cho SEO: bản HTML tĩnh chứa hero và tiêu đề của mọi bước (card đóng vẫn mang câu hỏi), bước 1 mở, vì lúc prerender visitor state là null.
  • Mỗi bước một anchor (li id="target", scroll-mt-24); /ielts#target cuộn tới card ấy sau khi hydrate.
  • Vùng chạm tối thiểu 44px (min-h-11, min-h-12, size-12); thiết kế cho 375px trước, rồi mới sm: trở lên (DS-001 §4c).

5. Hiện thực ​

Stack: motion + shadcn/ui + Tailwind v4 (DS-001 §0); không thêm thư viện.

FileDùng lại gì
apps/web/src/site/NumberedQuestionList.tsxNumberedQuestion: vòng số, đường nối, card disclosure
apps/web/src/site/IeltsDiscover.tsxRadioChips, BandPicker, Reveal, StepCard, Portrait, luồng advance, prop persona + bảng VOICE (§2h)
apps/web/src/site/ieltsDiscoverModel.tskhuôn model thuần: STEPS, STEP_COPY, currentStep, stepDone, stepOpen, stepSummary
apps/web/src/site/IeltsLearnerJourney.tsx + ieltsLearnerJourneyData.tsbiến thể chỉ đọc (/ielts/consider): 9 card, chữ ở file data, khối dữ liệu sống từ API
apps/learn/src/ielts/journeys/NumberedStep.tsxbản learn của cùng hình (token theme sâu của learn): link, disclosure, hoặc active mang CTA
apps/learn/src/ielts/journeys/model.ts + JourneyPages.tsxhub 6 journey / 3 tầng (TIERS, JOURNEYS), trạng thái từ dữ liệu API có sẵn

Token / class: accent (cam, trạng thái xong và CTA), border-border, bg-card, rounded-card, text-ocean-900 (tiêu đề), text-muted-foreground (tóm tắt, chưa tới), bg-muted/60 (Reveal), focus-visible:outline-accent.

Thêm một journey mới:

  1. File data: danh sách bước + STEP_COPY (câu hỏi giọng learner, gợi ý một dòng, nhãn EN).
  2. Model thuần (không React, không fetch, không đồng hồ): đọc/kiểm câu trả lời, currentStep, stepDone, stepSummary, các phép tính có nguồn ghi ngay trong comment đầu file.
  3. Component trang: hero (a) + thanh tiến độ (b) + một NumberedQuestion mỗi bước (c) + Portrait (g).
  4. Lưu câu trả lời vào visitor state (www) hoặc API của learner (learn), có version.
  5. Test, chép từ:
    • apps/web/src/site/ieltsDiscover.test.tsx (thứ tự bước, nhắc lại câu trả lời, sửa thì tính lại, prerender đủ câu hỏi);
    • apps/web/src/site/IeltsLearnerJourney.test.tsx (card disclosure, dữ liệu sống hỏng vẫn còn chữ tĩnh);
    • apps/learn/src/ielts/journeys/model.test.ts (thiếu dữ liệu thì không "xong", không bịa số).

6. Ví dụ đang chạy ​

7. Nên và không nên ​

NênKhông nên
h1 là một câu hỏi của learnerh1 là tên sản phẩm
một quyết định, một dòng tóm tắt mỗi cardgộp hai câu hỏi vào một card
hiện câu trả lời của learner trên card đã xonghỏi lại điều đã biết
nói thiếu dữ liệu và chỉ cách lấyđiền số mẫu hay số đoán
ghi nguồn cho mọi con số tính rađưa ước tính như lời hứa
một CTA chính ở chân dungnhiều nút cùng độ nổi
thử ở 375px trướcđể thanh tiến độ tràn ngang ở điện thoại