diff --git a/.gitignore b/.gitignore index fc5ae9f..1284798 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,9 @@ dist-ssr *.sln *.sw? .vercel + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/.sisyphus/PHASE_4_HUMAN_ACTIONS.md b/.sisyphus/PHASE_4_HUMAN_ACTIONS.md new file mode 100644 index 0000000..ce77b67 --- /dev/null +++ b/.sisyphus/PHASE_4_HUMAN_ACTIONS.md @@ -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= +``` + +### 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 ``-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--.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. diff --git a/App.tsx b/App.tsx index c330c47..cc89326 100644 --- a/App.tsx +++ b/App.tsx @@ -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 | null>(null); const [queModalOpen, setQueModalOpen] = useState(false); const [notebookOpen, setNotebookOpen] = useState(false); + const [worldSceneOpen, setWorldSceneOpen] = useState(false); + const [antiItineraryForm, setAntiItineraryForm] = useState(null); + const [portabilityOpen, setPortabilityOpen] = useState(false); const { user } = useAuth(); + void generateAntiItinerary; 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} /> @@ -526,6 +553,7 @@ export default function App() { onGoHome={handleGoHome} isSaved={isSaved || isSharedView} isExportingPDF={isExportingPDF} + formData={lastFormData} />
@@ -546,6 +574,14 @@ export default function App() { > ✍️ Mơ viết thư cho bạn + {lastFormData && ( + + )}
@@ -660,22 +696,34 @@ export default function App() { -
- - -
+ {/* Quick-access buttons — hidden on Hero (Hero has its own top-right nav) to prevent overlap */} + {view !== 'hero' && ( +
+ + {user && ( + + )} + +
+ )} {/* Main Content — inline styles ensure visibility even if CSS fails */}
setNotebookOpen(false)} /> + setWorldSceneOpen(false)} /> + setAntiItineraryForm(null)} + onWantNormalPlan={() => { + const f = antiItineraryForm; + setAntiItineraryForm(null); + if (f) void handleGenerateItinerary(f); + }} + /> + + {portabilityOpen && ( + setPortabilityOpen(false)} + > +
e.stopPropagation()} className="w-full max-w-md"> + +
+ +
+
+
+ )} +
setAuthModalOpen(false)} /> {/* Toast Notification */} diff --git a/components/AntiItineraryView.tsx b/components/AntiItineraryView.tsx new file mode 100644 index 0000000..07de352 --- /dev/null +++ b/components/AntiItineraryView.tsx @@ -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('loading'); + const [anti, setAnti] = useState(null); + const [errorMsg, setErrorMsg] = useState(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]); + + if (!open) return null; + + return ( + + + + +
+

Anti-Itinerary

+ + {state === 'loading' && ( + + Mơ đang thì thầm… + + )} + + {state === 'error' && ( +
+

{errorMsg}

+ +
+ )} + + {state === 'ready' && anti && ( + +

+ "{anti.vibe}" +

+
+

Hướng đi

+

+ {anti.direction} +

+
+
+

Lời thì thầm

+

+ — {anti.whisper} +

+
+ +
+ + +
+
+ )} +
+
+
+ ); +} diff --git a/components/DataPortabilityPanel.tsx b/components/DataPortabilityPanel.tsx new file mode 100644 index 0000000..2814045 --- /dev/null +++ b/components/DataPortabilityPanel.tsx @@ -0,0 +1,144 @@ +import { useState } from 'react'; +import { motion, AnimatePresence } from 'motion/react'; +import { useAuth } from '../services/useAuth'; +import { + buildDataExport, + downloadArchive, + requestAccountDeletionViaEdgeFunction, +} from '../services/dataExport'; + +type State = + | { kind: 'idle' } + | { kind: 'exporting' } + | { kind: 'exported'; count: number } + | { kind: 'export-failed'; error: string } + | { kind: 'confirming-deletion' } + | { kind: 'deleting' } + | { kind: 'delete-failed'; error: string } + | { kind: 'deleted' }; + +export function DataPortabilityPanel() { + const { user } = useAuth(); + const [state, setState] = useState({ kind: 'idle' }); + + if (!user) { + return ( +

+ Đăng nhập để xuất dữ liệu hoặc yêu cầu xoá tài khoản. +

+ ); + } + + async function handleExport() { + if (!user) return; + setState({ kind: 'exporting' }); + try { + const archive = await buildDataExport(user); + downloadArchive(archive); + setState({ kind: 'exported', count: archive.trips.length }); + } catch (err) { + setState({ + kind: 'export-failed', + error: err instanceof Error ? err.message : 'Lỗi không xác định', + }); + } + } + + async function handleConfirmDelete() { + setState({ kind: 'deleting' }); + const result = await requestAccountDeletionViaEdgeFunction(); + if (result.ok) { + setState({ kind: 'deleted' }); + } else { + setState({ + kind: 'delete-failed', + error: result.error ?? 'Lỗi không xác định', + }); + } + } + + return ( +
+
+

📦 Xuất dữ liệu của bạn

+

+ Tải về một file JSON chứa toàn bộ lịch trình, sở thích, và đồng ý xử lý dữ liệu của bạn. + Theo Nghị định 13/2023/NĐ-CP, bạn có quyền mang dữ liệu của mình đi bất kỳ lúc nào. +

+ + + {state.kind === 'exported' && ( + + ✓ Đã xuất {state.count} chuyến đi. + + )} + {state.kind === 'export-failed' && ( + + Xuất thất bại: {state.error} + + )} + +
+ +
+ +
+

🗑️ Yêu cầu xoá tài khoản

+

+ Xoá toàn bộ dữ liệu của bạn trên hệ thống MoodTrip. Hành động này không thể hoàn tác. + Theo Nghị định 13/2023/NĐ-CP, dữ liệu sẽ được xoá trong vòng 30 ngày. +

+ {state.kind === 'confirming-deletion' ? ( +
+

+ Bạn chắc chứ? Toàn bộ {user.email ? `(${user.email})` : 'tài khoản'} sẽ biến mất. +

+
+ + +
+
+ ) : state.kind === 'deleted' ? ( +

+ ✓ Tài khoản đã được xoá. Hẹn gặp lại nếu bạn quay lại. +

+ ) : ( + + )} + {state.kind === 'delete-failed' && ( +

Xoá thất bại: {state.error}

+ )} +
+
+ ); +} diff --git a/components/ItineraryDisplay.tsx b/components/ItineraryDisplay.tsx index 518c665..cc81250 100644 --- a/components/ItineraryDisplay.tsx +++ b/components/ItineraryDisplay.tsx @@ -1,5 +1,5 @@ import React, { useState, useRef, useEffect, KeyboardEvent } from 'react'; -import type { ItineraryPlan, TravelTip } from '../types'; +import type { ItineraryPlan, TravelTip, FormData } from '../types'; import { IconFood, IconHotel, IconTip, IconMapPin, IconDownload, IconRestart, IconSun, IconMoon, IconInfo, IconWallet, IconEdit, IconBookmark, IconCheck, IconX, IconThermometer, IconCloudSun, IconShirt, IconAlertTriangle, IconConstruction, IconReceipt, IconFire, IconShare, IconClock, IconHeart } from './icons'; import { Logo } from './Logo'; import { TravelTipsModal } from './TravelTipsModal'; @@ -7,6 +7,10 @@ import { motion } from 'motion/react'; import { ShareModal } from './ShareModal'; import { hapticLight, hapticMedium } from '../services/haptics'; import { TripRecap } from './TripRecap'; +import { TripHeroBanner } from './TripHeroBanner'; +import { TripViewModeToggle, type TripViewMode } from './TripViewModeToggle'; +import { TripDayStoryboard } from './TripDayStoryboard'; +import { TripReelModal } from './TripReelModal'; interface ItineraryDisplayProps { itinerary: ItineraryPlan; @@ -17,6 +21,7 @@ interface ItineraryDisplayProps { onGoHome: () => void; isSaved: boolean; isExportingPDF?: boolean; + formData?: FormData | null; } const InfoCard: React.FC<{ icon: React.ReactNode, title: string, children: React.ReactNode }> = ({ icon, title, children }) => ( @@ -37,13 +42,15 @@ const InfoCard: React.FC<{ icon: React.ReactNode, title: string, children: React ); -export const ItineraryDisplay: React.FC = ({ itinerary, onReset, onExportPDF, onSaveToList, onItineraryChange, onGoHome, isSaved, isExportingPDF }) => { +export const ItineraryDisplay: React.FC = ({ itinerary, onReset, onExportPDF, onSaveToList, onItineraryChange, onGoHome, isSaved, isExportingPDF, formData }) => { const [activeTips, setActiveTips] = useState<{ tips: TravelTip[], venue: string } | null>(null); const [editingTime, setEditingTime] = useState<{ dayIndex: number, itemIndex: number} | null>(null); const [currentTimeValue, setCurrentTimeValue] = useState(''); const [showShareModal, setShowShareModal] = useState(false); const [activeSection, setActiveSection] = useState('timeline'); const [showRecap, setShowRecap] = useState(false); + const [viewMode, setViewMode] = useState('timeline'); + const [showReel, setShowReel] = useState(false); const sectionRefs = useRef>({}); const scrollToSection = (id: string) => { @@ -162,41 +169,78 @@ export const ItineraryDisplay: React.FC = ({ itinerary, o
- {/* Overview */} - -

Hành trình của bạn đã sẵn sàng!

-

{itinerary.overview}

-
- - {/* Section Navigation Tabs */} -
-
- {sections.map((section) => ( - - ))} + setShowReel(true)} /> + +
+
+
+ {sections.map((section) => ( + + ))} +
+
- {/* Timeline */}
{ sectionRefs.current['timeline'] = el; }} className="md:col-span-2 space-y-8 scroll-mt-20"> - {itinerary.timeline.map((day, dayIndex) => ( + {viewMode === 'storyboard' ? ( + itinerary.timeline.map((day, dayIndex) => ( + + )) + ) : viewMode === 'compact' ? ( + itinerary.timeline.map((day, dayIndex) => ( + +
+
+ {dayIndex + 1} +
+
+

{day.day}

+

{day.title}

+
+
+
    + {day.schedule.map((item, i) => ( +
  • + {item.time} +
    +

    {item.activity}

    + {item.venue && ( +

    + + {item.google_maps_link ? ( + {item.venue} + ) : ( + {item.venue} + )} +

    + )} +
    +
  • + ))} +
+
+ )) + ) : ( + itinerary.timeline.map((day, dayIndex) => ( = ({ itinerary, o })}
- ))} + )) + )}
{/* Food */} @@ -538,6 +583,8 @@ export const ItineraryDisplay: React.FC = ({ itinerary, o /> )} + setShowReel(false)} /> + {/* Floating Action Bar */} import('./three/PersonalWorldCanvas')); + +interface PersonalWorldSceneProps { + open: boolean; + onClose: () => void; +} + +export function PersonalWorldScene({ open, onClose }: PersonalWorldSceneProps) { + const { user } = useAuth(); + const [trips, setTrips] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!open || !user) return; + let cancelled = false; + setLoading(true); + listOwnedTrips(user.id, 200) + .then((rows) => { + if (!cancelled) setTrips(rows); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [open, user]); + + if (!open) return null; + + const stats = buildWorldStats(trips); + const scene = buildSceneState(trips, stats); + const milestone = currentMilestone(stats.tripCount); + + return ( + + +
+
+

Thế giới của bạn

+

+ {milestone?.label ?? 'Vùng đất mới'} +

+

+ {stats.tripCount} chuyến · {stats.uniqueDestinations} điểm đến · {scene.monuments.length} công trình +

+
+ +
+ +
+ {loading ? ( +
+ Đang dựng thế giới của bạn… +
+ ) : scene.monuments.length === 0 ? ( +
+
+

🌱

+

Thế giới còn trống

+

+ Tạo chuyến đi đầu tiên — một công trình sẽ xuất hiện ở đây sau mỗi chuyến. +

+
+
+ ) : ( + Đang tải 3D…
}> + + + )} +
+ + + ); +} diff --git a/components/TripDayStoryboard.tsx b/components/TripDayStoryboard.tsx new file mode 100644 index 0000000..e668f29 --- /dev/null +++ b/components/TripDayStoryboard.tsx @@ -0,0 +1,113 @@ +import React from 'react'; +import { motion } from 'motion/react'; +import type { DayPlan } from '../types'; +import { IconMapPin, IconWallet, IconFire, IconSun, IconMoon, IconClock } from './icons'; + +interface TripDayStoryboardProps { + day: DayPlan; + dayIndex: number; +} + +function partOfDay(time: string): 'morning' | 'noon' | 'afternoon' | 'evening' { + const m = time.match(/(\d{1,2})/); + if (!m) return 'morning'; + const h = parseInt(m[1], 10); + if (h < 11) return 'morning'; + if (h < 14) return 'noon'; + if (h < 18) return 'afternoon'; + return 'evening'; +} + +const PART_LABELS: Record = { + morning: { label: 'Sáng', bg: 'from-amber-300/30 via-orange-200/20 to-rose-200/10', accent: 'text-amber-200' }, + noon: { label: 'Trưa', bg: 'from-sky-300/30 via-cyan-200/20 to-teal-200/10', accent: 'text-sky-200' }, + afternoon: { label: 'Chiều', bg: 'from-violet-300/30 via-fuchsia-200/20 to-pink-200/10', accent: 'text-violet-200' }, + evening: { label: 'Tối', bg: 'from-indigo-500/40 via-purple-400/25 to-slate-700/20', accent: 'text-indigo-200' }, +}; + +export const TripDayStoryboard: React.FC = ({ day, dayIndex }) => { + const isNight = day.title.toLowerCase().includes('toi') || day.title.toLowerCase().includes('dem'); + + return ( + +
+
+ {isNight ? : } +
+
+

{day.day}

+

{day.title}

+
+
+ +
+ {day.schedule.map((item, i) => { + const part = partOfDay(item.time); + const meta = PART_LABELS[part]; + return ( + +
+
+
+ + {meta.label} + + + {item.time} + +
+ +

{item.activity}

+ + {item.is_trending && ( +
+ Trending + {item.trending_reason && · {item.trending_reason}} +
+ )} + +
+ {item.venue && ( +
+ + {item.google_maps_link ? ( + + {item.venue} + + ) : ( + {item.venue} + )} +
+ )} + {item.estimated_cost && ( +
+ + {item.estimated_cost} +
+ )} +
+
+ + ); + })} +
+ + ); +}; diff --git a/components/TripForm.tsx b/components/TripForm.tsx index 5a0cdb8..e0a6d17 100644 --- a/components/TripForm.tsx +++ b/components/TripForm.tsx @@ -10,7 +10,7 @@ interface TripFormProps { onBack: () => void; onGoHome: () => void; error?: string | null; - initialData?: FormData | null; + initialData?: Partial | null; } const fadeUp = (delay: number) => ({ @@ -80,9 +80,13 @@ export const TripForm: React.FC = ({ onSubmit, onBack, error, ini if (initialData) { setTripMode(initialData.tripMode || 'long'); setStartLocation(initialData.startLocation || ''); - setDestination(initialData.destination); - setDuration(initialData.duration); - setBudget(initialData.budget); + if (initialData.destination !== undefined) setDestination(initialData.destination); + if (initialData.duration && typeof initialData.duration.days === 'number') { + setDuration(initialData.duration); + } + if (typeof initialData.budget === 'number' && !Number.isNaN(initialData.budget)) { + setBudget(initialData.budget); + } setMoods(initialData.moods || []); setShortMoods(initialData.shortMoods || []); setPersonalNote(initialData.personalNote || ''); diff --git a/components/TripHeroBanner.tsx b/components/TripHeroBanner.tsx new file mode 100644 index 0000000..24dfbd6 --- /dev/null +++ b/components/TripHeroBanner.tsx @@ -0,0 +1,231 @@ +import React, { useMemo } from 'react'; +import { motion } from 'motion/react'; +import type { ItineraryPlan, FormData, Mood } from '../types'; +import { IconMapPin, IconClock, IconWallet, IconFire, IconSparkles } from './icons'; + +interface TripHeroBannerProps { + itinerary: ItineraryPlan; + formData: FormData | null; + onShowReel?: () => void; +} + +const MOOD_VOICE: Record = { + relax: 'thư giãn', + explore: 'khám phá', + nature: 'đắm mình trong thiên nhiên', + romantic: 'lãng mạn', + adventure: 'phiêu lưu', + cultural: 'thấm văn hóa', +}; + +const MOOD_REASON: Record string> = { + relax: (d) => `${d} có nhịp sống chậm rãi, đủ để bạn thở sâu và nghỉ ngơi thật sự.`, + explore: (d) => `Mỗi góc phố ở ${d} đều có thứ để khám phá — không lặp lại một ngày nào.`, + nature: (d) => `Thiên nhiên ${d} rất gần — bạn có thể chạm vào nó trong vòng vài phút từ trung tâm.`, + romantic: (d) => `${d} là nơi của những bữa tối dưới đèn vàng và đi bộ bên nhau không cần nói gì.`, + adventure: (d) => `${d} có đủ địa hình và trải nghiệm để adrenaline lên cao mỗi ngày.`, + cultural: (d) => `${d} có lớp lịch sử dày, mỗi địa danh đều có một câu chuyện đáng nghe.`, +}; + +function moPersonaOpening(itinerary: ItineraryPlan, moods: Mood[]): string { + const destination = itinerary.destination; + const dayCount = itinerary.timeline.length; + const primaryMood = moods[0]; + const moodVoice = primaryMood ? MOOD_VOICE[primaryMood] : 'khám phá'; + const greetings = [ + `Mơ đã chọn ${destination} cho bạn — ${dayCount} ngày để ${moodVoice}.`, + `${destination} đang đợi bạn. Mơ vẽ sẵn ${dayCount} ngày, bạn chỉ việc đi.`, + `Mơ tin ${destination} sẽ vừa ý bạn — ${dayCount} ngày được chăm chút từng giờ.`, + ]; + const idx = (destination.length + dayCount) % greetings.length; + return greetings[idx]; +} + +function deriveWhyReasons(itinerary: ItineraryPlan, formData: FormData | null): string[] { + const reasons: string[] = []; + const moods = formData?.moods || []; + const destination = itinerary.destination; + + if (moods.length > 0) { + const fn = MOOD_REASON[moods[0]]; + if (fn) reasons.push(fn(destination)); + } + + const trendingCount = itinerary.timeline + .flatMap(d => d.schedule) + .filter(s => s.is_trending).length; + if (trendingCount > 0) { + reasons.push(`${trendingCount} điểm đang trending — bạn ghé đúng lúc cộng đồng còn nhắc.`); + } + + if (itinerary.food && itinerary.food.length >= 3) { + const firstFood = itinerary.food[0]?.name; + reasons.push(`Ẩm thực được Mơ chọn lọc — bắt đầu với ${firstFood} là đã đáng đi rồi.`); + } + + if (itinerary.budget_summary && formData?.budget) { + const totalStr = itinerary.budget_summary.total_estimated.replace(/[^\d]/g, ''); + const totalNum = parseInt(totalStr, 10); + if (!Number.isNaN(totalNum) && totalNum > 0 && totalNum <= formData.budget * (formData.duration?.days || 1)) { + reasons.push(`Hành trình nằm gọn trong ngân sách bạn đặt — không phá ví.`); + } + } + + if (reasons.length < 3 && itinerary.timeline.length > 0) { + const totalActivities = itinerary.timeline.reduce((sum, d) => sum + d.schedule.length, 0); + reasons.push(`${totalActivities} hoạt động được sắp theo nhịp tự nhiên — không vội, không phí giờ.`); + } + + if (reasons.length < 3) { + reasons.push(`Mỗi ngày có thời tiết, giá cả và mẹo di chuyển riêng — bạn không phải tự tra cứu thêm.`); + } + + return reasons.slice(0, 3); +} + +function computeVitals(itinerary: ItineraryPlan) { + const totalActivities = itinerary.timeline.reduce((sum, d) => sum + d.schedule.length, 0); + const trendingCount = itinerary.timeline + .flatMap(d => d.schedule) + .filter(s => s.is_trending).length; + return { + days: itinerary.timeline.length, + activities: totalActivities, + trending: trendingCount, + totalCost: itinerary.budget_summary?.total_estimated || null, + }; +} + +export const TripHeroBanner: React.FC = ({ itinerary, formData, onShowReel }) => { + const opening = useMemo( + () => moPersonaOpening(itinerary, formData?.moods || []), + [itinerary, formData] + ); + const reasons = useMemo( + () => deriveWhyReasons(itinerary, formData), + [itinerary, formData] + ); + const vitals = useMemo(() => computeVitals(itinerary), [itinerary]); + + return ( +
+ +
+
+
+ +
+ + + Hành trình từ Mơ + + + + {itinerary.destination} + + + + “{opening}” + + + +
+
+ Số ngày +
+
{vitals.days}
+
+
+
+ Hoạt động +
+
{vitals.activities}
+
+
+
+ Trending +
+
{vitals.trending}
+
+
+
+ Tổng dự kiến +
+
{vitals.totalCost || '—'}
+
+
+ + + {itinerary.overview} + + + {onShowReel && ( + + 📱 Tạo Reel để khoe bạn bè + + )} +
+ + + + {reasons.map((reason, i) => ( + +
+ {i + 1} +
+

{reason}

+
+ ))} +
+
+ ); +}; diff --git a/components/TripReelModal.tsx b/components/TripReelModal.tsx new file mode 100644 index 0000000..5ac44d8 --- /dev/null +++ b/components/TripReelModal.tsx @@ -0,0 +1,210 @@ +import React, { useMemo } from 'react'; +import { motion, AnimatePresence } from 'motion/react'; +import type { ItineraryPlan } from '../types'; +import { IconX, IconDownload } from './icons'; + +interface TripReelModalProps { + itinerary: ItineraryPlan; + open: boolean; + onClose: () => void; +} + +const W = 1080; +const H = 1920; + +function topHighlights(itinerary: ItineraryPlan, n: number): Array<{ time: string; title: string; venue: string | null }> { + const all = itinerary.timeline.flatMap((d) => + d.schedule.map((s) => ({ time: s.time, title: s.activity, venue: s.venue ?? null, trending: !!s.is_trending })) + ); + const trending = all.filter((a) => a.trending); + const rest = all.filter((a) => !a.trending); + return [...trending, ...rest].slice(0, n); +} + +function paletteFor(destination: string): { c1: string; c2: string; c3: string } { + const palettes = [ + { c1: '#0ea5a4', c2: '#0369a1', c3: '#1e1b4b' }, + { c1: '#f59e0b', c2: '#dc2626', c3: '#831843' }, + { c1: '#a855f7', c2: '#ec4899', c3: '#581c87' }, + { c1: '#10b981', c2: '#0d9488', c3: '#064e3b' }, + { c1: '#6366f1', c2: '#8b5cf6', c3: '#312e81' }, + ]; + const idx = (destination.charCodeAt(0) + destination.length) % palettes.length; + return palettes[idx]; +} + +function escapeXml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function buildReelSvg(itinerary: ItineraryPlan): string { + const dest = escapeXml(itinerary.destination); + const days = itinerary.timeline.length; + const activities = itinerary.timeline.reduce((s, d) => s + d.schedule.length, 0); + const cost = itinerary.budget_summary?.total_estimated || '—'; + const highlights = topHighlights(itinerary, 4); + const { c1, c2, c3 } = paletteFor(itinerary.destination); + + const highlightItems = highlights + .map((h, i) => { + const y = 1180 + i * 130; + const title = escapeXml(h.title.length > 38 ? h.title.slice(0, 36) + '…' : h.title); + const venue = h.venue ? escapeXml((h.venue.length > 32 ? h.venue.slice(0, 30) + '…' : h.venue)) : ''; + return ` + + + ${escapeXml(h.time)} + ${title} + ${venue ? `📍 ${venue}` : ''} + `; + }) + .join(''); + + return ` + + + + + + + + + + + + + + + + + + + + + + + + MOODTRIP + + + HÀNH TRÌNH CỦA TÔI + ${dest} + + + + NGÀY + ${days} + + + HOẠT ĐỘNG + ${activities} + + + DỰ KIẾN + ${escapeXml(cost)} + + + ĐIỂM NHẤN + ${highlightItems} + + + Tạo bởi Mơ ✨ + moodtrip.app + +`; +} + +export const TripReelModal: React.FC = ({ itinerary, open, onClose }) => { + const svg = useMemo(() => buildReelSvg(itinerary), [itinerary]); + const dataUrl = useMemo(() => `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`, [svg]); + + const handleDownload = () => { + const blob = new Blob([svg], { type: 'image/svg+xml;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + const slug = itinerary.destination.normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-zA-Z0-9]+/g, '-').toLowerCase(); + a.download = `moodtrip-${slug}-reel.svg`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 1000); + }; + + const handleCopyImage = async () => { + try { + const blob = new Blob([svg], { type: 'image/svg+xml;charset=utf-8' }); + await navigator.clipboard.write([new ClipboardItem({ 'image/svg+xml': blob })]); + return; + } catch (writeErr) { + console.warn('[reel] clipboard.write unavailable, falling back to text', writeErr); + } + try { + await navigator.clipboard.writeText(svg); + } catch (textErr) { + console.warn('[reel] clipboard unavailable', textErr); + } + }; + + return ( + + {open && ( + + e.stopPropagation()} + > + + + {`Reel + +
+ + +
+ +

