Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -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.<account>.workers.dev
VITE_EDGE_PROXY_URL=https://moodtrip-edge-proxy.remalw2019.workers.dev

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Trong file .env.example, chúng ta nên sử dụng placeholder (ví dụ: <account>) thay vì hardcode URL Worker cá nhân (remalw2019). Điều này giúp tránh việc người dùng vô tình gửi request đến môi trường deploy cá nhân của bạn trong quá trình phát triển cục bộ hoặc khi họ sao chép file cấu hình này.

VITE_EDGE_PROXY_URL=https://moodtrip-edge-proxy.<account>.workers.dev

VITE_SUPABASE_URL=
VITE_SUPABASE_ANON_KEY=
VITE_SENTRY_DSN=
Expand Down
31 changes: 30 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,8 @@ dist-ssr
progress.txt
.agent/
.campaign/

# Tooling / verification output (generated, not source)
/deck/
/ohmyperf-out/
/renders/
120 changes: 57 additions & 63 deletions App.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand All @@ -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 <NatureScene moodTheme={moodTheme} />;
}

// 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[];
Expand Down Expand Up @@ -551,47 +539,53 @@ export default function App() {
exit={{ opacity: 0 }}
transition={{ duration: 0.5 }}
>
<ItineraryErrorBoundary onRecover={handleReset}>
<ItineraryDisplay
itinerary={itinerary}
onReset={handleReset}
onExportPDF={handleExportPDF}
onSaveToList={handleSaveItineraryToList}
onItineraryChange={handleItineraryChange}
onGoHome={handleGoHome}
isSaved={isSaved || isSharedView}
isExportingPDF={isExportingPDF}
formData={lastFormData}
onOpenQue={() => setQueModalOpen(true)}
onOpenWorld={() => setWorldSceneOpen(true)}
/>
</ItineraryErrorBoundary>
<div className="max-w-3xl mx-auto px-4 mt-6 space-y-6 mb-10">
<PersonalWorldBadge />

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

<div className="flex flex-wrap items-start gap-3">
<PublicShareButton itinerary={itinerary} />
<button
onClick={() => setNotebookOpen(true)}
className="inline-flex items-center gap-2 min-h-[44px] px-4 py-2 rounded-xl bg-gradient-to-r from-amber-200 to-amber-300 text-amber-900 text-sm font-semibold hover:from-amber-300 hover:to-amber-400 transition-colors"
>
<IconFeather className="w-4 h-4" />
Mơ viết thư cho bạn
</button>
{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. */}
<div>
<ItineraryErrorBoundary onRecover={handleReset}>
<ItineraryDisplay
itinerary={itinerary}
onReset={handleReset}
onExportPDF={handleExportPDF}
onSaveToList={handleSaveItineraryToList}
onItineraryChange={handleItineraryChange}
onGoHome={handleGoHome}
isSaved={isSaved || isSharedView}
isExportingPDF={isExportingPDF}
formData={lastFormData}
onOpenQue={() => setQueModalOpen(true)}
onOpenWorld={() => setWorldSceneOpen(true)}
/>
</ItineraryErrorBoundary>

<div className="max-w-3xl mx-auto px-4 mt-3 space-y-6 mb-10">
<PersonalWorldBadge />

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

<div className="flex flex-wrap items-start gap-3">
<PublicShareButton itinerary={itinerary} />
<button
onClick={() => setAntiItineraryForm(lastFormData)}
className="inline-flex items-center gap-2 min-h-[44px] px-4 py-2 rounded-xl bg-gradient-to-r from-purple-500 to-pink-500 text-white text-sm font-semibold hover:from-purple-600 hover:to-pink-600 transition-colors"
onClick={() => setNotebookOpen(true)}
className="inline-flex items-center gap-2 min-h-[44px] px-4 py-2 rounded-xl bg-amber-300 text-amber-900 text-sm font-semibold hover:bg-amber-400 transition-colors"
>
<IconMoon className="w-4 h-4" />
Thử Anti-Itinerary
<IconFeather className="w-4 h-4" />
Mơ viết thư cho bạn
</button>
)}
{lastFormData && (
<button
onClick={() => setAntiItineraryForm(lastFormData)}
className="inline-flex items-center gap-2 min-h-[44px] px-4 py-2 rounded-xl bg-white/10 border border-purple-400/40 text-purple-200 hover:text-white hover:bg-white/15 text-sm font-semibold transition-colors"
>
<IconMoon className="w-4 h-4" />
Thử Anti-Itinerary
</button>
)}
</div>
</div>
</div>
</motion.div>
Expand Down Expand Up @@ -695,7 +689,7 @@ export default function App() {
{sceneReady && !prefersReducedMotion && (
<SceneErrorBoundary>
<Suspense fallback={null}>
<NatureScene />
<MoodReactiveScene />
</Suspense>
</SceneErrorBoundary>
)}
Expand Down Expand Up @@ -784,7 +778,7 @@ export default function App() {
trip={itinerary}
onClose={() => setNotebookOpen(false)}
/>
<PersonalWorldScene open={worldSceneOpen} onClose={() => setWorldSceneOpen(false)} localTrips={savedItineraries} />
<PersonalWorldScene open={worldSceneOpen} onClose={() => setWorldSceneOpen(false)} localTrips={savedItineraries} onOpenTrip={handleLoadItinerary} />
<AntiItineraryView
open={antiItineraryForm !== null}
form={antiItineraryForm}
Expand Down
62 changes: 43 additions & 19 deletions components/CardPullOnboarding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,10 +149,10 @@ export function CardPullOnboarding({ onComplete, onUseTraditionalForm }: CardPul
onComplete({ ...moods, narrative: buildPullNarrative(pull) });
}

const slots: { label: string; card: DeckCardLike | null | undefined }[] = [
{ label: 'Nguyên tố', card: pull ? ELEMENT_CARDS.find((c) => 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 (
Expand All @@ -163,7 +163,7 @@ export function CardPullOnboarding({ onComplete, onUseTraditionalForm }: CardPul
>
<AmbientBackground reduceMotion={!!reduceMotion} />

<header className="relative z-10 text-center mb-8 max-w-md">
<header className="text-scrim relative z-10 text-center mb-8 max-w-md">
<motion.p
initial={reduceMotion ? false : { opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
Expand Down Expand Up @@ -192,6 +192,7 @@ export function CardPullOnboarding({ onComplete, onUseTraditionalForm }: CardPul
<OracleCard
key={slot.label}
label={slot.label}
hint={slot.hint}
card={slot.card}
revealed={!!pull && !shaking}
shaking={shaking}
Expand Down Expand Up @@ -254,22 +255,24 @@ export function CardPullOnboarding({ onComplete, onUseTraditionalForm }: CardPul

interface OracleCardProps {
label: string;
hint: string;
card: DeckCardLike | null | undefined;
revealed: boolean;
shaking: boolean;
delay: number;
reduceMotion: boolean;
}

function OracleCard({ label, card, revealed, shaking, delay, reduceMotion }: OracleCardProps) {
function OracleCard({ label, hint, card, revealed, shaking, delay, reduceMotion }: OracleCardProps) {
const Icon = card ? CARD_ICONS[card.icon] : undefined;
const showFront = revealed && !!card;

// Reduced motion: no 3D flip, no idle sway, instant swap of face content.
if (reduceMotion) {
return (
<div className="flex flex-col items-center">
<p className="text-slate-500 text-[10px] uppercase tracking-wider mb-2">{label}</p>
<p className="text-slate-300 text-[10px] uppercase tracking-wider mb-0.5">{label}</p>
<p className="text-slate-400 text-[10px] mb-2">{hint}</p>
<div className="w-full aspect-[2/3] rounded-2xl border border-teal-500/25 glass-dark flex flex-col items-center justify-center p-2 text-center">
{showFront && Icon ? (
<>
Expand All @@ -287,7 +290,8 @@ function OracleCard({ label, card, revealed, shaking, delay, reduceMotion }: Ora

return (
<div className="flex flex-col items-center">
<p className="text-slate-500 text-[10px] uppercase tracking-wider mb-2">{label}</p>
<p className="text-slate-300 text-[10px] uppercase tracking-wider mb-0.5">{label}</p>
<p className="text-slate-400 text-[10px] mb-2">{hint}</p>
<motion.div
className="relative w-full aspect-[2/3]"
animate={
Expand Down Expand Up @@ -443,23 +447,36 @@ function CardBackStatic() {
return (
<div className="flex flex-col items-center justify-center">
<IconSparkles className="w-8 h-8 text-teal-400/70" aria-hidden="true" />
<span className="mt-2 text-slate-600 text-lg" aria-hidden="true">
<span className="mt-2 text-slate-400 text-lg" aria-hidden="true">
?
</span>
</div>
);
}

/** 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,
};
}),
[]
Expand All @@ -469,11 +486,11 @@ function ParticleBurst({ delay }: { delay: number }) {
{particles.map((p, i) => (
<motion.span
key={i}
className="absolute w-1.5 h-1.5 rounded-full bg-cyan-300"
className="absolute w-1.5 h-1.5 rounded-full"
initial={{ opacity: 0, x: 0, y: 0, scale: 0 }}
animate={{ opacity: [0, 1, 0], x: p.x, y: p.y, scale: [0, p.s, 0] }}
transition={{ duration: 0.85, delay, ease: 'easeOut' }}
style={{ boxShadow: '0 0 6px rgba(6,182,212,0.9)' }}
transition={{ duration: p.dur, delay, ease: 'easeOut' }}
style={{ backgroundColor: p.color.bg, boxShadow: `0 0 6px ${p.color.glow}` }}
/>
))}
</div>
Expand All @@ -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%)',
}}
/>
</div>
Expand All @@ -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. */}
<motion.div
className="absolute top-1/4 left-1/4 w-64 h-64 rounded-full blur-3xl"
style={{ background: 'radial-gradient(circle, rgba(245,158,11,0.22), transparent 70%)' }}
animate={{ x: [0, 30, -15, 0], y: [0, 18, -12, 0], scale: [1, 1.12, 1] }}
transition={{ duration: 22, repeat: Infinity, ease: 'easeInOut' }}
/>
{dots.map((dot, i) => (
<motion.span
key={i}
Expand Down
2 changes: 1 addition & 1 deletion components/ChatCompanion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ export const ChatCompanion: React.FC = () => {
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"
>
<IconX className="w-5 h-5" />
</motion.button>
Expand Down
Loading
Loading