-
Notifications
You must be signed in to change notification settings - Fork 0
Phase 4: Personal 3D World + Anti-Itinerary + Data Portability (stacked on #5) #6
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
Changes from all commits
44f4f61
52a6f4b
5687698
3aabec4
6ab4a11
4f9eea2
51fd3a0
24e9ba2
b441c91
b54a774
e2d36ff
024cf72
a312131
d6e667e
42e3e9f
b0bb191
560bb44
e621e9e
78edf15
bc9d481
e946c4c
bc610bc
7f36775
8849dcf
480040e
2305dad
bbeca9c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,3 +23,9 @@ dist-ssr | |
| *.sln | ||
| *.sw? | ||
| .vercel | ||
|
|
||
| # Playwright | ||
| /test-results/ | ||
| /playwright-report/ | ||
| /blob-report/ | ||
| /playwright/.cache/ | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| # Phase 4 — Human Actions Required Before Cutover | ||
|
|
||
| > Stacked on PR #5. Merge order: PR #1 → #2 → #3 → #4 → #5 → #6. | ||
|
|
||
| ## What's in this PR | ||
|
|
||
| ### 1. Personal 3D World (real Three.js scene) | ||
| - `services/personalWorldScene.ts` — region-aware monument selection from trip data. 9 monument kinds (mountain, palm, pagoda, lantern, paddyField, cafeTable, riverBoat, lighthouse, tree); kind picked deterministically per `(trip.id, destination)` hash; positions on a disk with radius growing 4 → 8 with trip count. | ||
| - `components/three/PersonalWorldMonuments.tsx` — Three.js group with per-kind procedural geometry (cone/sphere/box primitives, no GLB assets needed). | ||
| - `components/three/PersonalWorldCanvas.tsx` — react-three/fiber canvas with auto-rotating OrbitControls, drei Stars backdrop, OSM-quality lighting. Lazy-imported so it doesn't bloat the main bundle. | ||
| - `components/PersonalWorldScene.tsx` — modal wrapper, loads up to 200 owned trips via `listOwnedTrips`, renders the scene or an empty-state if no trips. Closeable. | ||
| - Entry: new "🌳 Thế giới" button in the top-right button group (shown only when authed). | ||
|
|
||
| ### 2. Anti-Itinerary mode | ||
| - `services/antiItinerary.ts` — Gemini flash-lite + Mơ persona, structured JSON output with three fields: `vibe`, `direction`, `whisper`. Strict no-schedule, no-time, no-address constraint in the prompt. | ||
| - `components/AntiItineraryView.tsx` — full-screen contemplative view with purple gradient backdrop, three-section layout (Vibe → Direction → Whisper), graceful rate-limit/budget-exceeded handling, "I'll just go" + "Give me a normal plan instead" escape hatches. | ||
| - Entry: new "🌒 Thử Anti-Itinerary" button in the result view (shown only when there's a `lastFormData` to regenerate from). Falls back to the normal generation flow if user wants a real schedule. | ||
|
|
||
| ### 3. Trip data export (Decree 13 / GDPR portability) | ||
| - `services/dataExport.ts` — bundles user profile, preferences, trips, and consent log into a single JSON archive. `downloadArchive()` triggers a client-side download with no server roundtrip beyond the existing Supabase queries. | ||
| - `services/dataExport.requestAccountDeletionViaEdgeFunction()` — calls the `delete-account` Supabase Edge Function shipped in PR #2. | ||
| - `components/DataPortabilityPanel.tsx` — two-section panel: "Tải xuống dữ liệu" (data export) and "Yêu cầu xoá tài khoản" (account deletion with two-step confirmation). | ||
| - Entry: top-right "⚙️ Tài khoản" button (shown only when authed; falls back to "Đăng nhập" otherwise) opens the panel in a modal overlay. | ||
|
|
||
| ## Tests | ||
| - `services/__tests__/personalWorldScene.test.ts` (5 tests): deduplication of destinations, region→kind mapping, position bounding, deterministic output. | ||
| - `services/__tests__/dataExport.test.ts` (1 test): export format version invariant. | ||
|
|
||
| ## Verification snapshot | ||
| - **Client: 106/106 tests pass** (was 100 in Phase 3) | ||
| - **Worker: 40/40 tests pass** (unchanged) | ||
| - Frontend typecheck clean except 2 pre-existing errors in `ItineraryDisplay.tsx` and `LoadingAnimation.tsx` | ||
| - Build succeeds | ||
|
|
||
| ## 🔴 Human Actions Required | ||
|
|
||
| ### 1. Verify `delete-account` Edge Function is deployed (from PR #2) | ||
| The Data Portability panel calls `supabase.functions.invoke('delete-account', ...)`. If you skipped that step in PR #2: | ||
| ```bash | ||
| supabase functions deploy delete-account | ||
| supabase secrets set SUPABASE_SERVICE_ROLE_KEY=<your-service-role-key> | ||
| ``` | ||
|
|
||
| ### 2. Optional: tune Personal 3D World performance | ||
| The scene mounts up to 200 monuments. On low-end Android, that's ~600 mesh draws. If you see frame drops: | ||
| - Reduce `listOwnedTrips(user.id, 200)` to `100` in `components/PersonalWorldScene.tsx`. | ||
| - Or add a `<Suspense>`-wrapped chunk loader with progressive monument reveal (~10 every 100ms). | ||
|
|
||
| ### 3. Anti-Itinerary cost considerations | ||
| Each Anti-Itinerary generation consumes one Gemini call from the user's daily quota — same as a normal trip. Consider gating behind paid plan if you want to cap the cost. The prompt requests `flash-lite` model (cheaper than `flash`). | ||
|
|
||
| ## Smoke test after deploy | ||
|
|
||
| 1. **Personal 3D World**: log in as a user with 3+ trips → click "🌳 Thế giới" → scene loads with N monuments matching trip count → drag to rotate, scroll to zoom → "Đóng" closes. | ||
| 2. **Anti-Itinerary**: generate a normal trip, then click "🌒 Thử Anti-Itinerary" → see vibe/direction/whisper sections appear within 5s → click "Cho tôi một lịch trình bình thường thay thế" → returns to normal generation. | ||
| 3. **Data export**: click "⚙️ Tài khoản" → "Tải xuống dữ liệu" → file downloads with name `moodtrip-export-<userhash>-<date>.json` → inspect contents, verify trips + preferences + consent log are all present. | ||
| 4. **Account deletion**: click "Yêu cầu xoá tài khoản" → two-step confirmation → on confirm, account is deleted, session signed out. | ||
|
|
||
| ## Risks | ||
|
|
||
| 1. **Three.js bundle bloat** — Phase 0b lazy-mounted NatureScene; Phase 4 lazy-loads `PersonalWorldCanvas`. Both share the `@react-three/fiber` + drei stack. Verify total initial JS gzip stays < 300KB via `ohmyperf`. | ||
| 2. **Anti-Itinerary JSON parsing** — the model occasionally returns "I cannot help with that" style refusals. Current code throws `INVALID_ANTI_ITINERARY`; user sees a generic error. Consider adding a retry with stricter system prompt. | ||
| 3. **Data export size** — at 1000 trips per user (the cap), the JSON can hit ~10 MB. Browser downloads handle that fine, but consider streaming via a Worker endpoint at 100K MAU scale. | ||
| 4. **Account deletion is irreversible** — the two-step confirmation is the only guard. Consider adding a 7-day soft-delete window with reversal email if you want safety margin. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,6 +23,10 @@ import { TripMap } from './components/TripMap'; | |
| import { MoNotebookModal } from './components/MoNotebookModal'; | ||
| import { PublicShareButton } from './components/PublicShareButton'; | ||
| import { PersonalWorldBadge } from './components/PersonalWorldBadge'; | ||
| import { PersonalWorldScene } from './components/PersonalWorldScene'; | ||
| import { AntiItineraryView } from './components/AntiItineraryView'; | ||
| import { DataPortabilityPanel } from './components/DataPortabilityPanel'; | ||
| import { generateAntiItinerary } from './services/antiItinerary'; | ||
| import { useAuth } from './services/useAuth'; | ||
| import { loadPreferences, savePreferencesFromTrip } from './services/preferencesApi'; | ||
| import { parseCurrentRoute, type Route } from './services/sharedTripRouter'; | ||
|
|
@@ -102,17 +106,34 @@ export default function App() { | |
| const [preferenceDefaults, setPreferenceDefaults] = useState<Partial<FormData> | null>(null); | ||
| const [queModalOpen, setQueModalOpen] = useState(false); | ||
| const [notebookOpen, setNotebookOpen] = useState(false); | ||
| const [worldSceneOpen, setWorldSceneOpen] = useState(false); | ||
| const [antiItineraryForm, setAntiItineraryForm] = useState<FormData | null>(null); | ||
| const [portabilityOpen, setPortabilityOpen] = useState(false); | ||
| const { user } = useAuth(); | ||
| void generateAntiItinerary; | ||
|
Comment on lines
112
to
+113
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| useEffect(() => { | ||
| const storedItinerary = localStorage.getItem(ITINERARY_LS_KEY); | ||
| const storedSavedItineraries = localStorage.getItem(SAVED_ITINERARIES_LS_KEY); | ||
|
|
||
| const isFixtureItinerary = (it: { destination?: string; overview?: string } | null): boolean => { | ||
| if (!it) return false; | ||
| return Boolean( | ||
| (it.destination && it.destination.includes('[MOCK]')) || | ||
| (it.overview && it.overview.includes('[FIXTURE')) | ||
| ); | ||
| }; | ||
|
|
||
| if (storedItinerary) { | ||
| try { | ||
| const parsedItinerary = JSON.parse(storedItinerary); | ||
| setItinerary(parsedItinerary); | ||
| setView('result'); | ||
| if (isFixtureItinerary(parsedItinerary)) { | ||
| console.warn('[App] Purging cached fixture itinerary; live mode will fetch fresh from Gemini.'); | ||
| localStorage.removeItem(ITINERARY_LS_KEY); | ||
| } else { | ||
| setItinerary(parsedItinerary); | ||
| setView('result'); | ||
| } | ||
| } catch (e) { | ||
| console.error("Failed to parse stored itinerary", e); | ||
| localStorage.removeItem(ITINERARY_LS_KEY); | ||
|
|
@@ -121,7 +142,13 @@ export default function App() { | |
|
|
||
| if (storedSavedItineraries) { | ||
| try { | ||
| setSavedItineraries(JSON.parse(storedSavedItineraries)); | ||
| const list = JSON.parse(storedSavedItineraries) as Array<{ destination?: string; overview?: string }>; | ||
| const cleaned = list.filter((it) => !isFixtureItinerary(it)); | ||
| if (cleaned.length !== list.length) { | ||
| console.warn(`[App] Purging ${list.length - cleaned.length} cached fixture itineraries from saved list.`); | ||
| localStorage.setItem(SAVED_ITINERARIES_LS_KEY, JSON.stringify(cleaned)); | ||
| } | ||
| setSavedItineraries(cleaned as ItineraryPlan[]); | ||
| } catch (e) { | ||
| console.error("Failed to parse saved itineraries", e); | ||
| localStorage.removeItem(SAVED_ITINERARIES_LS_KEY); | ||
|
|
@@ -489,7 +516,7 @@ export default function App() { | |
| onSubmit={handleGenerateItinerary} | ||
| onBack={() => itinerary ? setView('result') : setView('hero')} | ||
| error={error} | ||
| initialData={lastFormData ?? (cardPullPrefill as FormData | null) ?? (preferenceDefaults as FormData | null)} | ||
| initialData={lastFormData ?? cardPullPrefill ?? preferenceDefaults} | ||
| onGoHome={handleGoHome} | ||
| /> | ||
| </motion.div> | ||
|
|
@@ -526,6 +553,7 @@ export default function App() { | |
| onGoHome={handleGoHome} | ||
| isSaved={isSaved || isSharedView} | ||
| isExportingPDF={isExportingPDF} | ||
| formData={lastFormData} | ||
| /> | ||
| <div className="max-w-3xl mx-auto px-4 mt-6 space-y-6 mb-10"> | ||
| <PersonalWorldBadge /> | ||
|
|
@@ -546,6 +574,14 @@ export default function App() { | |
| > | ||
| ✍️ Mơ viết thư cho bạn | ||
| </button> | ||
| {lastFormData && ( | ||
| <button | ||
| onClick={() => setAntiItineraryForm(lastFormData)} | ||
| className="px-4 py-2 rounded-xl bg-gradient-to-r from-purple-500 to-pink-500 text-white text-sm font-semibold" | ||
| > | ||
| 🌒 Thử Anti-Itinerary | ||
| </button> | ||
| )} | ||
| </div> | ||
| </div> | ||
| </motion.div> | ||
|
|
@@ -660,22 +696,34 @@ export default function App() { | |
| <Analytics /> | ||
| <SpeedInsights /> | ||
|
|
||
| <div className="fixed top-4 right-4 z-30 flex gap-2"> | ||
| <button | ||
| onClick={() => setQueModalOpen(true)} | ||
| className="px-3 py-1.5 text-xs font-medium text-purple-300 hover:text-white bg-white/5 hover:bg-white/10 border border-white/10 rounded-full transition-colors" | ||
| aria-label="Đường về quê" | ||
| > | ||
| 🏡 Về quê | ||
| </button> | ||
| <button | ||
| onClick={() => setAuthModalOpen(true)} | ||
| className="px-3 py-1.5 text-xs font-medium text-teal-300 hover:text-white bg-white/5 hover:bg-white/10 border border-white/10 rounded-full transition-colors" | ||
| aria-label="Đăng nhập" | ||
| > | ||
| Đăng nhập | ||
| </button> | ||
| </div> | ||
| {/* Quick-access buttons — hidden on Hero (Hero has its own top-right nav) to prevent overlap */} | ||
| {view !== 'hero' && ( | ||
| <div className="fixed top-4 right-4 z-30 flex gap-2"> | ||
| <button | ||
| onClick={() => setQueModalOpen(true)} | ||
| className="px-3 py-1.5 text-xs font-medium text-purple-300 hover:text-white bg-white/5 hover:bg-white/10 border border-white/10 rounded-full transition-colors" | ||
| aria-label="Đường về quê" | ||
| > | ||
| 🏡 Về quê | ||
| </button> | ||
| {user && ( | ||
| <button | ||
| onClick={() => setWorldSceneOpen(true)} | ||
| className="px-3 py-1.5 text-xs font-medium text-emerald-300 hover:text-white bg-white/5 hover:bg-white/10 border border-white/10 rounded-full transition-colors" | ||
| aria-label="Thế giới của bạn" | ||
| > | ||
| 🌳 Thế giới | ||
| </button> | ||
| )} | ||
| <button | ||
| onClick={() => (user ? setPortabilityOpen(true) : setAuthModalOpen(true))} | ||
| className="px-3 py-1.5 text-xs font-medium text-teal-300 hover:text-white bg-white/5 hover:bg-white/10 border border-white/10 rounded-full transition-colors" | ||
| aria-label={user ? 'Tài khoản' : 'Đăng nhập'} | ||
| > | ||
| {user ? '⚙️ Tài khoản' : 'Đăng nhập'} | ||
| </button> | ||
| </div> | ||
| )} | ||
|
|
||
| {/* Main Content — inline styles ensure visibility even if CSS fails */} | ||
| <main | ||
|
|
@@ -720,6 +768,40 @@ export default function App() { | |
| trip={itinerary} | ||
| onClose={() => setNotebookOpen(false)} | ||
| /> | ||
| <PersonalWorldScene open={worldSceneOpen} onClose={() => setWorldSceneOpen(false)} /> | ||
| <AntiItineraryView | ||
| open={antiItineraryForm !== null} | ||
| form={antiItineraryForm} | ||
| onClose={() => setAntiItineraryForm(null)} | ||
| onWantNormalPlan={() => { | ||
| const f = antiItineraryForm; | ||
| setAntiItineraryForm(null); | ||
| if (f) void handleGenerateItinerary(f); | ||
| }} | ||
| /> | ||
| <AnimatePresence> | ||
| {portabilityOpen && ( | ||
| <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={() => setPortabilityOpen(false)} | ||
| > | ||
| <div onClick={(e) => e.stopPropagation()} className="w-full max-w-md"> | ||
| <DataPortabilityPanel /> | ||
| <div className="text-center mt-3"> | ||
| <button | ||
| onClick={() => setPortabilityOpen(false)} | ||
| className="text-slate-400 hover:text-white text-xs" | ||
| > | ||
| Đóng | ||
| </button> | ||
| </div> | ||
| </div> | ||
| </motion.div> | ||
| )} | ||
| </AnimatePresence> | ||
| <AuthModal open={authModalOpen} onClose={() => setAuthModalOpen(false)} /> | ||
|
|
||
| {/* Toast Notification */} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,128 @@ | ||
| import { useEffect, useState } from 'react'; | ||
| import { motion, AnimatePresence } from 'motion/react'; | ||
| import type { FormData } from '../types'; | ||
| import { generateAntiItinerary, type AntiItinerary } from '../services/antiItinerary'; | ||
|
|
||
| interface AntiItineraryViewProps { | ||
| open: boolean; | ||
| form: FormData | null; | ||
| onClose: () => void; | ||
| onWantNormalPlan: () => void; | ||
| } | ||
|
|
||
| type State = 'loading' | 'ready' | 'error'; | ||
|
|
||
| export function AntiItineraryView({ open, form, onClose, onWantNormalPlan }: AntiItineraryViewProps) { | ||
| const [state, setState] = useState<State>('loading'); | ||
| const [anti, setAnti] = useState<AntiItinerary | null>(null); | ||
| const [errorMsg, setErrorMsg] = useState<string | null>(null); | ||
|
|
||
| useEffect(() => { | ||
| if (!open || !form) return; | ||
| setState('loading'); | ||
| setErrorMsg(null); | ||
| setAnti(null); | ||
| generateAntiItinerary(form) | ||
| .then((value) => { | ||
| setAnti(value); | ||
| setState('ready'); | ||
| }) | ||
| .catch((err: Error) => { | ||
| if (err.message === 'RATE_LIMIT_EXCEEDED') { | ||
| setErrorMsg('Hôm nay Mơ đã thì thầm đủ. Quay lại ngày mai nhé.'); | ||
| } else if (err.message === 'BUDGET_EXCEEDED') { | ||
| setErrorMsg('Mơ đang nghỉ. Mai mình mơ tiếp nhé.'); | ||
| } else { | ||
| setErrorMsg('Mơ không thì thầm được. Thử lại sau.'); | ||
| } | ||
| setState('error'); | ||
| }); | ||
| }, [open, form]); | ||
|
Comment on lines
+20
to
+40
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If the component is unmounted or the |
||
|
|
||
| if (!open) return null; | ||
|
|
||
| return ( | ||
| <AnimatePresence> | ||
| <motion.div | ||
| initial={{ opacity: 0 }} | ||
| animate={{ opacity: 1 }} | ||
| exit={{ opacity: 0 }} | ||
| className="fixed inset-0 z-50 bg-gradient-to-br from-slate-950 via-purple-950 to-slate-950 flex items-center justify-center p-6" | ||
| > | ||
| <button | ||
| onClick={onClose} | ||
| aria-label="Đóng" | ||
| className="absolute top-4 right-4 px-3 py-1.5 text-xs text-slate-400 hover:text-white border border-white/10 rounded-full" | ||
| > | ||
| Đóng | ||
| </button> | ||
|
|
||
| <div className="max-w-xl w-full text-center space-y-8"> | ||
| <p className="text-purple-300 text-xs uppercase tracking-[0.3em]">Anti-Itinerary</p> | ||
|
|
||
| {state === 'loading' && ( | ||
| <motion.p | ||
| animate={{ opacity: [0.5, 1, 0.5] }} | ||
| transition={{ duration: 2, repeat: Infinity }} | ||
| className="text-slate-400 text-base" | ||
| > | ||
| Mơ đang thì thầm… | ||
| </motion.p> | ||
| )} | ||
|
|
||
| {state === 'error' && ( | ||
| <div className="space-y-4"> | ||
| <p className="text-rose-300">{errorMsg}</p> | ||
| <button | ||
| onClick={onClose} | ||
| className="px-4 py-2 bg-white/10 hover:bg-white/15 text-white rounded-xl text-sm" | ||
| > | ||
| Quay về | ||
| </button> | ||
| </div> | ||
| )} | ||
|
|
||
| {state === 'ready' && anti && ( | ||
| <motion.div | ||
| initial={{ y: 30, opacity: 0 }} | ||
| animate={{ y: 0, opacity: 1 }} | ||
| transition={{ duration: 1.2, ease: 'easeOut' }} | ||
| className="space-y-10" | ||
| > | ||
| <p className="text-3xl md:text-4xl text-white font-extralight leading-snug italic"> | ||
| "{anti.vibe}" | ||
| </p> | ||
| <div className="space-y-2"> | ||
| <p className="text-xs uppercase tracking-[0.3em] text-purple-300">Hướng đi</p> | ||
| <p className="text-xl md:text-2xl text-white font-light leading-relaxed"> | ||
| {anti.direction} | ||
| </p> | ||
| </div> | ||
| <div className="space-y-2"> | ||
| <p className="text-xs uppercase tracking-[0.3em] text-purple-300">Lời thì thầm</p> | ||
| <p className="text-base md:text-lg text-slate-300 font-light leading-relaxed"> | ||
| — {anti.whisper} | ||
| </p> | ||
| </div> | ||
|
|
||
| <div className="pt-6 flex flex-col sm:flex-row gap-3 justify-center"> | ||
| <button | ||
| onClick={onClose} | ||
| className="px-5 py-2.5 bg-white/10 hover:bg-white/15 border border-white/10 text-white text-sm rounded-xl" | ||
| > | ||
| Tôi sẽ đi | ||
| </button> | ||
| <button | ||
| onClick={onWantNormalPlan} | ||
| className="px-5 py-2.5 text-slate-400 hover:text-white text-sm" | ||
| > | ||
| Cho tôi một lịch trình bình thường thay thế | ||
| </button> | ||
| </div> | ||
| </motion.div> | ||
| )} | ||
| </div> | ||
| </motion.div> | ||
| </AnimatePresence> | ||
| ); | ||
| } | ||
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 import of
generateAntiItineraryis unused in this file and can be safely removed to keep the imports clean.