+ File 1080×1920, đăng được trực tiếp lên Instagram Reels, TikTok, Facebook Story. +

+
+
+ )} +
+ ); +}; diff --git a/components/TripViewModeToggle.tsx b/components/TripViewModeToggle.tsx new file mode 100644 index 0000000..569c869 --- /dev/null +++ b/components/TripViewModeToggle.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { motion } from 'motion/react'; + +export type TripViewMode = 'timeline' | 'storyboard' | 'compact'; + +interface TripViewModeToggleProps { + mode: TripViewMode; + onChange: (mode: TripViewMode) => void; +} + +const MODES: Array<{ id: TripViewMode; label: string; icon: string; hint: string }> = [ + { id: 'timeline', label: 'Lịch trình', icon: '📋', hint: 'Theo giờ' }, + { id: 'storyboard', label: 'Storyboard', icon: '🎬', hint: 'Phóng to' }, + { id: 'compact', label: 'Gọn', icon: '📑', hint: 'Cho điện thoại' }, +]; + +export const TripViewModeToggle: React.FC = ({ mode, onChange }) => { + return ( +
+ {MODES.map((m) => { + const active = m.id === mode; + return ( + + ); + })} +
+ ); +}; diff --git a/components/three/PersonalWorldCanvas.tsx b/components/three/PersonalWorldCanvas.tsx new file mode 100644 index 0000000..7f492ec --- /dev/null +++ b/components/three/PersonalWorldCanvas.tsx @@ -0,0 +1,34 @@ +import { Canvas } from '@react-three/fiber'; +import { OrbitControls, Stars } from '@react-three/drei'; +import { PersonalWorldMonuments } from './PersonalWorldMonuments'; +import type { Monument } from '../../services/personalWorldScene'; + +interface PersonalWorldCanvasProps { + monuments: Monument[]; + ringRadius: number; +} + +export default function PersonalWorldCanvas({ monuments, ringRadius }: PersonalWorldCanvasProps) { + const camDist = ringRadius * 1.8; + return ( + + + + + + + + ); +} diff --git a/components/three/PersonalWorldMonuments.tsx b/components/three/PersonalWorldMonuments.tsx new file mode 100644 index 0000000..6680298 --- /dev/null +++ b/components/three/PersonalWorldMonuments.tsx @@ -0,0 +1,143 @@ +import { useMemo } from 'react'; +import type { Monument } from '../../services/personalWorldScene'; + +interface PersonalWorldMonumentsProps { + monuments: Monument[]; +} + +const COLOR_BY_KIND: Record = { + mountain: '#0d9488', + palm: '#22c55e', + pagoda: '#f97316', + lantern: '#fbbf24', + paddyField: '#84cc16', + cafeTable: '#06b6d4', + riverBoat: '#0ea5e9', + lighthouse: '#f59e0b', + tree: '#16a34a', +}; + +export function PersonalWorldMonuments({ monuments }: PersonalWorldMonumentsProps) { + const items = useMemo(() => monuments, [monuments]); + return ( + + {items.map((m) => ( + + ))} + + + + + + ); +} + +function Monument3D({ monument }: { monument: Monument }) { + const color = COLOR_BY_KIND[monument.kind] ?? '#94a3b8'; + switch (monument.kind) { + case 'mountain': + return ( + + + + + ); + case 'palm': + return ( + + + + + + + + + + + ); + case 'pagoda': + return ( + + + + + + + + + + + + + + + ); + case 'lantern': + return ( + + + + + + + + + + + ); + case 'paddyField': + return ( + + + + + ); + case 'cafeTable': + return ( + + + + + + + + + + + ); + case 'riverBoat': + return ( + + + + + ); + case 'lighthouse': + return ( + + + + + + + + + + + ); + case 'tree': + default: + return ( + + + + + + + + + + + ); + } +} diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000..4d41f47 --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,65 @@ +# E2E Test Suite + +Playwright end-to-end tests for MoodTrip's critical user flows. + +## What it covers + +| Spec | Focus | +|---|---| +| `hero-and-consent.spec.ts` | Landing page, Decree 13 consent banner, top-right button non-overlap | +| `card-pull-flow.spec.ts` | Phase 1 A2 card-pull onboarding (Rút quẻ du lịch), manual fallback, TripForm crash regression | +| `create-trip.spec.ts` | Full create-trip happy path → result view with hero banner + vitals + reasons | +| `result-enhancements.spec.ts` | View-mode toggle, Reel modal, Reel SVG download, section nav, floating action bar | + +## How it works + +The suite uses `MOCK_ITINERARY=1` (via `playwright.config.ts` `webServer`), which makes the Vite dev-edge-proxy plugin +return a fixture itinerary instead of calling Gemini. This keeps the suite: +- Fast (no real LLM call) +- Deterministic (same fixture every run) +- Free (no Gemini quota burn) + +In production / dev with a real `GEMINI_API_KEY` in `.env.local`, the same flow uses real Gemini — only the e2e +spawned dev server is mocked. + +## Prerequisites + +```bash +npm install # already installed +npx playwright install # one-time: download chromium for your platform +``` + +## Run + +```bash +npm run test:e2e # headless, all specs +npm run test:e2e:ui # interactive UI mode for debugging +``` + +To run a single spec: + +```bash +npx playwright test e2e/create-trip.spec.ts +``` + +To run against a custom dev server URL: + +```bash +E2E_BASE_URL=http://localhost:5173 E2E_NO_WEBSERVER=1 npm run test:e2e +``` + +## CI + +The config respects `CI=1`: +- `forbidOnly` = true (no `.only` left in code) +- `retries` = 2 +- `webServer.reuseExistingServer` = false (always fresh) + +## Environment-specific browser + +If Playwright's bundled chromium doesn't run in your environment (e.g. wrong arch, missing libs), point at your +system browser: + +```bash +CHROME_PATH=/usr/bin/google-chrome npx playwright test +``` diff --git a/e2e/_helpers.ts b/e2e/_helpers.ts new file mode 100644 index 0000000..fd4b4d8 --- /dev/null +++ b/e2e/_helpers.ts @@ -0,0 +1,29 @@ +import type { Page } from '@playwright/test'; + +export async function acceptConsentIfPresent(page: Page): Promise { + const btn = page.locator('button:has-text("Tôi đồng ý")'); + if (await btn.first().isVisible({ timeout: 1500 }).catch(() => false)) { + await btn.first().click(); + await page.waitForTimeout(300); + } +} + +export async function preacceptConsent(page: Page): Promise { + await page.addInitScript(() => { + try { + const stored = { + version: '2026-05-26-v1', + scopes: ['ai_generation_cross_border', 'analytics_anonymous', 'storage_local'], + acceptedAt: Date.now(), + }; + localStorage.setItem('moodtrip_consent_v1', JSON.stringify(stored)); + } catch (err) { + console.warn('[e2e] localStorage unavailable', err); + } + }); +} + +export async function gotoHome(page: Page): Promise { + await page.goto('/', { waitUntil: 'networkidle' }); + await page.waitForTimeout(2500); +} diff --git a/e2e/card-pull-flow.spec.ts b/e2e/card-pull-flow.spec.ts new file mode 100644 index 0000000..65fa039 --- /dev/null +++ b/e2e/card-pull-flow.spec.ts @@ -0,0 +1,54 @@ +import { test, expect } from '@playwright/test'; +import { preacceptConsent, gotoHome } from './_helpers'; + +test.describe('Card-pull onboarding (Phase 1 A2)', () => { + test.beforeEach(async ({ page }) => { + await preacceptConsent(page); + }); + + test('Khám phá ngay leads to card-pull view', async ({ page }) => { + await gotoHome(page); + await page.locator('button:has-text("Khám phá ngay")').first().click(); + await page.waitForTimeout(2500); + + await expect(page.locator('text=RÚT QUẺ DU LỊCH')).toBeVisible(); + await expect(page.locator('text=NGUYÊN TỐ')).toBeVisible(); + await expect(page.locator('text=NHỊP')).toBeVisible(); + await expect(page.locator('text=BẠN ĐI CÙNG')).toBeVisible(); + }); + + test('Manual fallback button is present and reaches TripForm', async ({ page }) => { + await gotoHome(page); + await page.locator('button:has-text("Khám phá ngay")').first().click(); + await page.waitForTimeout(2500); + + const manual = page.locator('button:has-text("Tôi muốn chọn thủ công")'); + await expect(manual).toBeVisible(); + await manual.first().click(); + await page.waitForTimeout(2500); + + await expect(page.locator('text=ĐỊA ĐIỂM')).toBeVisible(); + await expect(page.locator('text=THỜI GIAN')).toBeVisible(); + await expect(page.locator('text=NGÂN SÁCH MỖI NGƯỜI')).toBeVisible(); + }); + + test('TripForm does not crash with partial initial data (regression: duration.days)', async ({ page }) => { + const errors: string[] = []; + page.on('pageerror', (e) => errors.push(e.message)); + + await gotoHome(page); + await page.locator('button:has-text("Khám phá ngay")').first().click(); + await page.waitForTimeout(2500); + await page.locator('button:has-text("Tôi muốn chọn thủ công")').first().click(); + await page.waitForTimeout(2500); + + const dayValue = await page + .locator('text=NGÀY') + .first() + .locator('xpath=ancestor::*[1]/following-sibling::*[1]') + .innerText() + .catch(() => ''); + expect(dayValue).toMatch(/[12]/); + expect(errors.filter((e) => e.includes('duration'))).toEqual([]); + }); +}); diff --git a/e2e/create-trip.spec.ts b/e2e/create-trip.spec.ts new file mode 100644 index 0000000..db0019c --- /dev/null +++ b/e2e/create-trip.spec.ts @@ -0,0 +1,70 @@ +import { test, expect } from '@playwright/test'; +import { preacceptConsent, gotoHome } from './_helpers'; + +test.describe('Create trip happy path (MOCK_ITINERARY)', () => { + test.beforeEach(async ({ page }) => { + await preacceptConsent(page); + }); + + test('Hero → card-pull → manual form → fill → submit → result with hero banner', async ({ page }) => { + const errors: string[] = []; + const apiCalls: Array<{ url: string; status: number }> = []; + page.on('pageerror', (e) => errors.push(e.message)); + page.on('response', (r) => { + const u = r.url(); + if (u.includes('/v1/')) apiCalls.push({ url: u, status: r.status() }); + }); + + await gotoHome(page); + await page.locator('button:has-text("Khám phá ngay")').first().click(); + await expect(page.locator('button:has-text("Tôi muốn chọn thủ công")').first()).toBeVisible({ timeout: 10_000 }); + await page.locator('button:has-text("Tôi muốn chọn thủ công")').first().click(); + await expect(page.locator('input[placeholder*="AI gợi ý"]').first()).toBeVisible({ timeout: 10_000 }); + + const dest = page.locator('input[placeholder*="AI gợi ý"]').first(); + await dest.fill('Đà Lạt'); + + const submit = page.locator('button:has-text("Tạo hành trình")').first(); + await submit.scrollIntoViewIfNeeded(); + await submit.click({ force: true }); + + await expect(page.locator('text=HÀNH TRÌNH TỪ MƠ')).toBeVisible({ timeout: 60_000 }); + + const vitalsLabels = ['Số ngày', 'Hoạt động', 'Trending', 'Tổng dự kiến']; + for (const label of vitalsLabels) { + await expect(page.getByText(label, { exact: true }).first()).toBeVisible(); + } + + await expect(page.locator('button:has-text("Tạo Reel")')).toBeVisible(); + + const generate = apiCalls.find((c) => c.url.includes('/v1/generate')); + expect(generate).toBeDefined(); + expect(generate?.status).toBe(200); + + const anon = apiCalls.find((c) => c.url.includes('/v1/anon-token')); + expect(anon).toBeDefined(); + expect(anon?.status).toBe(200); + + expect(errors).toEqual([]); + }); + + test('Result view shows 3 "why you will love it" reasons', async ({ page }) => { + await gotoHome(page); + await page.locator('button:has-text("Khám phá ngay")').first().click(); + await expect(page.locator('button:has-text("Tôi muốn chọn thủ công")').first()).toBeVisible({ timeout: 10_000 }); + await page.locator('button:has-text("Tôi muốn chọn thủ công")').first().click(); + await expect(page.locator('input[placeholder*="AI gợi ý"]').first()).toBeVisible({ timeout: 10_000 }); + + const dest = page.locator('input[placeholder*="AI gợi ý"]').first(); + await dest.fill('Đà Lạt'); + await page.locator('button:has-text("Tạo hành trình")').first().click({ force: true }); + await expect(page.locator('text=HÀNH TRÌNH TỪ MƠ')).toBeVisible({ timeout: 60_000 }); + + const reasons = await page.evaluate(() => { + const bodyText = document.body.innerText; + const matches = bodyText.match(/[1-3]\s*\n\n[^\n]+/g) || []; + return matches.length; + }); + expect(reasons).toBeGreaterThanOrEqual(3); + }); +}); diff --git a/e2e/hero-and-consent.spec.ts b/e2e/hero-and-consent.spec.ts new file mode 100644 index 0000000..4ff0b00 --- /dev/null +++ b/e2e/hero-and-consent.spec.ts @@ -0,0 +1,62 @@ +import { test, expect } from '@playwright/test'; +import { gotoHome } from './_helpers'; + +test.describe('Hero landing + consent', () => { + test('renders brand, tagline, and CTA', async ({ page }) => { + await gotoHome(page); + + await expect(page.locator('text=MoodTrip').first()).toBeVisible(); + await expect(page.locator('text=Để cảm xúc dẫn đường').first()).toBeVisible(); + await expect(page.locator('button:has-text("Khám phá ngay")').first()).toBeVisible(); + }); + + test('shows Decree 13 consent banner on first visit', async ({ page }) => { + await gotoHome(page); + await expect(page.locator('text=Nghị định 13')).toBeVisible({ timeout: 5000 }); + await expect(page.locator('button:has-text("Tôi đồng ý")')).toBeVisible(); + }); + + test('consent banner dismisses on accept and persists', async ({ page, context }) => { + await gotoHome(page); + await page.locator('button:has-text("Tôi đồng ý")').first().click(); + await page.waitForTimeout(500); + await expect(page.locator('text=Nghị định 13')).not.toBeVisible(); + + const value = await context.storageState(); + const ls = value.origins.flatMap((o) => o.localStorage); + const consent = ls.find((kv) => kv.name === 'moodtrip_consent_v1'); + expect(consent).toBeDefined(); + }); + + test('top-right buttons do not overlap with Hero nav', async ({ page }) => { + await gotoHome(page); + const overlaps = await page.evaluate(() => { + const els = Array.from(document.querySelectorAll('button, a')) + .map((el) => { + const r = el.getBoundingClientRect(); + const cs = window.getComputedStyle(el); + return { r, vis: r.width > 0 && r.height > 0 && parseFloat(cs.opacity) > 0.5 }; + }) + .filter((b) => b.vis && b.r.top >= 0 && b.r.top < 70 && b.r.right > window.innerWidth - 500); + const pairs: number[] = []; + for (let i = 0; i < els.length; i++) { + for (let j = i + 1; j < els.length; j++) { + const a = els[i].r; + const b = els[j].r; + if (a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom) { + pairs.push(1); + } + } + } + return pairs.length; + }); + expect(overlaps).toBe(0); + }); + + test('no JS errors on initial load', async ({ page }) => { + const errors: string[] = []; + page.on('pageerror', (e) => errors.push(e.message)); + await gotoHome(page); + expect(errors).toEqual([]); + }); +}); diff --git a/e2e/result-enhancements.spec.ts b/e2e/result-enhancements.spec.ts new file mode 100644 index 0000000..a7c0a00 --- /dev/null +++ b/e2e/result-enhancements.spec.ts @@ -0,0 +1,101 @@ +import { test, expect } from '@playwright/test'; +import { preacceptConsent, gotoHome } from './_helpers'; + +async function createTrip(page: import('@playwright/test').Page): Promise { + await page.locator('button:has-text("Khám phá ngay")').first().click(); + await expect(page.locator('button:has-text("Tôi muốn chọn thủ công")').first()).toBeVisible({ timeout: 10_000 }); + await page.locator('button:has-text("Tôi muốn chọn thủ công")').first().click(); + await expect(page.locator('input[placeholder*="AI gợi ý"]').first()).toBeVisible({ timeout: 10_000 }); + const dest = page.locator('input[placeholder*="AI gợi ý"]').first(); + await dest.fill('Đà Lạt'); + const submit = page.locator('button:has-text("Tạo hành trình")').first(); + await submit.scrollIntoViewIfNeeded(); + await submit.click({ force: true }); + await expect(page.locator('text=HÀNH TRÌNH TỪ MƠ')).toBeVisible({ timeout: 60_000 }); +} + +test.describe('Result view enhancements', () => { + test.beforeEach(async ({ page }) => { + await preacceptConsent(page); + await gotoHome(page); + await createTrip(page); + }); + + test('View mode toggle exposes Timeline / Storyboard / Compact', async ({ page }) => { + await expect(page.locator('button:has-text("Storyboard")')).toBeVisible(); + await expect(page.locator('button:has-text("Gọn")')).toBeVisible(); + await expect(page.locator('button[title="Theo giờ"]')).toBeVisible(); + }); + + test('Storyboard mode renders without errors', async ({ page }) => { + const errors: string[] = []; + page.on('pageerror', (e) => errors.push(e.message)); + await page.locator('button:has-text("Storyboard")').first().click(); + await page.waitForTimeout(1500); + await expect(page.locator('text=Ngày 1').first()).toBeVisible(); + expect(errors).toEqual([]); + }); + + test('Compact mode renders without errors', async ({ page }) => { + const errors: string[] = []; + page.on('pageerror', (e) => errors.push(e.message)); + await page.locator('button:has-text("Gọn")').first().click(); + await page.waitForTimeout(1500); + await expect(page.locator('text=Ngày 1').first()).toBeVisible(); + expect(errors).toEqual([]); + }); + + test('Reel modal opens with SVG preview', async ({ page }) => { + await page.locator('button:has-text("Tạo Reel")').first().click(); + await page.waitForTimeout(1500); + const reelImg = page.locator('img[alt*="Reel preview"]'); + await expect(reelImg).toBeVisible(); + const src = await reelImg.getAttribute('src'); + expect(src).toMatch(/^data:image\/svg\+xml/); + }); + + test('Reel modal close button works', async ({ page }) => { + await page.locator('button:has-text("Tạo Reel")').first().click(); + await page.waitForTimeout(1000); + const closeBtn = page.locator('button[aria-label="Đóng"]').first(); + await expect(closeBtn).toBeVisible(); + await closeBtn.click(); + await page.waitForTimeout(800); + await expect(page.locator('img[alt*="Reel preview"]')).not.toBeVisible(); + }); + + test('Reel SVG download produces a 9:16 file', async ({ page }) => { + await page.locator('button:has-text("Tạo Reel")').first().click(); + await page.waitForTimeout(1000); + + const downloadPromise = page.waitForEvent('download'); + await page.locator('button:has-text("Tải về")').first().click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toMatch(/^moodtrip-.*-reel\.svg$/); + + const path = await download.path(); + expect(path).not.toBeNull(); + if (path) { + const fs = await import('node:fs/promises'); + const text = await fs.readFile(path, 'utf-8'); + expect(text).toContain('width="1080"'); + expect(text).toContain('height="1920"'); + expect(text).toContain('MOODTRIP'); + } + }); + + test('Section nav scrolls to anchor sections', async ({ page }) => { + const foodTab = page.locator('button:has-text("Ẩm thực")').first(); + await foodTab.click(); + await page.waitForTimeout(800); + await expect(page.locator('text=Món ăn nên thử').first()).toBeInViewport({ ratio: 0.05 }); + }); + + test('Floating action bar shows Save, PDF, Share, Kỷ niệm, Mới', async ({ page }) => { + await expect(page.locator('button:has-text("Lưu"), button:has-text("Đã lưu")').first()).toBeVisible(); + await expect(page.locator('button:has-text("PDF")').first()).toBeVisible(); + await expect(page.locator('button:has-text("Chia sẻ")').first()).toBeVisible(); + await expect(page.locator('button:has-text("Kỷ niệm")').first()).toBeVisible(); + await expect(page.locator('button:has-text("Mới")').first()).toBeVisible(); + }); +}); diff --git a/package-lock.json b/package-lock.json index d70262f..d2d1dd4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "three": "^0.183.1" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@types/node": "^22.14.0", "@types/react": "^19.1.8", "@types/react-dom": "^19.2.3", @@ -2464,6 +2465,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@posthog/core": { "version": "1.29.11", "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.29.11.tgz", @@ -8131,6 +8148,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/package.json b/package.json index 3b71b97..ed97713 100644 --- a/package.json +++ b/package.json @@ -11,12 +11,16 @@ "test:watch": "vitest", "test:coverage": "vitest run --coverage", "test:worker": "npm test --prefix workers/edge-proxy", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", "typecheck": "tsc --noEmit" }, "dependencies": { "@google/genai": "^1.8.0", "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.5.0", + "@sentry/react": "^8.45.0", + "@supabase/supabase-js": "^2.46.0", "@tailwindcss/vite": "^4.2.1", "@types/three": "^0.183.1", "@sentry/react": "^8.45.0", @@ -26,6 +30,7 @@ "@vercel/speed-insights": "^1.2.0", "maplibre-gl": "^4.7.0", "motion": "^12.34.3", + "posthog-js": "^1.180.0", "react": "^19.1.0", "react-dom": "^19.2.4", "react-markdown": "^10.1.0", @@ -34,6 +39,7 @@ "three": "^0.183.1" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@types/node": "^22.14.0", "@types/react": "^19.1.8", "@types/react-dom": "^19.2.3", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..f347f9d --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,49 @@ +import { defineConfig, devices } from '@playwright/test'; + +const PORT = process.env.E2E_PORT ? parseInt(process.env.E2E_PORT, 10) : 5174; +const BASE_URL = process.env.E2E_BASE_URL || `http://127.0.0.1:${PORT}`; + +export default defineConfig({ + testDir: './e2e', + testMatch: /.*\.spec\.ts/, + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, + reporter: [['list']], + timeout: 90_000, + expect: { timeout: 10_000 }, + use: { + baseURL: BASE_URL, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + actionTimeout: 10_000, + navigationTimeout: 30_000, + }, + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + viewport: { width: 1280, height: 900 }, + launchOptions: process.env.CHROME_PATH + ? { + executablePath: process.env.CHROME_PATH, + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'], + } + : undefined, + }, + }, + ], + webServer: process.env.E2E_NO_WEBSERVER + ? undefined + : { + command: `MOCK_ITINERARY=1 npm run dev -- --host 127.0.0.1 --port ${PORT} --strictPort`, + url: `http://127.0.0.1:${PORT}/`, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + stdout: 'pipe', + stderr: 'pipe', + }, +}); diff --git a/services/__tests__/dataExport.test.ts b/services/__tests__/dataExport.test.ts new file mode 100644 index 0000000..303c38e --- /dev/null +++ b/services/__tests__/dataExport.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from 'vitest'; +import { EXPORT_FORMAT_VERSION } from '../dataExport'; + +describe('EXPORT_FORMAT_VERSION', () => { + it('is dated string, semver-ish', () => { + expect(EXPORT_FORMAT_VERSION).toMatch(/^\d{4}-\d{2}-\d{2}-v\d+$/); + }); +}); diff --git a/services/__tests__/personalWorldScene.test.ts b/services/__tests__/personalWorldScene.test.ts new file mode 100644 index 0000000..9dc8435 --- /dev/null +++ b/services/__tests__/personalWorldScene.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; +import { buildMonuments, buildSceneState } from '../personalWorldScene'; +import { buildWorldStats } from '../personalWorld'; +import type { TripRecord } from '../tripsApi'; + +function makeTrip(destination: string, id = `t-${Math.random()}`): TripRecord { + return { + id, + ownerId: 'u1', + destination, + tripMode: 'long', + itinerary: { destination, overview: '', timeline: [], food: [], accommodation: [], tips: [] }, + formInput: null, + isPublic: false, + shareSlug: null, + parentRemixId: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; +} + +describe('buildMonuments', () => { + it('returns one monument per unique destination', () => { + const monuments = buildMonuments([ + makeTrip('Đà Lạt', 't1'), + makeTrip('Đà Lạt', 't2'), + makeTrip('Hà Nội', 't3'), + makeTrip('Cần Thơ', 't4'), + ]); + expect(monuments.length).toBe(3); + expect(new Set(monuments.map((m) => m.destinationLabel.toLowerCase())).size).toBe(3); + }); + + it('classifies regions to kind pools deterministically', () => { + const monuments = buildMonuments([ + makeTrip('Đà Lạt', 'fixed-1'), + makeTrip('Hà Nội', 'fixed-2'), + makeTrip('Cần Thơ', 'fixed-3'), + ]); + const byDest = Object.fromEntries(monuments.map((m) => [m.destinationLabel, m])); + expect(['mountain', 'paddyField', 'tree']).toContain(byDest['Đà Lạt']!.kind); + expect(['pagoda', 'lantern', 'tree']).toContain(byDest['Hà Nội']!.kind); + expect(['riverBoat', 'paddyField', 'palm']).toContain(byDest['Cần Thơ']!.kind); + }); + + it('positions every monument within disk radius 6', () => { + const monuments = buildMonuments(Array.from({ length: 30 }, (_, i) => makeTrip(`Place ${i}`, `id-${i}`))); + for (const m of monuments) { + const r = Math.hypot(m.position[0], m.position[2]); + expect(r).toBeLessThanOrEqual(6.01); + } + }); + + it('produces stable output for stable input', () => { + const trips = [makeTrip('Đà Lạt', 'stable-1'), makeTrip('Huế', 'stable-2')]; + const a = buildMonuments(trips); + const b = buildMonuments(trips); + expect(a).toEqual(b); + }); +}); + +describe('buildSceneState', () => { + it('grows ring radius with monument count, capped at 8', () => { + const small = buildMonuments([makeTrip('A', 'a')]); + const huge = buildMonuments(Array.from({ length: 50 }, (_, i) => makeTrip(`P${i}`, `id-${i}`))); + const statsSmall = buildWorldStats([makeTrip('A', 'a')]); + const statsHuge = buildWorldStats(Array.from({ length: 50 }, (_, i) => makeTrip(`P${i}`, `id-${i}`))); + const sceneSmall = buildSceneState([makeTrip('A', 'a')], statsSmall); + const sceneHuge = buildSceneState( + Array.from({ length: 50 }, (_, i) => makeTrip(`P${i}`, `id-${i}`)), + statsHuge, + ); + expect(sceneSmall.ringRadius).toBeGreaterThan(0); + expect(sceneHuge.ringRadius).toBeLessThanOrEqual(8); + expect(huge.length).toBeGreaterThan(small.length); + }); +}); diff --git a/services/antiItinerary.ts b/services/antiItinerary.ts new file mode 100644 index 0000000..8769aef --- /dev/null +++ b/services/antiItinerary.ts @@ -0,0 +1,70 @@ +import type { FormData, Mood } from '../types'; +import { EdgeProxyError, extractText, generate } from './edgeProxyClient'; +import { buildMoSystemPrompt, detectRegion } from './moPersona'; + +export interface AntiItinerary { + vibe: string; + direction: string; + whisper: string; +} + +const STRICT_JSON = 'CHỈ trả về JSON hợp lệ. Không markdown, không lời dẫn, không giải thích.'; + +const SCHEMA = `Trả về JSON đúng cấu trúc: +{ + "vibe": string (1 câu cảm xúc rất ngắn, không mô tả lịch trình), + "direction": string (1 hướng đi cụ thể nhưng không có địa chỉ — VD: "đi về phía tây cho tới khi thấy cánh cửa vàng"), + "whisper": string (1 câu thì thầm của Mơ, mang tính nội tâm — không có lời khuyên thực dụng) +}`; + +function buildPrompt(form: FormData): string { + const moods = (form.moods.length ? form.moods : (form.shortMoods ?? []).map(String) as Mood[]).slice(0, 3).join(', '); + const dest = form.destination?.trim() || 'một vùng đất chưa rõ tên'; + return `Người dùng đang cần Anti-Itinerary cho ${dest}. Cảm xúc hôm nay: ${moods || 'mơ hồ'}. + +YÊU CẦU: Tuyệt đối KHÔNG tạo schedule, KHÔNG ghi giờ, KHÔNG liệt kê địa điểm cụ thể. Đây là "phản lịch trình" — chỉ một cảm giác, một hướng đi, và một câu thì thầm. + +${SCHEMA} + +${STRICT_JSON}`; +} + +function tryParse(raw: string): T { + const trimmed = raw.trim(); + const first = trimmed.indexOf('{'); + const last = trimmed.lastIndexOf('}'); + const candidate = first !== -1 && last > first ? trimmed.slice(first, last + 1) : trimmed; + return JSON.parse(candidate) as T; +} + +export async function generateAntiItinerary(form: FormData): Promise { + const region = detectRegion(form.destination); + const systemInstruction = buildMoSystemPrompt({ destination: form.destination, region }); + + try { + const response = await generate( + [{ role: 'user', parts: [{ text: buildPrompt(form) }] }], + { + model: 'flash-lite', + systemInstruction: { parts: [{ text: systemInstruction }] }, + generationConfig: { + temperature: 0.95, + maxOutputTokens: 1024, + responseMimeType: 'application/json', + }, + }, + ); + const text = extractText(response); + const parsed = tryParse(text); + if (!parsed.vibe || !parsed.direction || !parsed.whisper) { + throw new Error('INVALID_ANTI_ITINERARY'); + } + return parsed; + } catch (err) { + if (err instanceof EdgeProxyError) { + if (err.code === 'RATE_LIMIT_EXCEEDED') throw new Error('RATE_LIMIT_EXCEEDED'); + if (err.code === 'BUDGET_EXCEEDED') throw new Error('BUDGET_EXCEEDED'); + } + throw err; + } +} diff --git a/services/dataExport.ts b/services/dataExport.ts new file mode 100644 index 0000000..0d972d4 --- /dev/null +++ b/services/dataExport.ts @@ -0,0 +1,88 @@ +import type { User } from '@supabase/supabase-js'; +import { getSupabase } from './supabaseClient'; +import { listOwnedTrips, type TripRecord } from './tripsApi'; +import { loadPreferences, type PreferenceProfile } from './preferencesApi'; +import { CONSENT_VERSION, readLocalConsent } from './consent'; + +export const EXPORT_FORMAT_VERSION = '2026-05-26-v1'; + +export interface DataExportArchive { + formatVersion: string; + generatedAt: string; + user: { + id: string; + email: string | null; + createdAt: string | null; + }; + preferences: PreferenceProfile | null; + trips: TripRecord[]; + consent: { + version: string; + localScopes: string[]; + acceptedAt: number | null; + }; + meta: { + tripCount: number; + note: string; + }; +} + +export async function buildDataExport(user: User): Promise { + const [trips, preferences] = await Promise.all([ + listOwnedTrips(user.id, 1000), + loadPreferences(user.id), + ]); + + const localConsent = readLocalConsent(); + + return { + formatVersion: EXPORT_FORMAT_VERSION, + generatedAt: new Date().toISOString(), + user: { + id: user.id, + email: user.email ?? null, + createdAt: user.created_at ?? null, + }, + preferences, + trips, + consent: { + version: CONSENT_VERSION, + localScopes: localConsent?.scopes ?? [], + acceptedAt: localConsent?.acceptedAt ?? null, + }, + meta: { + tripCount: trips.length, + note: 'MoodTrip data export — Nghị định 13/2023/NĐ-CP Article 11 (right to data portability).', + }, + }; +} + +export function downloadArchive(archive: DataExportArchive): void { + const json = JSON.stringify(archive, null, 2); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `moodtrip-export-${archive.user.id.slice(0, 8)}-${archive.generatedAt.slice(0, 10)}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} + +export interface DeletionRequest { + ok: boolean; + error: string | null; +} + +export async function requestAccountDeletionViaEdgeFunction(): Promise { + const supabase = getSupabase(); + if (!supabase) return { ok: false, error: 'Supabase not configured' }; + const { data, error } = await supabase.functions.invoke('delete-account', { body: {} }); + if (error) return { ok: false, error: error.message }; + if (data && typeof data === 'object' && 'ok' in (data as object)) { + await supabase.auth.signOut(); + return { ok: true, error: null }; + } + return { ok: false, error: 'Unexpected response' }; +} diff --git a/services/edgeProxyClient.ts b/services/edgeProxyClient.ts index 9dd709c..549b5ef 100644 --- a/services/edgeProxyClient.ts +++ b/services/edgeProxyClient.ts @@ -1,8 +1,9 @@ import { getSupabaseAccessToken } from './authSession'; -const EDGE_PROXY_URL = - (typeof import.meta !== 'undefined' && (import.meta as { env?: Record }).env?.VITE_EDGE_PROXY_URL) || - 'https://api.moodtrip.app'; +const VITE_ENV = (typeof import.meta !== 'undefined' ? (import.meta as { env?: Record }).env : undefined) || {}; +const IS_DEV = Boolean(VITE_ENV.DEV); +const CONFIGURED_PROXY_URL = typeof VITE_ENV.VITE_EDGE_PROXY_URL === 'string' ? (VITE_ENV.VITE_EDGE_PROXY_URL as string) : ''; +const EDGE_PROXY_URL = CONFIGURED_PROXY_URL || (IS_DEV ? '' : 'https://api.moodtrip.app'); const ANON_TOKEN_LS_KEY = 'moodtrip_anon_token_v1'; const ANON_TOKEN_EXPIRY_LS_KEY = 'moodtrip_anon_token_expiry_v1'; @@ -90,7 +91,7 @@ export interface GeminiContent { export interface GeminiGenerateResponse { candidates?: Array<{ - content?: { parts?: Array<{ text?: string }> }; + content?: { parts?: Array<{ text?: string; thought?: boolean }> }; finishReason?: string; }>; usageMetadata?: { @@ -158,7 +159,8 @@ export async function generate( } export function extractText(response: GeminiGenerateResponse): string { - const part = response.candidates?.[0]?.content?.parts?.[0]?.text; - if (!part) throw new EdgeProxyError('Empty response', 'EMPTY_RESPONSE', 200); - return part; + const parts = response.candidates?.[0]?.content?.parts; + const answerPart = parts?.find((p) => !p.thought && typeof p.text === 'string' && p.text.length > 0); + if (!answerPart?.text) throw new EdgeProxyError('Empty response', 'EMPTY_RESPONSE', 200); + return answerPart.text; } diff --git a/services/geminiService.ts b/services/geminiService.ts index 2b48631..1f6c733 100644 --- a/services/geminiService.ts +++ b/services/geminiService.ts @@ -300,11 +300,12 @@ async function callProxyForItinerary(prompt: string, destination: string): Promi [{ role: 'user', parts: [{ text: prompt }] }], { model: 'flash', - systemInstruction: { parts: [{ text: buildSystemInstruction(destination) }] }, + systemInstruction: { role: 'system', parts: [{ text: buildSystemInstruction(destination) }] }, generationConfig: { temperature: 0.7, - maxOutputTokens: 16384, + maxOutputTokens: 8192, responseMimeType: 'application/json', + thinkingConfig: { thinkingBudget: 0 }, }, }, ); diff --git a/services/personalWorldScene.ts b/services/personalWorldScene.ts new file mode 100644 index 0000000..c7932e0 --- /dev/null +++ b/services/personalWorldScene.ts @@ -0,0 +1,111 @@ +import type { PersonalWorldStats } from './personalWorld'; +import type { TripRecord } from './tripsApi'; + +export type MonumentKind = + | 'mountain' + | 'palm' + | 'pagoda' + | 'lantern' + | 'paddyField' + | 'cafeTable' + | 'riverBoat' + | 'lighthouse' + | 'tree'; + +export interface Monument { + id: string; + kind: MonumentKind; + position: [number, number, number]; + scale: number; + rotation: number; + destinationLabel: string; +} + +const REGION_TO_KIND: Record = { + north: ['pagoda', 'lantern', 'tree'], + central: ['lantern', 'pagoda', 'cafeTable'], + south: ['palm', 'lighthouse', 'cafeTable'], + mekong: ['riverBoat', 'paddyField', 'palm'], + highlands: ['mountain', 'paddyField', 'tree'], + unknown: ['tree'], +}; + +const REGION_KEYWORDS: Array<[keyof typeof REGION_TO_KIND, RegExp]> = [ + ['north', /(hà nội|sapa|hạ long|ninh bình|hải phòng|nam định|hà giang|cao bằng)/i], + ['central', /(huế|hue|đà nẵng|da nang|hội an|hoi an|quảng nam|quảng bình|nha trang)/i], + ['south', /(sài gòn|sai gon|hồ chí minh|tphcm|tp\.hcm|vũng tàu|phú quốc)/i], + ['mekong', /(cần thơ|can tho|bến tre|tiền giang|cà mau|an giang|miền tây|sóc trăng)/i], + ['highlands', /(đà lạt|da lat|pleiku|kontum|buôn ma thuột)/i], +]; + +function classifyRegion(destination: string): keyof typeof REGION_TO_KIND { + for (const [region, re] of REGION_KEYWORDS) if (re.test(destination)) return region; + return 'unknown'; +} + +function hashString(s: string): number { + let h = 0; + for (let i = 0; i < s.length; i++) { + h = (h * 31 + s.charCodeAt(i)) | 0; + } + return Math.abs(h); +} + +function pickFromHash(arr: T[], seed: number): T { + const idx = seed % arr.length; + return arr[idx] as T; +} + +function placeOnDisk(seed: number, radius = 6): [number, number, number] { + const angle = ((seed % 360) / 360) * Math.PI * 2; + const r = (((seed * 17) % 100) / 100) * radius; + const x = Math.cos(angle) * r; + const z = Math.sin(angle) * r; + return [x, 0, z]; +} + +export function buildMonuments(trips: TripRecord[]): Monument[] { + const seen = new Set(); + const monuments: Monument[] = []; + for (const trip of trips) { + const key = trip.destination.trim().toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + + const seed = hashString(`${trip.id}:${trip.destination}`); + const region = classifyRegion(trip.destination); + const kindPool = REGION_TO_KIND[region] ?? REGION_TO_KIND.unknown; + if (!kindPool) continue; + const kind = pickFromHash(kindPool, seed); + const position = placeOnDisk(seed); + const scale = 0.85 + ((seed % 30) / 100); + const rotation = ((seed % 1000) / 1000) * Math.PI * 2; + monuments.push({ + id: trip.id, + kind, + position, + scale, + rotation, + destinationLabel: trip.destination, + }); + } + return monuments; +} + +export interface WorldSceneState { + monuments: Monument[]; + totalTrips: number; + uniqueDestinations: number; + ringRadius: number; +} + +export function buildSceneState(trips: TripRecord[], stats: PersonalWorldStats): WorldSceneState { + const monuments = buildMonuments(trips); + const ringRadius = Math.min(8, 4 + monuments.length * 0.2); + return { + monuments, + totalTrips: stats.tripCount, + uniqueDestinations: stats.uniqueDestinations, + ringRadius, + }; +} diff --git a/tsconfig.json b/tsconfig.json index 8164dbf..b4b07f1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,5 +27,5 @@ "@/*" : ["./*"] } }, - "exclude": ["node_modules", "dist", "workers", "supabase/functions", ".vercel"] + "exclude": ["node_modules", "dist", "workers", "supabase/functions", ".vercel", "e2e", "playwright-report", "test-results"] } diff --git a/vite.config.ts b/vite.config.ts index 3b75f3b..464757a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,10 +2,12 @@ import path from 'path'; import { defineConfig } from 'vite'; import tailwindcss from '@tailwindcss/vite'; import { VitePWA } from 'vite-plugin-pwa'; +import { devEdgeProxy } from './vite.devEdgeProxy'; export default defineConfig({ plugins: [ tailwindcss(), + devEdgeProxy(), VitePWA({ registerType: 'autoUpdate', includeAssets: ['favicon.png', 'apple-touch-icon.png', 'maskable-icon-512x512.png'], diff --git a/vite.devEdgeProxy.ts b/vite.devEdgeProxy.ts new file mode 100644 index 0000000..8eb2c19 --- /dev/null +++ b/vite.devEdgeProxy.ts @@ -0,0 +1,184 @@ +import { loadEnv, type Plugin, type ViteDevServer } from 'vite'; +import type { IncomingMessage, ServerResponse } from 'http'; + +interface DevEdgeProxyOptions { + geminiApiKey?: string; + mockItinerary?: boolean; +} + +const FAKE_ANON_TOKEN_TTL_SECONDS = 60 * 60; + +function readJsonBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); + req.on('end', () => { + if (chunks.length === 0) { + resolve({}); + return; + } + try { + resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); + } catch (err) { + reject(err); + } + }); + req.on('error', reject); + }); +} + +function sendJson(res: ServerResponse, status: number, body: unknown): void { + res.statusCode = status; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Cache-Control', 'no-store'); + res.end(JSON.stringify(body)); +} + +const MOCK_DESTINATION_MARKER = '[MOCK] Đà Lạt (fixture)'; + +function buildMockGeminiResponse(): unknown { + const sampleItinerary = { + destination: MOCK_DESTINATION_MARKER, + overview: '[FIXTURE — not real Gemini output] Hành trình 2 ngày 1 đêm tại Đà Lạt — thành phố ngàn hoa với khí hậu mát mẻ quanh năm. Kết hợp khám phá thiên nhiên, văn hóa cà phê và những khoảnh khắc lãng mạn bên hồ Xuân Hương.', + timeline: [ + { + day: 'Ngày 1', + title: 'Khám phá trung tâm Đà Lạt', + weather: { temperature: '15-22°C', condition: 'Mát, có sương', humidity: '78%', wind: '8 km/h', note: 'Tốt cho đi bộ buổi sáng' }, + schedule: [ + { time: '08:00', activity: 'Cà phê sáng bên Hồ Xuân Hương', venue: 'Cafe Tùng', estimated_cost: '50.000 VND', google_maps_link: 'https://maps.google.com/?q=Cafe+Tung+Da+Lat', is_trending: true, trending_reason: 'Hot trên TikTok' }, + { time: '10:00', activity: 'Tham quan Ga Đà Lạt cổ', venue: 'Ga Đà Lạt', estimated_cost: '40.000 VND' }, + { time: '12:30', activity: 'Ăn trưa bánh tráng nướng', venue: 'Dì Đinh', estimated_cost: '80.000 VND' }, + { time: '17:00', activity: 'Ngắm hoàng hôn Hồ Xuân Hương', venue: 'Hồ Xuân Hương', estimated_cost: '100.000 VND' }, + { time: '19:30', activity: 'Lẩu gà lá é tối', venue: 'Tao Ngộ', estimated_cost: '300.000 VND' }, + ], + }, + { + day: 'Ngày 2', + title: 'Thung lũng Tình Yêu & ra về', + weather: { temperature: '14-20°C', condition: 'Nắng nhẹ', note: 'Tốt cho check-in' }, + schedule: [ + { time: '08:00', activity: 'Ăn sáng phở khô Gia Lai', venue: 'Phở Khô Hồng', estimated_cost: '60.000 VND' }, + { time: '09:30', activity: 'Thung lũng Tình Yêu — check-in', venue: 'Thung lũng Tình Yêu', estimated_cost: '250.000 VND', is_trending: true, trending_reason: '50K reviews 4.6★' }, + { time: '12:00', activity: 'Nem nướng Bà Nghĩa', venue: 'Nem Nướng Bà Nghĩa', estimated_cost: '120.000 VND' }, + { time: '16:00', activity: 'Trả phòng, ra sân bay', venue: 'Sân bay Liên Khương' }, + ], + }, + ], + food: [ + { name: 'Bánh tráng nướng', description: 'Pizza Việt Nam, vỏ giòn, topping trứng + thịt băm.' }, + { name: 'Lẩu gà lá é', description: 'Đặc sản Đà Lạt, gà ta ngọt thanh.' }, + { name: 'Nem nướng', description: 'Cuốn bánh tráng, rau sống, chấm chua ngọt.' }, + ], + accommodation: [ + { name: 'Ana Mandara Villas', type: 'Resort 5★', reason: 'Villa kiểu Pháp, view rừng thông.' }, + ], + tips: ['Mang áo khoác mỏng', 'Đặt phòng trước', 'Đi xe máy thuê'], + packing_suggestions: [{ item: 'Áo khoác mỏng', reason: 'Đà Lạt mát 15-22°C.' }], + budget_summary: { + total_estimated: '3.500.000 VND', + breakdown: [ + { category: 'Di chuyển', amount: '900.000 VND' }, + { category: 'Ăn uống', amount: '1.200.000 VND' }, + { category: 'Lưu trú', amount: '600.000 VND' }, + ], + vs_budget_note: 'Trong khoảng ngân sách bạn đặt.', + }, + }; + return { + candidates: [{ content: { parts: [{ text: JSON.stringify(sampleItinerary) }] }, finishReason: 'STOP' }], + usageMetadata: { promptTokenCount: 100, candidatesTokenCount: 800, totalTokenCount: 900 }, + }; +} + +async function callGeminiDirect(body: unknown, apiKey: string, model: string): Promise { + const modelName = model === 'flash-lite' ? 'gemini-2.5-flash-lite' : 'gemini-2.5-flash'; + const url = `https://generativelanguage.googleapis.com/v1beta/models/${modelName}:generateContent?key=${apiKey}`; + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const errText = await res.text(); + throw new Error(`Gemini ${res.status}: ${errText.slice(0, 300)}`); + } + return res.json(); +} + +export function devEdgeProxy(opts: DevEdgeProxyOptions = {}): Plugin { + return { + name: 'moodtrip-dev-edge-proxy', + apply: 'serve', + configureServer(server: ViteDevServer) { + const env = loadEnv(server.config.mode, server.config.root, ''); + const apiKey = opts.geminiApiKey || env.GEMINI_API_KEY || process.env.GEMINI_API_KEY || ''; + const mockFlag = env.MOCK_ITINERARY === '1' || process.env.MOCK_ITINERARY === '1'; + const useMock = opts.mockItinerary || mockFlag; + + if (useMock) { + server.config.logger.warn( + '\n\u001b[33m\u2502 [dev-edge-proxy] MOCK MODE \u2014 /v1/generate returns the \u0110\u00e0 L\u1ea1t fixture, not real Gemini.\u001b[0m\n\u001b[33m\u2502 Unset MOCK_ITINERARY to use real Gemini.\u001b[0m\n', + ); + } else if (!apiKey) { + server.config.logger.error( + '\n\u001b[31m\u2502 [dev-edge-proxy] MISSING GEMINI_API_KEY \u2014 /v1/generate will return 500.\u001b[0m\n\u001b[31m\u2502 Add GEMINI_API_KEY=... to .env.local, or set MOCK_ITINERARY=1 to use the fixture.\u001b[0m\n', + ); + } else { + server.config.logger.info( + `\n\u001b[32m\u2502 [dev-edge-proxy] LIVE Gemini for /v1/generate (key ${apiKey.slice(0, 6)}\u2026${apiKey.slice(-4)})\u001b[0m\n`, + ); + } + + server.middlewares.use('/v1/anon-token', (req, res, next) => { + if (req.method !== 'POST') return next(); + sendJson(res, 200, { + token: `dev-anon-${Date.now()}`, + expiresIn: FAKE_ANON_TOKEN_TTL_SECONDS, + tier: 'anon', + }); + }); + + server.middlewares.use('/v1/generate', async (req, res, next) => { + if (req.method !== 'POST') return next(); + try { + const body = (await readJsonBody(req)) as { model?: string; contents?: unknown; generationConfig?: unknown; systemInstruction?: unknown }; + if (useMock) { + sendJson(res, 200, buildMockGeminiResponse()); + return; + } + if (!apiKey) { + sendJson(res, 500, { + code: 'NO_GEMINI_KEY', + error: 'GEMINI_API_KEY missing from .env.local. Add it, or set MOCK_ITINERARY=1 to use the fixture.', + }); + return; + } + const result = await callGeminiDirect( + { + contents: body.contents, + generationConfig: body.generationConfig, + systemInstruction: body.systemInstruction, + }, + apiKey, + body.model ?? 'flash', + ); + sendJson(res, 200, result); + } catch (err) { + const message = err instanceof Error ? err.message : 'unknown'; + server.config.logger.error(`[dev-edge-proxy] /v1/generate error: ${message}`); + sendJson(res, 502, { code: 'DEV_PROXY_ERROR', error: message }); + } + }); + + server.middlewares.use('/v1/health', (_req, res) => { + const mode = useMock ? 'mock' : apiKey ? 'live-gemini' : 'misconfigured'; + sendJson(res, 200, { ok: mode !== 'misconfigured', mode }); + }); + + server.middlewares.use('/v1/spend-status', (_req, res) => { + sendJson(res, 200, { ok: true, mode: 'dev', dailyBudgetUsd: 0, dailySpentUsd: 0 }); + }); + }, + }; +} diff --git a/yarn.lock b/yarn.lock index e8689df..cf9ec7b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1085,6 +1085,13 @@ resolved "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz" integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== +"@playwright/test@^1.60.0": + version "1.60.0" + resolved "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz" + integrity sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag== + dependencies: + playwright "1.60.0" + "@posthog/core@1.29.11": version "1.29.11" resolved "https://registry.npmjs.org/@posthog/core/-/core-1.29.11.tgz" @@ -2529,7 +2536,7 @@ fs-extra@^9.0.1: jsonfile "^6.0.1" universalify "^2.0.0" -fsevents@~2.3.2, fsevents@~2.3.3: +fsevents@~2.3.2, fsevents@~2.3.3, fsevents@2.3.2: version "2.3.3" resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz" integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== @@ -4090,6 +4097,20 @@ picomatch@^2.2.2: resolved "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz" integrity sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg== +playwright-core@1.60.0: + version "1.60.0" + resolved "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz" + integrity sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA== + +playwright@1.60.0: + version "1.60.0" + resolved "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz" + integrity sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA== + dependencies: + playwright-core "1.60.0" + optionalDependencies: + fsevents "2.3.2" + possible-typed-array-names@^1.0.0: version "1.1.0" resolved "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz"