diff --git a/.env.example b/.env.example index 5d04ba4..9c123ef 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,8 @@ -VITE_EDGE_PROXY_URL=https://api.moodtrip.app +# URL of the deployed edge-proxy Cloudflare Worker (holds the Gemini key server-side). +# Set this in your host (e.g. Vercel) build env. In local dev it can stay empty — the Vite +# devEdgeProxy middleware serves /v1/* on the same origin. Example after `wrangler deploy`: +# VITE_EDGE_PROXY_URL=https://moodtrip-edge-proxy..workers.dev +VITE_EDGE_PROXY_URL=https://moodtrip-edge-proxy.remalw2019.workers.dev VITE_SUPABASE_URL= VITE_SUPABASE_ANON_KEY= VITE_SENTRY_DSN= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4fe610d..47cddf2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,7 +66,7 @@ jobs: build: name: Build runs-on: ubuntu-latest - needs: [typecheck, test-client] + needs: [typecheck, test-client, test-worker] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -77,3 +77,32 @@ jobs: - run: npm run build env: VITE_EDGE_PROXY_URL: https://api.moodtrip.app + + e2e-functional: + name: E2E (functional + mobile) + runs-on: ubuntu-latest + needs: [build] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + - run: npm ci + - run: npx playwright install --with-deps chromium + - run: npm run test:e2e + + e2e-visual: + name: E2E (visual regression — informational) + runs-on: ubuntu-latest + needs: [build] + continue-on-error: true # non-blocking until baselines prove stable across >=3 runs + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + - run: npm ci + - run: npx playwright install --with-deps chromium + - run: npm run test:e2e:visual diff --git a/.gitignore b/.gitignore index 85945f6..13624df 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,8 @@ dist-ssr progress.txt .agent/ .campaign/ + +# Tooling / verification output (generated, not source) +/deck/ +/ohmyperf-out/ +/renders/ diff --git a/App.tsx b/App.tsx index 9aa7cc2..7136e80 100644 --- a/App.tsx +++ b/App.tsx @@ -1,5 +1,4 @@ -import { useState, useEffect, useCallback, useRef, Component, Suspense, lazy } from 'react'; -import type { ReactNode } from 'react'; +import { useState, useEffect, useCallback, useRef, Suspense, lazy } from 'react'; import { generateItinerary } from './services/geminiService'; import { mapGenerationError } from './services/errorCopy'; import { ItineraryErrorBoundary } from './components/ItineraryErrorBoundary'; @@ -24,6 +23,7 @@ import { MoNotebookModal } from './components/MoNotebookModal'; import { PublicShareButton } from './components/PublicShareButton'; import { PersonalWorldBadge } from './components/PersonalWorldBadge'; import { PersonalWorldScene } from './components/PersonalWorldScene'; +import { useMoodTheme } from './hooks/useMoodTheme'; import { AntiItineraryView } from './components/AntiItineraryView'; import { generateAntiItinerary } from './services/antiItinerary'; @@ -43,29 +43,17 @@ import { hapticSuccess, spawnConfetti } from './services/haptics'; // Lazy load Three.js scene to prevent blocking initial render const NatureScene = lazy(() => import('./components/three/NatureScene')); -// Error boundary to catch Three.js crashes without killing the whole app -class SceneErrorBoundary extends Component<{ children: ReactNode }, { hasError: boolean }> { - constructor(props: { children: ReactNode }) { - super(props); - this.state = { hasError: false }; - } - - static getDerivedStateFromError() { - return { hasError: true }; - } - - componentDidCatch(error: Error) { - console.warn('[MoodTrip] 3D scene error (non-fatal):', error.message); - } - - render() { - if (this.state.hasError) { - return null; // Silently fail — app works without 3D background - } - return this.props.children; - } +/** Bridges the mood theme into the (lazy) 3D backdrop. Isolated so theme updates (debounced as the + * user types their mood) re-render only this wrapper + the scene's props — never the whole App. */ +function MoodReactiveScene() { + const moodTheme = useMoodTheme(); + return ; } +// Error boundary to catch Three.js crashes without killing the whole app. +// Shared with the PersonalWorld modal — see components/three/sceneHelpers.tsx. +import { SceneErrorBoundary } from './components/three/sceneHelpers'; + // Define types for html2pdf.js since it's loaded from a script interface Html2PdfOptions { margin?: number | number[]; @@ -551,47 +539,53 @@ export default function App() { exit={{ opacity: 0 }} transition={{ duration: 0.5 }} > - - setQueModalOpen(true)} - onOpenWorld={() => setWorldSceneOpen(true)} - /> - -
- - -
-

Bản đồ hành trình

- -
- -
- - - {lastFormData && ( + {/* Result layout: single vertical column — the itinerary, then the map + action + cluster stacked BELOW it (centered). ItineraryDisplay self-centers its content via + its own `container` and keeps a full-width sticky header. */} +
+ + setQueModalOpen(true)} + onOpenWorld={() => setWorldSceneOpen(true)} + /> + + +
+ + +
+

Bản đồ hành trình

+ +
+ +
+ - )} + {lastFormData && ( + + )} +
@@ -695,7 +689,7 @@ export default function App() { {sceneReady && !prefersReducedMotion && ( - + )} @@ -784,7 +778,7 @@ export default function App() { trip={itinerary} onClose={() => setNotebookOpen(false)} /> - setWorldSceneOpen(false)} localTrips={savedItineraries} /> + setWorldSceneOpen(false)} localTrips={savedItineraries} onOpenTrip={handleLoadItinerary} /> c.id === pull.element) : null }, - { label: 'Nhịp', card: pull ? TEMPO_CARDS.find((c) => c.id === pull.tempo) : null }, - { label: 'Bạn đi cùng', card: pull ? COMPANION_CARDS.find((c) => c.id === pull.companion) : null }, + const slots: { label: string; hint: string; card: DeckCardLike | null | undefined }[] = [ + { label: 'Nguyên tố', hint: 'Cảnh bạn hợp', card: pull ? ELEMENT_CARDS.find((c) => c.id === pull.element) : null }, + { label: 'Nhịp', hint: 'Tốc độ chuyến đi', card: pull ? TEMPO_CARDS.find((c) => c.id === pull.tempo) : null }, + { label: 'Bạn đi cùng', hint: 'Ai cùng bạn', card: pull ? COMPANION_CARDS.find((c) => c.id === pull.companion) : null }, ]; return ( @@ -163,7 +163,7 @@ export function CardPullOnboarding({ onComplete, onUseTraditionalForm }: CardPul > -
+
-

{label}

+

{label}

+

{hint}

{showFront && Icon ? ( <> @@ -287,7 +290,8 @@ function OracleCard({ label, card, revealed, shaking, delay, reduceMotion }: Ora return (
-

{label}

+

{label}

+

{hint}

); } -/** Small radial particle burst emitted when a card reveals. */ +/** Small radial particle burst emitted when a card reveals. Angles, radii, sizes and colours are + * jittered per particle so the burst reads as an organic spray rather than a symmetric ring. */ +const BURST_COLORS = [ + { bg: 'rgb(103,232,249)', glow: 'rgba(6,182,212,0.9)' }, // cyan + { bg: 'rgb(94,234,212)', glow: 'rgba(13,148,136,0.9)' }, // teal + { bg: 'rgb(252,211,77)', glow: 'rgba(245,158,11,0.85)' }, // warm amber accent +]; function ParticleBurst({ delay }: { delay: number }) { const particles = useMemo( () => - Array.from({ length: 10 }, (_, i) => { - const angle = (i / 10) * Math.PI * 2; + Array.from({ length: 11 }, (_, i) => { + // Deterministic pseudo-random jitter (no Math.random → stable across renders). + const seed = (i * 1297 + 7) % 360; + const jitter = ((i * 53) % 17) / 17 - 0.5; // -0.5..0.5 + const angle = ((i / 11) * Math.PI * 2) + jitter * 0.9; + const radius = 28 + ((i * 37) % 26) + (i % 3) * 6; + const color = BURST_COLORS[i % BURST_COLORS.length]; return { - x: Math.cos(angle) * (34 + (i % 3) * 8), - y: Math.sin(angle) * (34 + (i % 3) * 8), - s: 0.5 + (i % 3) * 0.25, + x: Math.cos(angle) * radius, + y: Math.sin(angle) * radius, + s: 0.45 + ((seed % 7) / 7) * 0.6, + dur: 0.7 + ((i * 31) % 9) / 20, + color, }; }), [] @@ -469,11 +486,11 @@ function ParticleBurst({ delay }: { delay: number }) { {particles.map((p, i) => ( ))}
@@ -500,7 +517,7 @@ function AmbientBackground({ reduceMotion }: { reduceMotion: boolean }) { className="absolute inset-0 opacity-40" style={{ background: - 'radial-gradient(60% 50% at 25% 20%, rgba(13,148,136,0.25), transparent 60%), radial-gradient(55% 45% at 80% 80%, rgba(14,165,233,0.22), transparent 60%)', + 'radial-gradient(60% 50% at 25% 20%, rgba(13,148,136,0.25), transparent 60%), radial-gradient(55% 45% at 80% 80%, rgba(14,165,233,0.22), transparent 60%), radial-gradient(45% 40% at 70% 30%, rgba(245,158,11,0.16), transparent 60%)', }} />
@@ -527,6 +544,13 @@ function AmbientBackground({ reduceMotion }: { reduceMotion: boolean }) { animate={{ x: [0, 25, -25, 0], y: [0, -25, 0, 0] }} transition={{ duration: 18, repeat: Infinity, ease: 'easeInOut' }} /> + {/* Warm amber ambient blob — balances the cool teal/cyan palette with one warm anchor. */} + {dots.map((dot, i) => ( { onClick={() => setIsOpen(false)} whileHover={{ scale: 1.1, rotate: 90 }} whileTap={{ scale: 0.9 }} - className="p-2.5 min-h-[44px] min-w-[44px] flex items-center justify-center text-slate-500 hover:text-white hover:bg-white/10 rounded-lg transition-colors" + className="p-2.5 min-h-[44px] min-w-[44px] flex items-center justify-center text-slate-400 hover:text-white hover:bg-white/10 rounded-lg transition-colors" > diff --git a/components/Footer.tsx b/components/Footer.tsx index c6aca43..e35a0cc 100644 --- a/components/Footer.tsx +++ b/components/Footer.tsx @@ -79,13 +79,13 @@ export const Footer: React.FC = ({ onGoHome, onGoToRelease, onGoToT {/* Divider */}
-

+

{currentYear} MoodTrip. Phát triển với bởi Hoài Nhớ

-

+

Mọi quyền được bảo lưu.

diff --git a/components/Hero.tsx b/components/Hero.tsx index 572b4d2..be2ffbe 100644 --- a/components/Hero.tsx +++ b/components/Hero.tsx @@ -2,9 +2,38 @@ import React, { useState, useEffect, useRef } from 'react'; import { Logo } from './Logo'; import { IconMapPin, IconSparkles, IconGlobe, IconCompass, IconX } from './icons'; import type { ItineraryPlan } from '../types'; -import { motion, AnimatePresence } from 'motion/react'; +import { motion, AnimatePresence, useReducedMotion } from 'motion/react'; import { hapticLight, hapticSelection } from '../services/haptics'; import { TripComparison } from './TripComparison'; +import { AmbientHero } from './home/AmbientHero'; + +// S4 — kinetic mood typography: a single emotion word that morphs, hinting the app is mood-led. +const KINETIC_MOODS = ['chữa lành', 'phiêu lưu', 'lãng mạn', 'tự do', 'bình yên', 'rực rỡ', 'tò mò']; + +const KineticMood: React.FC = () => { + const reduce = useReducedMotion(); + const [i, setI] = useState(0); + useEffect(() => { + if (reduce) return; + const t = setInterval(() => setI((p) => (p + 1) % KINETIC_MOODS.length), 2400); + return () => clearInterval(t); + }, [reduce]); + return ( + + + + {KINETIC_MOODS[i]} + + + + ); +}; interface HeroProps { onStart: () => void; @@ -32,6 +61,7 @@ export const Hero: React.FC = ({ onStart, savedItineraries, onLoadIti const hamburgerRef = useRef(null); const firstMenuItemRef = useRef(null); + const prefersReduced = useReducedMotion(); // Escape key closes menu and restores focus to hamburger useEffect(() => { @@ -61,17 +91,24 @@ export const Hero: React.FC = ({ onStart, savedItineraries, onLoadIti return (
+ {/* Living ambient backdrop (S2) — aurora + fireflies, behind the readability scrim. */} + + + {/* One smooth, full-bleed readability gradient over the whole hero — edgeless and uniform + (no boxed per-text scrim), so the 3D scene blends consistently top-to-bottom. */} +