-
Notifications
You must be signed in to change notification settings - Fork 0
Phase 3: M\u01a1's Notebook + S\u00f3ng \u0110i + Personal World + F1 share + Ti\u1ebfng V\u00f9ng UI (stacked on #4) #5
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
18 commits
Select commit
Hold shift + click to select a range
44f4f61
feat(phase-0a): edge proxy + key rotation + gitleaks + sentry (securi…
nhonhgeargame 52a6f4b
feat(phase-0b): supabase platform + auth + M\u01a1 persona + perf sur…
nhonhgeargame 5687698
feat(phase-1): F1 Remix v0.5 + F-Card recap + F8 Mood Memory + A2 Car…
nhonhgeargame 3aabec4
feat(phase-2): F4 Affiliate + F3-Lite Map + A3 Du\u1eddng V\u1ec1 Qu\…
nhonhgeargame 6ab4a11
feat(phase-3): M\u01a1's Notebook + S\u00f3ng \u0110i + Personal Worl…
nhonhgeargame 024cf72
fix(ci): unblock PR #1 — redact secret literal + fix typecheck
nhonhgeargame a312131
Merge remote-tracking branch 'origin/feature/phase-0a-edge-proxy' int…
nhonhgeargame d6e667e
Merge remote-tracking branch 'origin/feature/phase-0b-platform' into …
nhonhgeargame 42e3e9f
Merge remote-tracking branch 'origin/feature/phase-1-features' into f…
nhonhgeargame b0bb191
Merge remote-tracking branch 'origin/feature/phase-2-features' into f…
nhonhgeargame e621e9e
fix(ci): allowlist .sisyphus/*.md in gitleaks
nhonhgeargame 78edf15
Merge remote-tracking branch 'origin/feature/phase-0a-edge-proxy' int…
nhonhgeargame bc9d481
Merge remote-tracking branch 'origin/feature/phase-0b-platform' into …
nhonhgeargame e946c4c
Merge remote-tracking branch 'origin/feature/phase-1-features' into f…
nhonhgeargame bc610bc
Merge remote-tracking branch 'origin/feature/phase-2-features' into f…
nhonhgeargame f0d3a0c
Merge remote-tracking branch 'origin/main' into feature/phase-3-stretch
nhonhgeargame fd3cff4
Merge remote-tracking branch 'origin/main' into feature/phase-3-stretch
nhonhgeargame 49882a2
Merge remote-tracking branch 'origin/main' into feature/phase-3-stretch
nhonhgeargame File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| # Phase 3 — Human Actions Required Before Cutover | ||
|
|
||
| > Stacked on PR #4. Merge order: PR #1 → #2 → #3 → #4 → #5. | ||
|
|
||
| ## 1. Optional: load handwriting font | ||
|
|
||
| Mơ's Notebook uses a `Caveat`/`Be Vietnam Pro` handwriting fallback stack. For best visual: | ||
|
|
||
| - Add to `index.html` `<head>`: | ||
| ```html | ||
| <link rel="preconnect" href="https://fonts.googleapis.com"> | ||
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | ||
| <link href="https://fonts.googleapis.com/css2?family=Caveat:wght@400;600&display=swap" rel="stylesheet"> | ||
| ``` | ||
| - Or self-host via the same workflow as the existing `Be Vietnam Pro`. | ||
|
|
||
| ## 2. Sóng Đi — no infrastructure needed | ||
|
|
||
| The recorder is fully client-side (WebAudio + MediaRecorder). Recorded audio stays on-device unless you later wire upload to Supabase Storage (not in this PR). | ||
|
|
||
| ## 3. Mơ persona illustration | ||
|
|
||
| `Mơ viết thư cho bạn` currently renders text + SVG doodle placeholders. Once you hire the Vietnamese watercolor illustrator (per FINAL plan), drop replacement SVGs into `services/moNotebook.ts` `DOODLE_SVG_LIBRARY`. | ||
|
|
||
| ## 4. Personal World — Supabase RLS reminder | ||
|
|
||
| `PersonalWorldBadge` calls `listOwnedTrips()` which uses the `trips_owner_all` RLS policy from Phase 0b. Verify this policy is still active in Supabase Studio before relying on the badge to scope correctly. | ||
|
|
||
| ## 5. F1 public share — verify slug uniqueness | ||
|
|
||
| `PublicShareButton` uses `ensurePublicTrip()` which generates a fresh `share_slug` via `generateShareSlug()` if no public version exists. The `UNIQUE` constraint on `share_slug` in the migration catches collisions, but the client doesn't currently retry on collision — extremely rare (10^15 space) but worth flagging. | ||
|
|
||
| ## 6. Smoke test after deploy | ||
|
|
||
| 1. **Mơ's Notebook**: open a result view → click "✍️ Mơ viết thư cho bạn" → letter renders within 8s → click "In / Lưu PDF" → printable layout opens. | ||
| 2. **Personal World badge**: log in as a user with 0 / 1 / 5 / 10+ trips and verify milestone progression. | ||
| 3. **Tiếng Vùng dialect override**: open `About` page (or wherever you mount `<RegionDialectSelector>`), pick "Miền Trung" → generate a trip to Sài Gòn → Mơ should now use central dialect instead of southern. | ||
| 4. **Sóng Đi**: open a result view on a mobile device with mic permission → tap "Bắt đầu ghi" → 5s recording → waveform visualization appears → playback works. | ||
| 5. **F1 public share**: log in, generate a trip, click "🔗 Chia sẻ công khai" → URL returned → open in incognito → SharedTripView loads with itinerary visible. | ||
|
|
||
| ## What this PR ships | ||
|
|
||
| ### Mơ's Notebook — End-of-trip handwritten letter | ||
| - `services/moNotebook.ts`: | ||
| - `composeMoLetter(trip)`: calls Gemini flash-lite with Mơ persona + structured JSON schema | ||
| - `buildDoodleSvg(seed)`: 4 preset SVG doodles (nón lá, cafe, biển, núi) + text fallback | ||
| - `components/MoNotebookModal.tsx`: | ||
| - Letter modal with paper-textured background, Caveat handwriting font | ||
| - In-modal `print()` action that opens a print-friendly window with full letter + doodle | ||
| - Graceful handling of RATE_LIMIT_EXCEEDED / BUDGET_EXCEEDED with VN copy | ||
| - Wired into result view as "✍️ Mơ viết thư cho bạn" CTA | ||
|
|
||
| ### Sóng Đi — Sound postcard (scaffolding) | ||
| - `services/songDi.ts`: | ||
| - Browser feature detection (MediaRecorder + getUserMedia + supported MIME types) | ||
| - `startSoundRecorder({ maxDurationMs, label })`: 5-second cap, cleans up MediaStream tracks | ||
| - `computeWaveform(blob, samples)`: client-side WebAudio decode + peak extraction for visualization | ||
| - `components/SongDiRecorder.tsx`: | ||
| - 5-state machine: idle/requesting/recording/processing/ready | ||
| - Live waveform visualization after recording | ||
| - Audio playback with `<audio controls>` | ||
| - Graceful unsupported-device fallback | ||
| - NOT yet wired into the UI by default — left as a building block. Mount as `<SongDiRecorder destination={trip.destination} />` wherever appropriate (e.g., a "post-trip ritual" page). | ||
|
|
||
| ### Personal World — Trip-derived identity | ||
| - `services/personalWorld.ts`: | ||
| - `buildWorldStats(trips)`: counts trips, unique destinations, regions visited (north/central/south/mekong/highlands), top mood tags | ||
| - 5 milestones: Lá thứ nhất → Cây nhỏ → Bụi tre → Vườn nhỏ → Rừng riêng | ||
| - `components/PersonalWorldBadge.tsx`: | ||
| - Stats grid + progress bar toward next milestone | ||
| - Only renders for authed users with at least 0 trips loaded | ||
| - Mounted in result view above the map | ||
|
|
||
| ### F1 — Public Share Button (finally wired) | ||
| - `components/PublicShareButton.tsx`: | ||
| - One-click "🔗 Chia sẻ công khai" → calls `ensurePublicTrip()` from Phase 1 | ||
| - Returns copyable share URL + 14-day cookie-safe attribution chain | ||
| - Auth-gated (opens AuthModal if anonymous) | ||
| - Mounted next to Mơ Notebook button in result view | ||
|
|
||
| ### Tiếng Vùng — Settings UI | ||
| - `components/RegionDialectSelector.tsx`: | ||
| - 5 options: auto / north / central / south / mekong (with VN dialect samples) | ||
| - Saves to `preferences.region_dialect` via `setRegionDialect()` (added in Phase 2) | ||
| - NOT yet mounted in the app — drop into a future Settings page or About page | ||
| - Backend was wired in Phase 2; this PR adds only the UI control | ||
|
|
||
| ### Tests | ||
| - `services/__tests__/moNotebook.test.ts` (5 tests): SVG doodle output, fallback behavior, XML escaping | ||
| - `services/__tests__/personalWorld.test.ts` (8 tests): regional classification, milestone ladders, mood aggregation, empty trips edge case | ||
| - `services/__tests__/songDi.test.ts` (1 test): feature detection in happy-dom | ||
|
|
||
| ### Verification | ||
| - **Client: 100/100 tests pass** (up from 87) | ||
| - **Worker: 40/40 tests pass** (unchanged) | ||
| - Frontend typecheck clean except 2 pre-existing errors | ||
| - Build succeeds | ||
|
|
||
| ## What this PR does NOT yet do | ||
|
|
||
| - **Personal World 3D visualization** — currently a stats badge only. Real 3D monument placement in NatureScene needs custom Three.js assets and a designer. Deferred. | ||
| - **Sóng Đi server-side stitching into postcard video** — recorder is client-side only. Server-side ffmpeg pipeline (per FINAL plan) needs Fly.io worker. Deferred. | ||
| - **RegionDialectSelector mounted** — component exists but no Settings page mounts it. Easy follow-up. | ||
| - **Mơ Notebook → R2 storage** — letters are ephemeral (modal). To persist, write to `trips.enrichment.mo_letter`. Easy follow-up. | ||
| - **Public share OG meta tags in `index.html`** — same deferral as Phase 1. | ||
| - **Watercolor illustrator artwork** — all visuals are emoji/SVG placeholders. | ||
|
|
||
| ## Risks (Phase 3 specific) | ||
|
|
||
| 1. **Mơ Notebook calls Gemini** — counts against the user's daily quota. Free users get 3 generations/day; a notebook letter consumes 1. Consider gating behind paid plan if cost matters. | ||
| 2. **`composeMoLetter` schema drift** — Gemini sometimes returns slightly different JSON shapes. The parser uses bracket extraction + structure check; retry logic is single-attempt. If this becomes a real reliability issue, port the retry+JSON-extract logic from `geminiService.ts`. | ||
| 3. **Sóng Đi mic permission** — many users will reject mic access. UI gracefully degrades, but you'll see a high dismissal rate in analytics. | ||
| 4. **PersonalWorldBadge fetches 100 trips on mount** — at scale, paginate or use a count-only query. Currently fine for early users (<100 trips/user). | ||
| 5. **`print()` popup blocked** — many browsers block `window.open` from non-user-gestures. The current implementation triggers from click handler so should be fine, but verify on iOS Safari. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,136 @@ | ||
| import { useEffect, useState } from 'react'; | ||
| import { motion, AnimatePresence } from 'motion/react'; | ||
| import { IconX } from './icons'; | ||
| import type { ItineraryPlan } from '../types'; | ||
| import { buildDoodleSvg, composeMoLetter, type MoLetter } from '../services/moNotebook'; | ||
|
|
||
| interface MoNotebookModalProps { | ||
| open: boolean; | ||
| trip: ItineraryPlan | null; | ||
| onClose: () => void; | ||
| } | ||
|
|
||
| export function MoNotebookModal({ open, trip, onClose }: MoNotebookModalProps) { | ||
| const [letter, setLetter] = useState<MoLetter | null>(null); | ||
| const [loading, setLoading] = useState(false); | ||
| const [error, setError] = useState<string | null>(null); | ||
|
|
||
| useEffect(() => { | ||
| if (!open || !trip) return; | ||
| setLoading(true); | ||
| setError(null); | ||
| setLetter(null); | ||
| composeMoLetter(trip) | ||
| .then(setLetter) | ||
| .catch((err: Error) => { | ||
| if (err.message === 'RATE_LIMIT_EXCEEDED') { | ||
| setError('Hôm nay Mơ hết hạn ngạch viết thư rồi. Quay lại ngày mai nhé.'); | ||
| } else if (err.message === 'BUDGET_EXCEEDED') { | ||
| setError('Mơ đang nghỉ. Quay lại ngày mai nhé.'); | ||
| } else { | ||
| setError('Mơ chưa viết được thư lần này. Thử lại sau.'); | ||
| } | ||
| }) | ||
| .finally(() => setLoading(false)); | ||
| }, [open, trip]); | ||
|
|
||
| if (!open) return null; | ||
|
|
||
| return ( | ||
| <AnimatePresence> | ||
| <motion.div | ||
| initial={{ opacity: 0 }} | ||
| animate={{ opacity: 1 }} | ||
| exit={{ opacity: 0 }} | ||
| className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm" | ||
| onClick={onClose} | ||
| > | ||
| <motion.div | ||
| initial={{ scale: 0.95, y: 20 }} | ||
| animate={{ scale: 1, y: 0 }} | ||
| exit={{ scale: 0.95, y: 20 }} | ||
| className="relative w-full max-w-lg max-h-[85vh] overflow-y-auto rounded-3xl border border-amber-200/20" | ||
| style={{ | ||
| background: 'linear-gradient(180deg, #fefcf6 0%, #fff8e7 100%)', | ||
| color: '#3f2e1c', | ||
| fontFamily: 'Caveat, "Be Vietnam Pro", cursive', | ||
| }} | ||
| onClick={(e) => e.stopPropagation()} | ||
| > | ||
| <button | ||
| onClick={onClose} | ||
| aria-label="Đóng" | ||
| className="absolute top-3 right-3 p-1.5 rounded-lg text-amber-700/60 hover:bg-amber-100" | ||
| > | ||
| <IconX className="w-5 h-5" /> | ||
| </button> | ||
|
|
||
| <div className="p-8" id="mo-letter-content"> | ||
| {loading && ( | ||
| <p className="text-center text-amber-700 text-lg" style={{ fontFamily: 'Be Vietnam Pro, sans-serif' }}> | ||
| Mơ đang viết thư cho bạn… | ||
| </p> | ||
| )} | ||
| {error && ( | ||
| <p className="text-rose-600 text-base" style={{ fontFamily: 'Be Vietnam Pro, sans-serif' }}> | ||
| {error} | ||
| </p> | ||
| )} | ||
| {letter && ( | ||
| <> | ||
| <p className="text-2xl mb-4 leading-snug">{letter.greeting}</p> | ||
| {letter.body.map((para, idx) => ( | ||
| <p key={idx} className="text-lg mb-3 leading-relaxed"> | ||
| {para} | ||
| </p> | ||
| ))} | ||
| <p className="text-xl mt-6">{letter.signoff}</p> | ||
| <p className="text-2xl mt-1">— Mơ</p> | ||
| <div | ||
| className="mt-6 flex justify-center" | ||
| dangerouslySetInnerHTML={{ __html: buildDoodleSvg(letter.doodleSeed) }} | ||
| /> | ||
| </> | ||
| )} | ||
| </div> | ||
|
|
||
| {letter && ( | ||
| <div className="px-6 pb-6 flex gap-2" style={{ fontFamily: 'Be Vietnam Pro, sans-serif' }}> | ||
| <button | ||
| onClick={() => printLetter()} | ||
| className="flex-1 px-4 py-2.5 bg-amber-700 hover:bg-amber-800 text-white text-sm font-semibold rounded-xl" | ||
| > | ||
| In / Lưu PDF | ||
| </button> | ||
| <button | ||
| onClick={onClose} | ||
| className="px-4 py-2.5 text-amber-700 text-sm hover:bg-amber-100 rounded-xl" | ||
| > | ||
| Đóng | ||
| </button> | ||
| </div> | ||
| )} | ||
| </motion.div> | ||
| </motion.div> | ||
| </AnimatePresence> | ||
| ); | ||
| } | ||
|
|
||
| function printLetter(): void { | ||
| const node = document.getElementById('mo-letter-content'); | ||
| if (!node) return; | ||
| const win = window.open('', 'mo-letter', 'width=600,height=800'); | ||
| if (!win) return; | ||
| win.document.write(` | ||
| <!doctype html> | ||
| <html><head><title>Thư của Mơ</title> | ||
| <style> | ||
| body { font-family: Caveat, "Be Vietnam Pro", cursive; background: #fefcf6; color: #3f2e1c; padding: 48px; max-width: 600px; margin: 0 auto; } | ||
| p { font-size: 20px; line-height: 1.6; margin: 0 0 12px; } | ||
| svg { display: block; margin: 24px auto; } | ||
| </style></head><body>${node.innerHTML}</body></html> | ||
| `); | ||
| win.document.close(); | ||
| win.focus(); | ||
| win.print(); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| import { useEffect, useState } from 'react'; | ||
| import { motion } from 'motion/react'; | ||
| import { listOwnedTrips } from '../services/tripsApi'; | ||
| import { | ||
| buildWorldStats, | ||
| currentMilestone, | ||
| nextMilestone, | ||
| type PersonalWorldStats, | ||
| } from '../services/personalWorld'; | ||
| import { useAuth } from '../services/useAuth'; | ||
|
|
||
| export function PersonalWorldBadge() { | ||
| const { user } = useAuth(); | ||
| const [stats, setStats] = useState<PersonalWorldStats | null>(null); | ||
|
|
||
| useEffect(() => { | ||
| if (!user) { | ||
| setStats(null); | ||
| return; | ||
| } | ||
| let cancelled = false; | ||
| listOwnedTrips(user.id, 100).then((trips) => { | ||
| if (cancelled) return; | ||
| setStats(buildWorldStats(trips)); | ||
| }); | ||
| return () => { | ||
| cancelled = true; | ||
| }; | ||
| }, [user]); | ||
|
|
||
| if (!user || !stats) return null; | ||
|
|
||
| const current = currentMilestone(stats.tripCount); | ||
| const next = nextMilestone(stats.tripCount); | ||
| const progressTowardsNext = next | ||
| ? Math.min(1, stats.tripCount / next.threshold) | ||
| : 1; | ||
|
|
||
| return ( | ||
| <motion.div | ||
| initial={{ opacity: 0, y: 10 }} | ||
| animate={{ opacity: 1, y: 0 }} | ||
| className="rounded-2xl glass-dark border border-teal-500/20 p-4 max-w-md mx-auto" | ||
| > | ||
| <p className="text-teal-300 text-xs uppercase tracking-wider mb-2">Thế giới MoodTrip của bạn</p> | ||
| <div className="flex items-center gap-3 mb-3"> | ||
| <div className="text-3xl">{current ? '🌳' : '🌱'}</div> | ||
| <div> | ||
| <p className="text-white font-bold text-base"> | ||
| {current?.label ?? 'Chưa có chuyến nào'} | ||
| </p> | ||
| <p className="text-slate-400 text-xs">{current?.description ?? 'Hãy tạo chuyến đầu tiên'}</p> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div className="grid grid-cols-3 gap-2 text-center mb-3 text-xs"> | ||
| <Stat label="Chuyến" value={stats.tripCount} /> | ||
| <Stat label="Điểm đến" value={stats.uniqueDestinations} /> | ||
| <Stat label="Vùng miền" value={stats.regionsVisited.size} /> | ||
| </div> | ||
|
|
||
| {next && ( | ||
| <> | ||
| <div className="h-1.5 rounded-full bg-white/10 overflow-hidden"> | ||
| <motion.div | ||
| initial={{ width: 0 }} | ||
| animate={{ width: `${progressTowardsNext * 100}%` }} | ||
| className="h-full bg-gradient-to-r from-teal-400 to-cyan-400" | ||
| /> | ||
| </div> | ||
| <p className="text-slate-500 text-xs mt-1.5"> | ||
| {next.threshold - stats.tripCount} chuyến nữa đến {next.label} | ||
| </p> | ||
| </> | ||
| )} | ||
| </motion.div> | ||
| ); | ||
| } | ||
|
|
||
| function Stat({ label, value }: { label: string; value: number }) { | ||
| return ( | ||
| <div className="rounded-lg bg-white/5 py-2"> | ||
| <p className="text-white font-bold text-base">{value}</p> | ||
| <p className="text-slate-500 text-[10px] mt-0.5">{label}</p> | ||
| </div> | ||
| ); | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The printed window is a separate document and does not inherit the Google Fonts
<link>tags fromindex.html. To ensure the handwriting font (Caveat/Be Vietnam Pro) renders correctly in the printed PDF, you should include the font stylesheet links in the print window's<head>.