diff --git a/docker-compose.yml b/docker-compose.yml index 9322da0..4979d0a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -58,6 +58,7 @@ services: PORT: "8002" APP_URL: http://frontend:3001 CACHE_DIR: /cache + SCREENSHOT_CACHE_ENABLED: "false" SCREENSHOT_CONCURRENCY: "3" SCREENSHOT_RATE_WINDOW_MS: "60000" SCREENSHOT_RATE_LIMIT: "30" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 12fc9fa..0e0a0c1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,16 +12,16 @@ import { } from './lib/seoRoutes'; import Header, { type Page } from './components/ui/Header'; import type { FeatureMeta, FeatureGroup, POICategoriesResponse, POICategoryGroup } from './types'; -import { fetchWithRetry, apiUrl } from './lib/api'; +import { fetchWithRetry, apiUrl, logNonAbortError } from './lib/api'; import { trackEvent } from './lib/analytics'; import { parseUrlState } from './lib/url-state'; import { INITIAL_VIEW_STATE } from './lib/consts'; import { useTheme } from './hooks/useTheme'; import { useIsMobile } from './hooks/useIsMobile'; import { useAuth } from './hooks/useAuth'; +import { useLicense } from './hooks/useLicense'; import { useTelemetry } from './hooks/useTelemetry'; import { useSavedSearches } from './hooks/useSavedSearches'; -import { useSavedProperties } from './hooks/useSavedProperties'; declare global { interface Window { @@ -39,9 +39,6 @@ const AccountPage = lazy(() => import('./components/account/AccountPage')); const SavedPage = lazy(() => import('./components/account/AccountPage').then((module) => ({ default: module.SavedPage })) ); -const InvitesPage = lazy(() => - import('./components/account/AccountPage').then((module) => ({ default: module.InvitesPage })) -); const InvitePage = lazy(() => import('./components/invite/InvitePage')); const MapPage = lazy(() => import('./components/map/MapPage')); const AuthModal = lazy(() => import('./components/ui/AuthModal')); @@ -52,6 +49,49 @@ function PageFallback() { return
; } +interface RouteMatch { + page: Page; + inviteCode?: string; + hash?: string; +} + +type PostAuthIntent = 'checkout'; +type LicenseSuccessStatus = 'hidden' | 'verifying' | 'success' | 'delayed'; + +const LICENSE_VERIFICATION_ATTEMPTS = 8; +const LICENSE_VERIFICATION_DELAY_MS = 1500; + +function hasFullAccess(user?: { subscription?: string; isAdmin?: boolean } | null): boolean { + return user?.subscription === 'licensed' || user?.isAdmin === true; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} + +function normalizeHash(hash?: string | null): string { + return hash?.replace(/^#/, '') ?? ''; +} + +function currentRelativePath(): string { + return `${window.location.pathname}${window.location.search}`; +} + +function isProtectedPage(page: Page): boolean { + return page === 'account' || page === 'saved'; +} + +function buildPageUrl(page: Page, inviteCode?: string, search = '', hash = ''): string { + const normalizedHash = normalizeHash(hash); + return `${pageToPath(page, inviteCode)}${search}${normalizedHash ? `#${normalizedHash}` : ''}`; +} + +function scrollToHash(hash: string) { + window.requestAnimationFrame(() => { + document.getElementById(hash)?.scrollIntoView({ block: 'start', behavior: 'smooth' }); + }); +} + function unavailableAuthAction(): never { throw new Error('Authentication actions are not available in this render mode'); } @@ -79,8 +119,6 @@ function pageToPath(page: Page, inviteCode?: string): string { return SEO_CONTENT_PATHS[page]; case 'saved': return '/saved'; - case 'invites': - return '/invites'; case 'account': return '/account'; case 'invite': @@ -93,10 +131,10 @@ function pageToPath(page: Page, inviteCode?: string): string { } } -function pathToPage(pathname: string): { page: Page; inviteCode?: string } | null { +function pathToPage(pathname: string): RouteMatch | null { if (pathname === '/dashboard') return { page: 'dashboard' }; if (pathname === '/saved') return { page: 'saved' }; - if (pathname === '/invites') return { page: 'invites' }; + if (pathname === '/invites') return { page: 'account', hash: 'invites' }; if (pathname === '/learn') return { page: 'learn' }; if (pathname === '/pricing') return { page: 'pricing' }; const seoLandingPage = getSeoLandingPage(pathname); @@ -123,7 +161,14 @@ function isSeoContentPage(page: Page): page is SeoContentKey { export default function App() { const urlState = useMemo(() => parseUrlState(), []); + const initialRoute = useMemo(() => pathToPage(window.location.pathname), []); const [mapUrlState, setMapUrlState] = useState(urlState); + const [dashboardRouteKey, setDashboardRouteKey] = useState(() => + window.location.pathname === '/dashboard' ? window.location.search : '' + ); + const [dashboardParams, setDashboardParams] = useState(() => + window.location.pathname === '/dashboard' ? window.location.search.replace(/^\?/, '') : '' + ); const dashboardSearchRef = useRef( window.location.pathname === '/dashboard' ? window.location.search : '' ); @@ -147,15 +192,16 @@ export default function App() { const [initialLoading, setInitialLoading] = useState(true); const [pendingInfoFeature, setPendingInfoFeature] = useState(null); const [inviteCode, setInviteCode] = useState(() => { - const fromPath = pathToPage(window.location.pathname); - return fromPath?.inviteCode ?? null; + return initialRoute?.inviteCode ?? null; }); + const [routeHash, setRouteHash] = useState( + () => initialRoute?.hash ?? normalizeHash(window.location.hash) + ); const [activePage, setActivePage] = useState(() => { if (isScreenshotMode) return 'dashboard'; // Derive page from URL pathname - const fromPath = pathToPage(window.location.pathname); - if (fromPath) return fromPath.page; + if (initialRoute) return initialRoute.page; // Restore from history state (e.g. popstate) if (window.history.state?.page) return window.history.state.page; @@ -182,26 +228,95 @@ export default function App() { refreshAuth, clearError, } = useAuth(); + const { startCheckout: startPostAuthCheckout } = useLicense(); const [showAuthModal, setShowAuthModal] = useState(false); const [authModalTab, setAuthModalTab] = useState<'login' | 'register'>('login'); - const [showLicenseSuccess, setShowLicenseSuccess] = useState(false); + const [postAuthIntent, setPostAuthIntent] = useState(null); + const postAuthCheckoutReturnPathRef = useRef(null); + const authCompletedRef = useRef(false); + const [licenseSuccessStatus, setLicenseSuccessStatus] = useState('hidden'); + + const openAuthModal = useCallback( + ( + tab: 'login' | 'register', + intent: PostAuthIntent | null = null, + checkoutReturnPath?: string + ) => { + authCompletedRef.current = false; + postAuthCheckoutReturnPathRef.current = + intent === 'checkout' ? (checkoutReturnPath ?? currentRelativePath()) : null; + setPostAuthIntent(intent); + setAuthModalTab(tab); + setShowAuthModal(true); + clearError(); + }, + [clearError] + ); + + const closeAuthModal = useCallback(() => { + setShowAuthModal(false); + const completed = authCompletedRef.current; + if (!completed) { + setPostAuthIntent(null); + postAuthCheckoutReturnPathRef.current = null; + if (isProtectedPage(activePageRef.current)) { + window.history.replaceState({ page: 'home', hash: '' }, '', '/'); + setRouteHash(''); + setActivePage('home'); + } + } + authCompletedRef.current = false; + }, []); + useEffect(() => { const params = new URLSearchParams(window.location.search); - if (params.get('license_success') === '1') { + const returnedFromCheckout = params.get('license_success') === '1'; + let cancelled = false; + + if (returnedFromCheckout) { params.delete('license_success'); const newUrl = params.toString() ? `${window.location.pathname}?${params.toString()}` : window.location.pathname; window.history.replaceState({}, '', newUrl); - trackEvent('Purchase'); - setShowLicenseSuccess(true); } - // Always refresh auth on startup to pick up server-side subscription changes - refreshAuth().catch(() => {}); + + async function refreshOnStartup() { + if (!returnedFromCheckout) { + // Always refresh auth on startup to pick up server-side subscription changes. + refreshAuth().catch(() => {}); + return; + } + + setLicenseSuccessStatus('verifying'); + for (let attempt = 0; attempt < LICENSE_VERIFICATION_ATTEMPTS; attempt += 1) { + try { + const refreshedUser = await refreshAuth(); + if (cancelled) return; + if (hasFullAccess(refreshedUser)) { + trackEvent('Purchase'); + setLicenseSuccessStatus('success'); + return; + } + } catch (error) { + logNonAbortError('Failed to verify license activation', error); + break; + } + + await delay(LICENSE_VERIFICATION_DELAY_MS); + if (cancelled) return; + } + + if (!cancelled) setLicenseSuccessStatus('delayed'); + } + + refreshOnStartup(); + return () => { + cancelled = true; + }; }, []); // eslint-disable-line react-hooks/exhaustive-deps const savedSearches = useSavedSearches(user?.id ?? null); - const savedProperties = useSavedProperties(user?.id ?? null); const [showSaveModal, setShowSaveModal] = useState(false); useEffect(() => { @@ -241,6 +356,7 @@ export default function App() { const navigateTo = useCallback( (page: Page, hash?: string, infoFeature?: string) => { + const targetHash = normalizeHash(hash); // Save dashboard search params before navigating away if (activePageRef.current === 'dashboard') { dashboardSearchRef.current = window.location.search; @@ -248,38 +364,67 @@ export default function App() { if (infoFeature) { window.history.replaceState({ ...window.history.state, infoFeature }, ''); } - const path = pageToPath(page, inviteCode ?? undefined); // Restore dashboard search params when navigating back const search = page === 'dashboard' ? dashboardSearchRef.current : ''; - const url = hash ? `${path}${search}#${hash}` : `${path}${search}`; - window.history.pushState({ page }, '', url); + const url = buildPageUrl(page, inviteCode ?? undefined, search, targetHash); + window.history.pushState({ page, hash: targetHash }, '', url); if (page === 'dashboard') { setMapUrlState(parseUrlState()); + setDashboardRouteKey(window.location.search); } + setRouteHash(targetHash); setActivePage(page); + if (targetHash) scrollToHash(targetHash); }, [inviteCode] ); + useEffect(() => { + if (authLoading || !user || postAuthIntent !== 'checkout') return; + + setPostAuthIntent(null); + setShowAuthModal(false); + const checkoutReturnPath = postAuthCheckoutReturnPathRef.current ?? undefined; + postAuthCheckoutReturnPathRef.current = null; + if (hasFullAccess(user)) { + if (checkoutReturnPath?.startsWith('/dashboard')) { + window.history.pushState({ page: 'dashboard', hash: '' }, '', checkoutReturnPath); + setMapUrlState(parseUrlState()); + setDashboardRouteKey(window.location.search); + setRouteHash(''); + setActivePage('dashboard'); + } else { + navigateTo('dashboard'); + } + return; + } + + startPostAuthCheckout(checkoutReturnPath).catch((error) => { + logNonAbortError('Failed to resume checkout after auth', error); + navigateTo('pricing'); + }); + }, [authLoading, navigateTo, postAuthIntent, startPostAuthCheckout, user]); + useEffect(() => { activePageRef.current = activePage; }, [activePage]); useEffect(() => { if (!window.history.state?.page) { + const hash = routeHash || normalizeHash(window.location.hash); window.history.replaceState( - { page: activePage }, + { page: activePage, hash }, '', - pageToPath(activePage, inviteCode ?? undefined) + - window.location.search + - window.location.hash + buildPageUrl(activePage, inviteCode ?? undefined, window.location.search, hash) ); } const handlePopState = (e: PopStateEvent) => { let page: Page; + const hash = normalizeHash(window.location.hash); if (e.state?.page) { page = e.state.page; setActivePage(page); + setRouteHash(hash || e.state.hash || ''); if (e.state.infoFeature) { setPendingInfoFeature(e.state.infoFeature); } @@ -287,11 +432,13 @@ export default function App() { const parsed = pathToPage(window.location.pathname); page = parsed?.page || 'home'; setActivePage(page); + setRouteHash(parsed?.hash ?? hash); if (parsed?.inviteCode) setInviteCode(parsed.inviteCode); } // Re-parse URL state when returning to dashboard via back/forward if (page === 'dashboard') { setMapUrlState(parseUrlState()); + setDashboardRouteKey(window.location.search); } }; window.addEventListener('popstate', handlePopState); @@ -299,30 +446,22 @@ export default function App() { }, []); // eslint-disable-line react-hooks/exhaustive-deps const { fetchSearches } = savedSearches; - const { fetchProperties: fetchSavedProperties } = savedProperties; useEffect(() => { if (activePage === 'saved') { fetchSearches(); - fetchSavedProperties(); } - if (activePage === 'dashboard' && user) { - fetchSavedProperties(); - } - }, [activePage, fetchSearches, fetchSavedProperties, user]); + }, [activePage, fetchSearches]); - const isAuthRequiredPage = - activePage === 'account' || activePage === 'saved' || activePage === 'invites'; + const isAuthRequiredPage = activePage === 'account' || activePage === 'saved'; useEffect(() => { if (authLoading) return; if (isAuthRequiredPage && !user) { - setAuthModalTab('login'); - setShowAuthModal(true); - navigateTo('home'); + openAuthModal('login'); } - if (activePage === 'pricing' && (user?.subscription === 'licensed' || user?.isAdmin)) { + if (activePage === 'pricing' && hasFullAccess(user)) { navigateTo('dashboard'); } - }, [activePage, isAuthRequiredPage, user, authLoading, navigateTo]); + }, [activePage, authLoading, isAuthRequiredPage, navigateTo, openAuthModal, user]); const [exportState, setExportState] = useState(null); @@ -372,21 +511,17 @@ export default function App() {
setShowSaveModal(true) : null} savingSearch={savedSearches.saving} user={user} - onLoginClick={() => { - setAuthModalTab('login'); - setShowAuthModal(true); - }} - onRegisterClick={() => { - setAuthModalTab('register'); - setShowAuthModal(true); - }} + onLoginClick={() => openAuthModal('login')} + onRegisterClick={() => openAuthModal('register')} onLogout={logout} isMobile={isMobile} /> @@ -402,14 +537,8 @@ export default function App() { navigateTo('dashboard')} user={user} - onLoginClick={() => { - setAuthModalTab('login'); - setShowAuthModal(true); - }} - onRegisterClick={() => { - setAuthModalTab('register'); - setShowAuthModal(true); - }} + onLoginClick={() => openAuthModal('login', 'checkout', '/pricing')} + onRegisterClick={() => openAuthModal('register', 'checkout', '/pricing')} /> ) : activePage === 'learn' ? ( @@ -427,38 +556,32 @@ export default function App() { onOpenSearch={(params) => { window.location.href = `/dashboard?${params}`; }} - savedProperties={savedProperties.properties} - propertiesLoading={savedProperties.loading} - onDeleteProperty={savedProperties.deleteProperty} - onUpdatePropertyNotes={savedProperties.updatePropertyNotes} - onOpenProperty={(postcode) => { - window.location.href = `/dashboard?pc=${encodeURIComponent(postcode)}`; - }} /> - ) : activePage === 'invites' && user ? ( - ) : activePage === 'account' && user ? ( - + { + await refreshAuth(); + }} + scrollTarget={routeHash} + /> + ) : isAuthRequiredPage && !user ? ( + ) : activePage === 'invite' && inviteCode ? ( { - setAuthModalTab('login'); - setShowAuthModal(true); - }} - onRegisterClick={() => { - setAuthModalTab('register'); - setShowAuthModal(true); - }} + onLoginClick={() => openAuthModal('login')} + onRegisterClick={() => openAuthModal('register')} onLicenseGranted={() => { - setShowLicenseSuccess(true); + setLicenseSuccessStatus('success'); refreshAuth(); }} /> ) : ( setPendingInfoFeature(null)} onNavigateTo={navigateTo} onExportStateChange={setExportState} + onDashboardParamsChange={setDashboardParams} isMobile={isMobile} initialTravelTime={mapUrlState.travelTime} initialPostcode={mapUrlState.postcode} shareCode={mapUrlState.share} user={user} - onLoginClick={() => { - setAuthModalTab('login'); - setShowAuthModal(true); - }} - onRegisterClick={() => { - setAuthModalTab('register'); - setShowAuthModal(true); - }} - onSaveProperty={user ? savedProperties.saveProperty : undefined} - onUnsaveProperty={user ? savedProperties.deleteProperty : undefined} - isPropertySaved={user ? savedProperties.isPropertySaved : undefined} - getSavedPropertyId={user ? savedProperties.getSavedPropertyId : undefined} - deferTutorial={showLicenseSuccess} + onLoginClick={() => openAuthModal('login')} + onRegisterClick={() => openAuthModal('register')} + onCheckoutLoginClick={(returnPath) => openAuthModal('login', 'checkout', returnPath)} + onCheckoutRegisterClick={(returnPath) => + openAuthModal('register', 'checkout', returnPath) + } + deferTutorial={licenseSuccessStatus !== 'hidden'} onSaveSearch={user ? savedSearches.saveSearch : undefined} savingSearch={savedSearches.saving} /> @@ -497,7 +615,10 @@ export default function App() { {showAuthModal && ( setShowAuthModal(false)} + onClose={closeAuthModal} + onAuthenticated={() => { + authCompletedRef.current = true; + }} onLogin={login} onRegister={register} onOAuthLogin={loginWithOAuth} @@ -511,7 +632,7 @@ export default function App() { {showSaveModal && ( setShowSaveModal(false)} - onSave={savedSearches.saveSearch} + onSave={(name) => savedSearches.saveSearch(name, dashboardParams)} onViewSearches={() => { setShowSaveModal(false); navigateTo('saved'); @@ -520,11 +641,13 @@ export default function App() { error={savedSearches.error} /> )} - {showLicenseSuccess && ( + {licenseSuccessStatus !== 'hidden' && ( { - setShowLicenseSuccess(false); - navigateTo('dashboard'); + const shouldOpenDashboard = licenseSuccessStatus === 'success'; + setLicenseSuccessStatus('hidden'); + if (shouldOpenDashboard) navigateTo('dashboard'); }} /> )} diff --git a/frontend/src/components/account/AccountPage.tsx b/frontend/src/components/account/AccountPage.tsx index dcdfdb0..5d8d68c 100644 --- a/frontend/src/components/account/AccountPage.tsx +++ b/frontend/src/components/account/AccountPage.tsx @@ -2,7 +2,6 @@ import { useState, useCallback, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import type { AuthUser } from '../../hooks/useAuth'; import type { SavedSearch } from '../../hooks/useSavedSearches'; -import type { SavedProperty, SavedPropertyData } from '../../hooks/useSavedProperties'; import { apiUrl, authHeaders, assertOk, shortenUrl, prewarmScreenshot } from '../../lib/api'; import { copyToClipboard } from '../../lib/clipboard'; import { formatRelativeTime, formatNumber } from '../../lib/format'; @@ -11,7 +10,6 @@ import { SpinnerIcon } from '../ui/icons/SpinnerIcon'; import { CheckIcon } from '../ui/icons/CheckIcon'; import { ClipboardIcon } from '../ui/icons/ClipboardIcon'; import { BookmarkIcon } from '../ui/icons/BookmarkIcon'; -import { HouseIcon } from '../ui/icons/HouseIcon'; import { TrashIcon } from '../ui/icons/TrashIcon'; import { CloseIcon } from '../ui/icons/CloseIcon'; import { useLicense } from '../../hooks/useLicense'; @@ -126,24 +124,6 @@ function NotesInput({ value, onSave }: { value: string; onSave: (notes: string) ); } -function formatPropertyPrice(data: SavedPropertyData): string | null { - if (data.estimatedPrice) return `~£${formatNumber(data.estimatedPrice)}`; - if (data.price) return `£${formatNumber(data.price)}`; - return null; -} - -function formatPropertyDetails( - data: SavedPropertyData, - t: { (key: 'savedPage.bed'): string; (key: 'savedPage.epc'): string } -): string { - const parts: string[] = []; - if (data.propertySubType) parts.push(data.propertySubType); - else if (data.propertyType) parts.push(data.propertyType); - if (data.floorArea) parts.push(`${formatNumber(data.floorArea)}m²`); - if (data.energyRating) parts.push(`${t('savedPage.epc')} ${data.energyRating}`); - return parts.join(' · '); -} - function EditableName({ value, onSave }: { value: string; onSave: (name: string) => void }) { const { t } = useTranslation(); const [editing, setEditing] = useState(false); @@ -355,115 +335,6 @@ function SavedSearchesTab({ ); } -function SavedPropertiesTab({ - properties, - loading, - onDelete, - onUpdateNotes, - onOpen, -}: { - properties: SavedProperty[]; - loading: boolean; - onDelete: (id: string) => Promise; - onUpdateNotes: (id: string, notes: string) => void; - onOpen: (postcode: string) => void; -}) { - const { t } = useTranslation(); - const [deleteConfirmId, setDeleteConfirmId] = useState(null); - - const handleDeleteConfirm = useCallback(async () => { - if (!deleteConfirmId) return; - await onDelete(deleteConfirmId); - setDeleteConfirmId(null); - }, [deleteConfirmId, onDelete]); - - if (loading) { - return ( -
- -
- ); - } - - if (properties.length === 0) { - return ( -
- -

- {t('savedPage.noSavedProperties')} -

-

- {t('savedPage.noSavedPropertiesDesc')} -

-
- ); - } - - return ( - <> -
- {properties.map((prop) => { - const price = formatPropertyPrice(prop.data); - const details = formatPropertyDetails(prop.data, t); - return ( -
-
-

- {prop.address} -

-
-

{prop.postcode}

- {price && ( -

{price}

- )} - {details && ( -

{details}

- )} -

- {formatRelativeTime(prop.created)} -

- -
- onUpdateNotes(prop.id, notes)} /> -
- -
-
- - -
-
-
- ); - })} -
- - {deleteConfirmId && ( - setDeleteConfirmId(null)} - onConfirm={handleDeleteConfirm} - /> - )} - - ); -} - export function SavedPage({ searches, searchesLoading, @@ -471,11 +342,6 @@ export function SavedPage({ onUpdateSearchNotes, onUpdateSearchName, onOpenSearch, - savedProperties, - propertiesLoading, - onDeleteProperty, - onUpdatePropertyNotes, - onOpenProperty, }: { searches: SavedSearch[]; searchesLoading: boolean; @@ -483,15 +349,10 @@ export function SavedPage({ onUpdateSearchNotes: (id: string, notes: string) => void; onUpdateSearchName: (id: string, name: string) => void; onOpenSearch: (params: string) => void; - savedProperties: SavedProperty[]; - propertiesLoading: boolean; - onDeleteProperty: (id: string) => Promise; - onUpdatePropertyNotes: (id: string, notes: string) => void; - onOpenProperty: (postcode: string) => void; }) { const { t } = useTranslation(); - const [activeTab, setActiveTab] = useState<'searches' | 'properties'>( - window.location.hash === '#properties' ? 'properties' : 'searches' + const [activeTab, setActiveTab] = useState<'searches' | 'shared-links'>( + window.location.hash === '#shared-links' ? 'shared-links' : 'searches' ); const tabClass = (tab: string) => @@ -512,13 +373,8 @@ export function SavedPage({ )} -
@@ -532,13 +388,7 @@ export function SavedPage({ onOpen={onOpenSearch} /> ) : ( - + )} ); @@ -655,7 +505,7 @@ function InviteTable({ ); } -function ShareLinksSection() { +function ShareLinksSection({ showTitle = true }: { showTitle?: boolean }) { const { t } = useTranslation(); const [links, setLinks] = useState([]); const [loading, setLoading] = useState(false); @@ -698,9 +548,11 @@ function ShareLinksSection() { return (
-

- {t('accountPage.shareLinksTitle')} -

+ {showTitle && ( +

+ {t('accountPage.shareLinksTitle')} +

+ )}
{loading ? (
@@ -1048,8 +900,6 @@ export default function AccountPage({
- - {/* Support */}

{t('accountPage.needHelp')}

diff --git a/frontend/src/components/home/HomeFinalCta.tsx b/frontend/src/components/home/HomeFinalCta.tsx index 0d3158d..e114b10 100644 --- a/frontend/src/components/home/HomeFinalCta.tsx +++ b/frontend/src/components/home/HomeFinalCta.tsx @@ -5,7 +5,7 @@ const HOME_SECTION_HEADING_CLASS = 'text-2xl md:text-3xl font-bold text-navy-950 dark:text-warm-100'; const HOME_BODY_CLASS = 'text-base leading-relaxed text-warm-600 dark:text-warm-400'; const HOME_PRIMARY_BUTTON_CLASS = - 'bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-base shadow-lg shadow-coral-500/25 text-center'; + 'border border-[#d27a11] bg-[#f09a22] text-navy-950 rounded-lg font-semibold hover:bg-[#df8614] transition-colors text-base shadow-lg shadow-[#7a3905]/25 text-center'; export default function HomeFinalCta({ onOpenDashboard, diff --git a/frontend/src/components/home/HomePage.tsx b/frontend/src/components/home/HomePage.tsx index 35a0ffe..8e929ff 100644 --- a/frontend/src/components/home/HomePage.tsx +++ b/frontend/src/components/home/HomePage.tsx @@ -1,6 +1,7 @@ import { lazy, Suspense, useState, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { useFadeInRef } from '../../hooks/useFadeIn'; +import { useIsMobile } from '../../hooks/useIsMobile'; import HexCanvas from './HexCanvas'; import HomeFinalCta from './HomeFinalCta'; import BottomIllustration from './BottomIllustration'; @@ -15,7 +16,7 @@ const HOME_SECTION_HEADING_CLASS = 'text-2xl md:text-3xl font-bold text-navy-950 dark:text-warm-100'; const HOME_BODY_CLASS = 'text-base leading-relaxed text-warm-600 dark:text-warm-400'; const HOME_PRIMARY_BUTTON_CLASS = - 'bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-base shadow-lg shadow-coral-500/25 text-center'; + 'border border-[#d27a11] bg-[#f09a22] text-navy-950 rounded-lg font-semibold hover:bg-[#df8614] transition-colors text-base shadow-lg shadow-[#7a3905]/25 text-center'; const PRODUCT_DEMO_VIDEO_BY_LANGUAGE: Record = { en: 'recording', de: 'recording-de', @@ -34,9 +35,13 @@ function ProductShowcaseFallback({ className = '' }: { className?: string }) { ); } -function getProductDemoSlug(language: string | undefined): string { +function getProductDemoSlug(language: string | undefined, isMobile: boolean): string { const code = language?.toLowerCase().split('-')[0] ?? 'en'; - return PRODUCT_DEMO_VIDEO_BY_LANGUAGE[code] ?? PRODUCT_DEMO_VIDEO_BY_LANGUAGE.en; + const base = PRODUCT_DEMO_VIDEO_BY_LANGUAGE[code] ?? PRODUCT_DEMO_VIDEO_BY_LANGUAGE.en; + // Mobile cuts (9:16, 540x960) are published as `-mobile` alongside + // the 16:9 desktop cuts. The recorder pipeline writes both every render — + // see video/src/storyboard.ts. + return isMobile ? `${base}-mobile` : base; } function highlightBrandText(text: string) { @@ -57,12 +62,13 @@ function highlightBrandText(text: string) { function ProductDemoVideo() { const { t, i18n } = useTranslation(); + const isMobile = useIsMobile(); const sectionRef = useRef(null); const videoRef = useRef(null); const currentVideoSrcRef = useRef(null); const [shouldLoadVideo, setShouldLoadVideo] = useState(false); const [isVideoPlaying, setIsVideoPlaying] = useState(false); - const productDemoSlug = getProductDemoSlug(i18n.language); + const productDemoSlug = getProductDemoSlug(i18n.language, isMobile); const productDemoVideoSrc = `/video/${productDemoSlug}.mp4`; const productDemoPosterSrc = `/video/${productDemoSlug}.jpg`; @@ -123,7 +129,11 @@ function ProductDemoVideo() {

{t('home.productDemoLabel')}

-
+