From 3941e2c6847ce1ca38bccca06942b725e6af73d0 Mon Sep 17 00:00:00 2001 From: NhoNH Date: Thu, 28 May 2026 14:01:48 +0000 Subject: [PATCH 1/8] ui(polish): remove emoji + login + Storyboard redesign + Reel multi-size + map geocoding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is the user-requested UI polish sweep. Five concrete improvements: 1. REMOVE ALL EMOJI from rendered UI; replace with stroke SVG icons. - Added 16 new icons to components/icons.tsx (Home, Cog, User, Film, LayoutList, LayoutGrid, Smartphone, Wand, Feather, LogIn, Route, Mic, Record, Mail, Sprout, Tree, Package, Trash, Languages). - Patched: App.tsx (top nav), TripHeroBanner (Reel CTA), TripViewModeToggle (3 mode pills), CardPullOnboarding (Rút bài button), PWAInstallPrompt (install card), SongDiRecorder (mic + record dot), PersonalWorldBadge + PersonalWorldScene (tree/sprout), DataPortabilityPanel (package + trash + check), RegionDialectSelector (languages icon), SundayDreamBanner (coffee + fire), TripReelModal (copy/check/sparkle/download), SharedTripView (wand/check). - Zero emoji remain in TSX render paths (verified via Python unicode range scan). 2. REMOVE 'Đăng nhập' / LOGIN entirely. MoodTrip is fast-food / fast-use per user direction. - Deleted AuthModal usage from App.tsx (imports, state, authModalOpen, setAuthModalOpen, render block). - Removed MigrationBanner from App.tsx (it migrates LocalStorage → Supabase which is now never needed). - Top-right nav: 3 icon buttons (Về quê / Thế giới / Cài đặt), each unconditional — Cài đặt opens DataPortabilityPanel directly instead of redirecting to login. - PublicShareButton: refactored to use the existing local generateShareUrl() (hash-based URL share, no Supabase RLS needed). Removed onRequestSignIn prop entirely. - SharedTripView: refactored handleFork to a local clone (no forkTrip() Supabase call); no auth gate. - Auth-gated branches in App.tsx (lastFormData persist, savePreferences, saveTrip) remain but stay dormant since user is always null — services already handle null user gracefully. - All persistence flows now route through localStorage exclusively (matches user's 'save all to local machine' requirement). 3. STORYBOARD VIEW REDESIGN. User reported colors inconsistent, distracting, no clear highlights, hard to read. - Replaced the 4-different-gradient-per-card approach (amber/sky/ violet/indigo competing for attention) with a single coherent palette: white card on dark bg + a 3px colored LEFT RAIL signaling time-of-day. - Time-of-day uses an icon (IconCoffee morning / IconSun noon / IconCloudSun afternoon / IconMoon evening) instead of a colored gradient backdrop that competed with content. - Clear hierarchy: top row = time-of-day badge + clock + time (+ Trending chip right-aligned), then activity title, then optional trending reason, then venue, then cost. One scan path top-to-bottom. - Trending reason moved into its own row with IconSparkles instead of being inline with title. - Reduced motion + delay (24px→16px enter, 0.55s→0.45s, 0.1s→0.08s stagger). 4. TRIP REEL POLISH + MULTI-FORMAT for FB/TikTok/IG. - Added FORMATS object with 3 canonical sizes: story 1080x1920 (9:16 TikTok/IG Reels/FB Story), portrait 1080x1350 (4:5 FB/IG Feed), square 1080x1080 (1:1 FB Post/IG Feed). Tabs at top of modal to switch between them; preview updates live. - Added SPARKLE PARTICLES (per-format seeded for stability): random small white circles + larger 8-point sparkle stars layered between bg gradients and content. Density scales with canvas area. - Added textGlow SVG filter for the destination heading (gaussian blur + merge — gives the sparkle/glittering feel user asked for). - Added vignette gradient (glow3) at low opacity for professional focus pull toward content. - Color palette: 6 deterministic palettes keyed by destination name hash (each has c1+c2+c3 gradient + accent + ambient glow tint). - Added DOWNLOAD PNG (canvas-based raster export) in addition to SVG. Filename includes size: moodtrip-da-lat-story-1080x1920.png. - Added body scroll lock + Escape-to-close + focus trap via shared hooks/useBodyScrollLock + hooks/useEscapeKey. - Replaced all emoji (📋 → IconCopy, ✓ → IconCheck, ✨ → IconSparkles). - Removed '✨' from 'Tạo bởi Mơ' SVG signature; replaced with a subtle layout glow. 5. TRIPMAP DATA FIX — markers were missing because real Gemini responses embed venue names in google_maps_link query strings (no coordinates). - New services/geocoder.ts: Nominatim (OSM) public geocoder with 30-day localStorage cache, 1.1s rate-limit per Nominatim TOS, countrycodes=vn bias for Vietnamese place names. - TripMap.tsx: tries parseLatLng() first (free, fast); for venues missing coords, falls back to geocodeBatch() (sequential, polite). - UI feedback during geocoding: 'Đang tìm vị trí địa điểm…' spinner with N/total progress pill in top-left. - Empty-state messaging improved: differentiates 'no venues at all' vs 'venues exist but geocoding failed'. - DAY-COLORED markers (7-color palette): each day gets its own consistent color, numbered by order within the day. - ROUTE LINES drawn between activities of the same day (dashed line, per-day color, opacity 0.75) — gives user the visual 'road' they asked for. - Selected venue popup gains a 'Chỉ đường' (Directions) link to OSM directions in addition to Google Maps and TikTok. INFRASTRUCTURE: - New hooks/useBodyScrollLock.ts (scrollbar-width-aware, restores prev overflow and padding-right on unmount). - New hooks/useEscapeKey.ts. - index.css additions: .pb-safe-plus-4, .bottom-safe, .touch-target (44px WCAG minimum), .sr-only, body.modal-open scroll lock helper, :focus-visible ring (modern browsers only, no fallback bug). TYPECHECK: clean (npx tsc --noEmit zero errors). BROWSER-VERIFIED: container has no working browser — patches reach disk typeclean. User to pull on Mac and visual-verify. --- App.tsx | 59 ++-- components/CardPullOnboarding.tsx | 7 +- components/ConsentBanner.tsx | 22 +- components/DataPortabilityPanel.tsx | 16 +- components/PWAInstallPrompt.tsx | 38 ++- components/PersonalWorldBadge.tsx | 5 +- components/PersonalWorldScene.tsx | 5 +- components/PublicShareButton.tsx | 45 +-- components/RegionDialectSelector.tsx | 6 +- components/SharedTripView.tsx | 58 ++-- components/SongDiRecorder.tsx | 19 +- components/SundayDreamBanner.tsx | 13 +- components/TripDayStoryboard.tsx | 165 ++++++---- components/TripHeroBanner.tsx | 7 +- components/TripMap.tsx | 212 ++++++++++-- components/TripReelModal.tsx | 461 ++++++++++++++++++++++----- components/TripViewModeToggle.tsx | 23 +- components/icons.tsx | 164 ++++++++++ hooks/useBodyScrollLock.ts | 22 ++ hooks/useEscapeKey.ts | 12 + index.css | 48 +++ services/geocoder.ts | 114 +++++++ 22 files changed, 1218 insertions(+), 303 deletions(-) create mode 100644 hooks/useBodyScrollLock.ts create mode 100644 hooks/useEscapeKey.ts create mode 100644 services/geocoder.ts diff --git a/App.tsx b/App.tsx index cc89326..773cc28 100644 --- a/App.tsx +++ b/App.tsx @@ -11,8 +11,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'; @@ -33,7 +31,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, IconCog, 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 +98,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); @@ -438,13 +435,11 @@ export default function App() { setRoute({ kind: 'app' }); setView('result'); }} - onRequestSignIn={() => setAuthModalOpen(true)} onBackToApp={() => { window.history.replaceState({}, '', '/'); setRoute({ kind: 'app' }); }} /> - setAuthModalOpen(false)} /> ); } @@ -564,22 +559,21 @@ export default function App() {
- setAuthModalOpen(true)} - /> + {lastFormData && ( )}
@@ -696,31 +690,34 @@ export default function App() { - {/* Quick-access buttons — hidden on Hero (Hero has its own top-right nav) to prevent overlap */} {view !== 'hero' && (
- {user && ( - - )} +
)} @@ -751,7 +748,6 @@ export default function App() { - setView('card-pull')} /> @@ -802,7 +798,6 @@ export default function App() { )} - setAuthModalOpen(false)} /> {/* Toast Notification */} diff --git a/components/CardPullOnboarding.tsx b/components/CardPullOnboarding.tsx index 2540b97..175a406 100644 --- a/components/CardPullOnboarding.tsx +++ b/components/CardPullOnboarding.tsx @@ -11,6 +11,7 @@ import { } from '../services/cardPullDeck'; import { hapticSelection, hapticSuccess } from '../services/haptics'; import type { Mood, ShortTripMood } from '../types'; +import { IconSparkles } from './icons'; interface CardPullOnboardingProps { onComplete: (result: { moods: Mood[]; shortMoods: ShortTripMood[]; narrative: string }) => void; @@ -127,11 +128,13 @@ export function CardPullOnboarding({ onComplete, onUseTraditionalForm }: CardPul
diff --git a/components/ConsentBanner.tsx b/components/ConsentBanner.tsx index 78c4e95..75ff827 100644 --- a/components/ConsentBanner.tsx +++ b/components/ConsentBanner.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { motion, AnimatePresence } from 'motion/react'; import { CONSENT_SCOPES, readLocalConsent, recordConsent } from '../services/consent'; import { useAuth } from '../services/useAuth'; @@ -6,12 +6,21 @@ import { useAuth } from '../services/useAuth'; export function ConsentBanner() { const { user } = useAuth(); const [needsConsent, setNeedsConsent] = useState(false); + const descriptionId = 'consent-description'; + const acceptBtnRef = useRef(null); useEffect(() => { const stored = readLocalConsent(); setNeedsConsent(!stored); }, []); + useEffect(() => { + if (needsConsent) { + const t = window.setTimeout(() => acceptBtnRef.current?.focus(), 400); + return () => window.clearTimeout(t); + } + }, [needsConsent]); + if (!needsConsent) return null; async function accept() { @@ -25,14 +34,16 @@ export function ConsentBanner() { initial={{ y: 100, opacity: 0 }} animate={{ y: 0, opacity: 1 }} exit={{ y: 100, opacity: 0 }} - className="fixed bottom-0 left-0 right-0 z-50 p-4 bg-slate-950/95 border-t border-white/10 backdrop-blur-md" + className="fixed bottom-0 left-0 right-0 z-[60] px-4 pt-4 pb-safe-plus-4 bg-slate-950/95 border-t border-white/10 backdrop-blur-md" role="dialog" + aria-modal="false" aria-label="Đồng ý xử lý dữ liệu" + aria-describedby={descriptionId} >
-

Bạn có đồng ý cho MoodTrip xử lý dữ liệu?

-

+

Bạn có đồng ý cho MoodTrip xử lý dữ liệu?

+

Chúng tôi gửi nội dung trò chuyện của bạn đến dịch vụ AI (Google Gemini, có thể nằm ngoài Việt Nam) để tạo lịch trình, dùng cookie/LocalStorage để lưu lịch trình, và đo lường ẩn danh để cải tiến sản phẩm. Theo Nghị định 13/2023/NĐ-CP, bạn có quyền yêu @@ -41,8 +52,9 @@ export function ConsentBanner() {

diff --git a/components/DataPortabilityPanel.tsx b/components/DataPortabilityPanel.tsx index 2814045..0055497 100644 --- a/components/DataPortabilityPanel.tsx +++ b/components/DataPortabilityPanel.tsx @@ -6,6 +6,7 @@ import { downloadArchive, requestAccountDeletionViaEdgeFunction, } from '../services/dataExport'; +import { IconPackage, IconTrash, IconCheck } from './icons'; type State = | { kind: 'idle' } @@ -60,7 +61,10 @@ export function DataPortabilityPanel() { return (
-

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

+

+ + 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. @@ -77,9 +81,10 @@ export function DataPortabilityPanel() { - ✓ Đã xuất {state.count} chuyến đi. + + Đã xuất {state.count} chuyến đi. )} {state.kind === 'export-failed' && ( @@ -97,7 +102,10 @@ export function DataPortabilityPanel() {


-

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

+

+ + 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. diff --git a/components/PWAInstallPrompt.tsx b/components/PWAInstallPrompt.tsx index 374dae5..7579c78 100644 --- a/components/PWAInstallPrompt.tsx +++ b/components/PWAInstallPrompt.tsx @@ -1,5 +1,7 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { motion, AnimatePresence } from 'motion/react'; +import { useEscapeKey } from '../hooks/useEscapeKey'; +import { IconSmartphone } from './icons'; interface BeforeInstallPromptEvent extends Event { prompt: () => Promise; @@ -12,6 +14,7 @@ const DISMISSED_TTL_MS = 30 * 24 * 60 * 60 * 1000; export function PWAInstallPrompt() { const [deferred, setDeferred] = useState(null); const [visible, setVisible] = useState(false); + const installBtnRef = useRef(null); useEffect(() => { const dismissedAt = readDismissedAt(); @@ -27,6 +30,15 @@ export function PWAInstallPrompt() { return () => window.removeEventListener('beforeinstallprompt', onBeforeInstall); }, []); + useEffect(() => { + if (visible) { + const t = window.setTimeout(() => installBtnRef.current?.focus(), 400); + return () => window.clearTimeout(t); + } + }, [visible]); + + useEscapeKey(visible, () => handleDismiss()); + if (!visible || !deferred) return null; async function handleInstall() { @@ -40,8 +52,8 @@ export function PWAInstallPrompt() { function handleDismiss() { try { localStorage.setItem(DISMISSED_LS_KEY, String(Date.now())); - } catch { - void 0; + } catch (err) { + console.warn('[pwa] localStorage unavailable', err); } setVisible(false); } @@ -52,22 +64,32 @@ export function PWAInstallPrompt() { initial={{ y: 80, opacity: 0 }} animate={{ y: 0, opacity: 1 }} exit={{ y: 80, opacity: 0 }} - className="fixed bottom-4 right-4 z-40 max-w-xs p-4 rounded-2xl glass-dark border border-teal-500/30 shadow-2xl" + role="dialog" + aria-label="Cài đặt ứng dụng MoodTrip" + aria-modal="false" + className="fixed left-4 right-4 sm:left-auto sm:right-4 sm:max-w-xs z-[42] p-4 rounded-2xl glass-dark border border-teal-500/30 shadow-2xl" + style={{ bottom: 'calc(env(safe-area-inset-bottom, 0px) + 1rem)' }} > -

📱 Cài MoodTrip vào màn hình chính

-

+

+ + Cài MoodTrip vào màn hình chính +

+

Mở nhanh, xem offline khi đang đi đường.

diff --git a/components/PersonalWorldBadge.tsx b/components/PersonalWorldBadge.tsx index 200b20a..41dad88 100644 --- a/components/PersonalWorldBadge.tsx +++ b/components/PersonalWorldBadge.tsx @@ -8,6 +8,7 @@ import { type PersonalWorldStats, } from '../services/personalWorld'; import { useAuth } from '../services/useAuth'; +import { IconTree, IconSprout } from './icons'; export function PersonalWorldBadge() { const { user } = useAuth(); @@ -44,7 +45,9 @@ export function PersonalWorldBadge() { >

Thế giới MoodTrip của bạn

-
{current ? '🌳' : '🌱'}
+
+ {current ? : } +

{current?.label ?? 'Chưa có chuyến nào'} diff --git a/components/PersonalWorldScene.tsx b/components/PersonalWorldScene.tsx index 9f6ed75..07b8cc7 100644 --- a/components/PersonalWorldScene.tsx +++ b/components/PersonalWorldScene.tsx @@ -4,6 +4,7 @@ import { useAuth } from '../services/useAuth'; import { listOwnedTrips, type TripRecord } from '../services/tripsApi'; import { buildWorldStats, currentMilestone } from '../services/personalWorld'; import { buildSceneState } from '../services/personalWorldScene'; +import { IconSprout } from './icons'; const LazySceneCanvas = lazy(() => import('./three/PersonalWorldCanvas')); @@ -73,8 +74,8 @@ export function PersonalWorldScene({ open, onClose }: PersonalWorldSceneProps) { ) : scene.monuments.length === 0 ? (

-

🌱

-

Thế giới còn trống

+ +

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.

diff --git a/components/PublicShareButton.tsx b/components/PublicShareButton.tsx index 0e91204..0945c6d 100644 --- a/components/PublicShareButton.tsx +++ b/components/PublicShareButton.tsx @@ -1,37 +1,31 @@ import { useState } from 'react'; import { motion, AnimatePresence } from 'motion/react'; import type { ItineraryPlan, FormData } from '../types'; -import { ensurePublicTrip } from '../services/publicShare'; -import { useAuth } from '../services/useAuth'; +import { generateShareUrl } from '../services/shareService'; +import { IconShare, IconCopy, IconCheck } from './icons'; interface PublicShareButtonProps { itinerary: ItineraryPlan; formInput?: Partial; - onRequestSignIn: () => void; } type State = 'idle' | 'sharing' | 'shared' | 'error'; -export function PublicShareButton({ itinerary, formInput, onRequestSignIn }: PublicShareButtonProps) { - const { user } = useAuth(); +export function PublicShareButton({ itinerary }: PublicShareButtonProps) { const [state, setState] = useState('idle'); const [shareUrl, setShareUrl] = useState(null); const [errorMsg, setErrorMsg] = useState(null); + const [copied, setCopied] = useState(false); async function handleShare() { - if (!user) { - onRequestSignIn(); - return; - } setState('sharing'); setErrorMsg(null); try { - const existingId = typeof itinerary.id === 'string' ? itinerary.id : undefined; - const result = await ensurePublicTrip(user.id, itinerary, formInput ?? {}, existingId); - setShareUrl(result.url); + const url = await generateShareUrl(itinerary); + setShareUrl(url); setState('shared'); } catch (err) { - setErrorMsg(err instanceof Error ? err.message : 'Không thể chia sẻ'); + setErrorMsg(err instanceof Error ? err.message : 'Không thể tạo link chia sẻ'); setState('error'); } } @@ -40,19 +34,23 @@ export function PublicShareButton({ itinerary, formInput, onRequestSignIn }: Pub if (!shareUrl) return; try { await navigator.clipboard.writeText(shareUrl); - } catch { - void 0; + setCopied(true); + window.setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.warn('[share] clipboard unavailable', err); } } return (
@@ -63,16 +61,21 @@ export function PublicShareButton({ itinerary, formInput, onRequestSignIn }: Pub exit={{ opacity: 0 }} className="rounded-xl bg-white/5 border border-teal-500/30 p-3 text-xs text-slate-300" > -

Lịch trình đã được công khai. Ai có link đều xem được:

+

Link chia sẻ (ai có link cũng xem được, không cần đăng ký):

(e.target as HTMLInputElement).select()} - className="flex-1 px-2 py-1.5 bg-slate-900 rounded text-teal-200 text-xs" + className="flex-1 min-w-0 px-2 py-2 bg-slate-900 rounded text-teal-200 text-xs" /> -
diff --git a/components/RegionDialectSelector.tsx b/components/RegionDialectSelector.tsx index 910eb33..444b6d9 100644 --- a/components/RegionDialectSelector.tsx +++ b/components/RegionDialectSelector.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { useAuth } from '../services/useAuth'; import { loadPreferences, setRegionDialect } from '../services/preferencesApi'; +import { IconLanguages } from './icons'; const OPTIONS: Array<{ id: string; label: string; sample: string }> = [ { id: 'auto', label: 'Tự động theo điểm đến', sample: 'Mơ chọn giọng phù hợp với mỗi chuyến' }, @@ -36,7 +37,10 @@ export function RegionDialectSelector() { return (
-

🗣️ Tiếng vùng của Mơ

+

+ + Tiếng vùng của Mơ +

Chọn giọng địa phương bạn muốn nghe khi Mơ kể chuyện.

diff --git a/components/SharedTripView.tsx b/components/SharedTripView.tsx index acce76b..6942d98 100644 --- a/components/SharedTripView.tsx +++ b/components/SharedTripView.tsx @@ -1,20 +1,18 @@ import { useEffect, useState } from 'react'; import { motion } from 'motion/react'; import type { TripRecord } from '../services/tripsApi'; -import { forkTrip, getTripBySlug } from '../services/tripsApi'; -import { useAuth } from '../services/useAuth'; +import { getTripBySlug } from '../services/tripsApi'; +import { IconWand, IconCheck } from './icons'; interface SharedTripViewProps { slug: string; onForkSuccess: (trip: TripRecord) => void; - onRequestSignIn: () => void; onBackToApp: () => void; } type State = 'loading' | 'loaded' | 'not-found' | 'forking' | 'forked'; -export function SharedTripView({ slug, onForkSuccess, onRequestSignIn, onBackToApp }: SharedTripViewProps) { - const { user } = useAuth(); +export function SharedTripView({ slug, onForkSuccess, onBackToApp }: SharedTripViewProps) { const [trip, setTrip] = useState(null); const [state, setState] = useState('loading'); const [error, setError] = useState(null); @@ -36,20 +34,22 @@ export function SharedTripView({ slug, onForkSuccess, onRequestSignIn, onBackToA }; }, [slug]); - async function handleFork() { - if (!user) { - onRequestSignIn(); - return; - } + function handleFork() { + if (!trip) return; setState('forking'); - const forked = await forkTrip(slug, user.id); - if (!forked) { - setError('Không thể fork lịch trình này. Thử lại nhé.'); + setError(null); + try { + const cloned: TripRecord = { + ...trip, + id: `${trip.id}-fork-${Date.now()}`, + itinerary: { ...trip.itinerary, id: `${trip.id}-fork-${Date.now()}` }, + }; + setState('forked'); + window.setTimeout(() => onForkSuccess(cloned), 500); + } catch (e) { + setError(e instanceof Error ? e.message : 'Không thể remix lịch trình này.'); setState('loaded'); - return; } - setState('forked'); - setTimeout(() => onForkSuccess(forked), 600); } if (state === 'loading') { @@ -110,21 +110,29 @@ export function SharedTripView({ slug, onForkSuccess, onRequestSignIn, onBackToA