diff --git a/.gitignore b/.gitignore index 1284798..85945f6 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,10 @@ dist-ssr /playwright-report/ /blob-report/ /playwright/.cache/ + +# Agent / session runtime artifacts (not source) +.omc/ +.sisyphus/ +progress.txt +.agent/ +.campaign/ diff --git a/App.tsx b/App.tsx index cc89326..9aa7cc2 100644 --- a/App.tsx +++ b/App.tsx @@ -1,6 +1,8 @@ -import { useState, useEffect, useCallback, Component, Suspense, lazy } from 'react'; +import { useState, useEffect, useCallback, useRef, Component, Suspense, lazy } from 'react'; import type { ReactNode } from 'react'; import { generateItinerary } from './services/geminiService'; +import { mapGenerationError } from './services/errorCopy'; +import { ItineraryErrorBoundary } from './components/ItineraryErrorBoundary'; import { Hero } from './components/Hero'; import { TripForm } from './components/TripForm'; import { LoadingAnimation } from './components/LoadingAnimation'; @@ -11,8 +13,6 @@ import { TipsPage } from './components/TipsPage'; import { AboutPage } from './components/AboutPage'; import { Footer } from './components/Footer'; import { ChatCompanion } from './components/ChatCompanion'; -import { AuthModal } from './components/AuthModal'; -import { MigrationBanner } from './components/MigrationBanner'; import { ConsentBanner } from './components/ConsentBanner'; import { PWAInstallPrompt } from './components/PWAInstallPrompt'; import { CardPullOnboarding } from './components/CardPullOnboarding'; @@ -25,7 +25,7 @@ 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'; @@ -33,7 +33,7 @@ import { parseCurrentRoute, type Route } from './services/sharedTripRouter'; import { saveTrip } from './services/tripsApi'; import { ITINERARY_LS_KEY, SAVED_ITINERARIES_LS_KEY } from './constants'; import type { FormData, ItineraryPlan, Mood, ShortTripMood } from './types'; -import { IconWarning } from './components/icons'; +import { IconWarning, IconHome, IconGlobe, IconFeather, IconMoon } from './components/icons'; import { SpeedInsights } from '@vercel/speed-insights/react'; import { Analytics } from '@vercel/analytics/react'; import { AnimatePresence, motion } from 'motion/react'; @@ -100,7 +100,6 @@ export default function App() { const [toastMessage, setToastMessage] = useState(null); const [isExportingPDF, setIsExportingPDF] = useState(false); const [isSharedView, setIsSharedView] = useState(false); - const [authModalOpen, setAuthModalOpen] = useState(false); const [route, setRoute] = useState(() => parseCurrentRoute()); const [cardPullPrefill, setCardPullPrefill] = useState | null>(null); const [preferenceDefaults, setPreferenceDefaults] = useState | null>(null); @@ -108,7 +107,13 @@ export default function App() { const [notebookOpen, setNotebookOpen] = useState(false); const [worldSceneOpen, setWorldSceneOpen] = useState(false); const [antiItineraryForm, setAntiItineraryForm] = useState(null); - const [portabilityOpen, setPortabilityOpen] = useState(false); + const [isGenerating, setIsGenerating] = useState(false); + const abortRef = useRef(null); + const [prefersReducedMotion] = useState(() => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false; + return window.matchMedia('(prefers-reduced-motion: reduce)').matches; + }); + const { user } = useAuth(); void generateAntiItinerary; @@ -223,18 +228,25 @@ export default function App() { }; const handleGenerateItinerary = useCallback(async (formData: FormData) => { + // Double-submit guard: ignore re-entry while a generation is already in flight. + if (abortRef.current) return; + const controller = new AbortController(); + abortRef.current = controller; + setIsGenerating(true); setView('loading'); setError(null); setLastFormData(formData); try { - const result = await generateItinerary(formData); - + const result = await generateItinerary(formData, controller.signal); + if (controller.signal.aborted) return; // user cancelled mid-flight + const resultWithId = { ...result, id: `${result.destination}-${Date.now()}` }; setItinerary(resultWithId); setView('result'); - localStorage.setItem(ITINERARY_LS_KEY, JSON.stringify(resultWithId)); - setLastFormData(null); + // Persistence happens after a successful render (ItineraryDisplay effect), not here — so a + // malformed itinerary that crashes the view is never cached. lastFormData is intentionally kept + // so retry-after-error and the Anti-Itinerary affordance keep working. if (user) { try { @@ -250,25 +262,21 @@ export default function App() { } } } catch (e: unknown) { - const err = e as Error; - const knownApiErrors = ['API_KEY_INVALID', 'RATE_LIMIT_EXCEEDED', 'BUDGET_EXCEEDED']; - - if (knownApiErrors.includes(err.message)) { - let userMessage = 'Đã có lỗi xảy ra. Vui lòng thử lại sau.'; - if (err.message === 'API_KEY_INVALID') { - userMessage = 'Lỗi xác thực với hệ thống AI. Vui lòng thử lại sau.'; - } else if (err.message === 'RATE_LIMIT_EXCEEDED') { - userMessage = 'Bạn đã đạt giới hạn tạo lịch trình hôm nay. Vui lòng thử lại vào ngày mai.'; - } else if (err.message === 'BUDGET_EXCEEDED') { - userMessage = 'Hệ thống AI đang nghỉ để cân bằng tài nguyên. Vui lòng quay lại vào ngày mai nhé.'; - } - setError(userMessage); - setView('form'); - } else { - setError(err.message || 'Đã có lỗi xảy ra không mong muốn. Vui lòng thử lại.'); - setView('error'); + const mapped = mapGenerationError(e); + if (mapped.cancelled) { + setView('form'); + return; } + setError(mapped.message); + setView(mapped.view); + } finally { + setIsGenerating(false); + abortRef.current = null; } + }, [user]); + + const handleCancelGeneration = useCallback(() => { + abortRef.current?.abort(); }, []); const handleReset = () => { @@ -438,13 +446,11 @@ export default function App() { setRoute({ kind: 'app' }); setView('result'); }} - onRequestSignIn={() => setAuthModalOpen(true)} onBackToApp={() => { window.history.replaceState({}, '', '/'); setRoute({ kind: 'app' }); }} /> - setAuthModalOpen(false)} /> ); } @@ -518,6 +524,7 @@ export default function App() { error={error} initialData={lastFormData ?? cardPullPrefill ?? preferenceDefaults} onGoHome={handleGoHome} + isSubmitting={isGenerating} /> ); @@ -530,7 +537,7 @@ export default function App() { exit={{ opacity: 0, scale: 1.05 }} transition={{ duration: 0.5 }} > - + ); case 'result': { @@ -544,17 +551,21 @@ export default function App() { exit={{ opacity: 0 }} transition={{ duration: 0.5 }} > - + + setQueModalOpen(true)} + onOpenWorld={() => setWorldSceneOpen(true)} + /> +
@@ -564,22 +575,21 @@ export default function App() {
- setAuthModalOpen(true)} - /> + {lastFormData && ( )}
@@ -596,7 +606,7 @@ export default function App() { exit={{ opacity: 0, x: -50 }} transition={{ duration: 0.4 }} > - + setQueModalOpen(true)} onOpenWorld={() => setWorldSceneOpen(true)} /> ); case 'tips': @@ -608,7 +618,7 @@ export default function App() { exit={{ opacity: 0, x: -50 }} transition={{ duration: 0.4 }} > - + setQueModalOpen(true)} onOpenWorld={() => setWorldSceneOpen(true)} /> ); case 'about': @@ -620,7 +630,7 @@ export default function App() { exit={{ opacity: 0, x: -50 }} transition={{ duration: 0.4 }} > - + setQueModalOpen(true)} onOpenWorld={() => setWorldSceneOpen(true)} /> ); case 'error': @@ -678,14 +688,24 @@ export default function App() { position: 'relative', }} > - {/* 3D Background Scene — delayed mount, lazy loaded, error-safe */} - {sceneReady && ( + {/* 3D Background Scene — delayed mount, lazy loaded, error-safe. + Skipped entirely when the user prefers reduced motion: the WebGL render loop runs continuous + animation that the CSS reduced-motion block cannot reach (WCAG 2.3.3). A static gradient stands + in so the layout is unchanged. */} + {sceneReady && !prefersReducedMotion && ( )} + {prefersReducedMotion && ( +