Skip to content
Merged
Show file tree
Hide file tree
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 May 26, 2026
52a6f4b
feat(phase-0b): supabase platform + auth + M\u01a1 persona + perf sur…
nhonhgeargame May 26, 2026
5687698
feat(phase-1): F1 Remix v0.5 + F-Card recap + F8 Mood Memory + A2 Car…
nhonhgeargame May 27, 2026
3aabec4
feat(phase-2): F4 Affiliate + F3-Lite Map + A3 Du\u1eddng V\u1ec1 Qu\…
nhonhgeargame May 27, 2026
6ab4a11
feat(phase-3): M\u01a1's Notebook + S\u00f3ng \u0110i + Personal Worl…
nhonhgeargame May 27, 2026
024cf72
fix(ci): unblock PR #1 — redact secret literal + fix typecheck
nhonhgeargame May 27, 2026
a312131
Merge remote-tracking branch 'origin/feature/phase-0a-edge-proxy' int…
nhonhgeargame May 27, 2026
d6e667e
Merge remote-tracking branch 'origin/feature/phase-0b-platform' into …
nhonhgeargame May 27, 2026
42e3e9f
Merge remote-tracking branch 'origin/feature/phase-1-features' into f…
nhonhgeargame May 27, 2026
b0bb191
Merge remote-tracking branch 'origin/feature/phase-2-features' into f…
nhonhgeargame May 27, 2026
e621e9e
fix(ci): allowlist .sisyphus/*.md in gitleaks
nhonhgeargame May 27, 2026
78edf15
Merge remote-tracking branch 'origin/feature/phase-0a-edge-proxy' int…
nhonhgeargame May 27, 2026
bc9d481
Merge remote-tracking branch 'origin/feature/phase-0b-platform' into …
nhonhgeargame May 27, 2026
e946c4c
Merge remote-tracking branch 'origin/feature/phase-1-features' into f…
nhonhgeargame May 27, 2026
bc610bc
Merge remote-tracking branch 'origin/feature/phase-2-features' into f…
nhonhgeargame May 27, 2026
f0d3a0c
Merge remote-tracking branch 'origin/main' into feature/phase-3-stretch
nhonhgeargame May 27, 2026
fd3cff4
Merge remote-tracking branch 'origin/main' into feature/phase-3-stretch
nhonhgeargame May 27, 2026
49882a2
Merge remote-tracking branch 'origin/main' into feature/phase-3-stretch
nhonhgeargame May 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions .sisyphus/PHASE_3_HUMAN_ACTIONS.md
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.
32 changes: 29 additions & 3 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ import { SharedTripView } from './components/SharedTripView';
import { DuongVeQueModal } from './components/DuongVeQueModal';
import { SundayDreamBanner } from './components/SundayDreamBanner';
import { TripMap } from './components/TripMap';
import { MoNotebookModal } from './components/MoNotebookModal';
import { PublicShareButton } from './components/PublicShareButton';
import { PersonalWorldBadge } from './components/PersonalWorldBadge';
import { useAuth } from './services/useAuth';
import { loadPreferences, savePreferencesFromTrip } from './services/preferencesApi';
import { parseCurrentRoute, type Route } from './services/sharedTripRouter';
Expand Down Expand Up @@ -98,6 +101,7 @@ export default function App() {
const [cardPullPrefill, setCardPullPrefill] = useState<Partial<FormData> | null>(null);
const [preferenceDefaults, setPreferenceDefaults] = useState<Partial<FormData> | null>(null);
const [queModalOpen, setQueModalOpen] = useState(false);
const [notebookOpen, setNotebookOpen] = useState(false);
const { user } = useAuth();

useEffect(() => {
Expand Down Expand Up @@ -523,9 +527,26 @@ export default function App() {
isSaved={isSaved || isSharedView}
isExportingPDF={isExportingPDF}
/>
<div className="max-w-3xl mx-auto px-4 mt-6 mb-10">
<h3 className="text-lg font-bold text-white mb-3">Bản đồ hành trình</h3>
<TripMap itinerary={itinerary} />
<div className="max-w-3xl mx-auto px-4 mt-6 space-y-6 mb-10">
<PersonalWorldBadge />

<div>
<h3 className="text-lg font-bold text-white mb-3">Bản đồ hành trình</h3>
<TripMap itinerary={itinerary} />
</div>

<div className="flex flex-wrap items-start gap-3">
<PublicShareButton
itinerary={itinerary}
onRequestSignIn={() => setAuthModalOpen(true)}
/>
<button
onClick={() => setNotebookOpen(true)}
className="px-4 py-2 rounded-xl bg-gradient-to-r from-amber-200 to-amber-300 text-amber-900 text-sm font-semibold"
>
✍️ Mơ viết thư cho bạn
</button>
</div>
</div>
</motion.div>
);
Expand Down Expand Up @@ -694,6 +715,11 @@ export default function App() {
setView('form');
}}
/>
<MoNotebookModal
open={notebookOpen}
trip={itinerary}
onClose={() => setNotebookOpen(false)}
/>
<AuthModal open={authModalOpen} onClose={() => setAuthModalOpen(false)} />

{/* Toast Notification */}
Expand Down
136 changes: 136 additions & 0 deletions components/MoNotebookModal.tsx
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>
`);
Comment on lines +124 to +132

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The printed window is a separate document and does not inherit the Google Fonts <link> tags from index.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>.

Suggested change
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.write(`
<!doctype html>
<html><head><title>Thư của Mơ</title>
<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&family=Be+Vietnam+Pro:wght@400;600&display=swap" rel="stylesheet">
<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();
}
87 changes: 87 additions & 0 deletions components/PersonalWorldBadge.tsx
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>
);
}
Loading
Loading