diff --git a/client/src/components/FourZero/NoFearBeacon.tsx b/client/src/components/FourZero/NoFearBeacon.tsx index a607ba4c7..84444626d 100644 --- a/client/src/components/FourZero/NoFearBeacon.tsx +++ b/client/src/components/FourZero/NoFearBeacon.tsx @@ -25,23 +25,23 @@ export default function NoFearBeacon() { return ( <> - - { e.stopPropagation(); localStorage.setItem(DISMISS_KEY, '1'); setDismissed(true) }} - onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.stopPropagation(); localStorage.setItem(DISMISS_KEY, '1'); setDismissed(true) } }} + aria-label={copy.skip} + onClick={() => { localStorage.setItem(DISMISS_KEY, '1'); setDismissed(true) }} > - -

TREK 4.0.0

-

{copy.beaconTitle}

-

{copy.beaconSub}

- {copy.beaconCta} - + + {open && ( setOpen(false)} /> diff --git a/client/src/components/FourZero/NoFearShow.tsx b/client/src/components/FourZero/NoFearShow.tsx index 05a3f73ba..3ed54ad72 100644 --- a/client/src/components/FourZero/NoFearShow.tsx +++ b/client/src/components/FourZero/NoFearShow.tsx @@ -1,6 +1,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Volume2, VolumeX, X } from 'lucide-react' +import { placesApi, tripsApi } from '../../api/client' +import apiClient from '../../api/client' import { useTranslation } from '../../i18n' +import type { Place, Trip } from '../../types' import { NoFearAudio, type NoFearAct } from './noFearAudio' import { NoFearScene, type SceneState } from './noFearScene' import { ANTHEM_CASCADE, INSPIRED_URL, noFearCopy } from './noFearLines' @@ -15,51 +18,68 @@ interface Cue { act?: NoFearAct line?: LineKey | null hard?: boolean + impact?: number } -// The script. Times in seconds; the scene ramps below are keyed to the same clock. +// The script. Times in seconds; the scene ramps below share the same clock. const CUES: Cue[] = [ { at: 0.8, act: 'fear', line: 'afraid' }, { at: 6.0, line: 'ofTheStranger' }, { at: 12.0, act: 'dread', line: 'fearTool' }, { at: 18.5, line: 'hateTrade' }, - { at: 25.0, act: 'silence', line: null }, - { at: 27.5, line: 'butYouTraveled' }, - { at: 33.0, act: 'hope', line: 'tables' }, - { at: 41.0, line: 'face' }, - { at: 48.0, line: 'fences' }, - { at: 54.5, line: 'notAnOpinion', hard: true }, - { at: 62.0, act: 'anthem', line: null }, - { at: 74.0, act: 'end', line: null }, + { at: 26.0, act: 'silence', line: null }, + { at: 28.5, line: 'butYouTraveled', impact: 0.5 }, + { at: 34.0, act: 'hope', line: 'tables' }, + { at: 42.0, line: 'face' }, + { at: 49.0, line: 'fences' }, + { at: 55.5, line: 'yourPlaces', impact: 0.35 }, + { at: 58.8, line: 'yourStatsCountries' }, + { at: 64.0, line: 'notAnOpinion', hard: true, impact: 1 }, + { at: 71.0, act: 'anthem', line: null, impact: 0.8 }, + { at: 84.0, act: 'end', line: null }, ] -const ANTHEM_AT = 62 -const CREDITS_AT = 74 +const ANTHEM_AT = 71 +const CREDITS_AT = 84 +// The three staccato words strobe with the late fear act, just before the cut. +const FLASH_WINDOWS: [number, number][] = [[22.2, 22.85], [23.6, 24.25], [25.0, 25.65]] const ramp = (t: number, from: number, to: number): number => Math.min(Math.max((t - from) / (to - from), 0), 1) function sceneAt(t: number): SceneState { - // The blackout (25→27.4s) is the show's held breath; everything else is ramps. - const blackout = t >= 25 && t < 31 ? 1 - ramp(t, 30, 31) : 0 + // Hard cut to black at 26s; the pivot line stands alone in the dark, then the + // world returns WITH the hope act (28→31.5) — that overlap is intentional. + const blackout = t >= 26 && t < 31.5 ? 1 - ramp(t, 28, 31.5) : 0 return { - land: ramp(t, 5.5, 9.5), - borderHeat: t < 33 ? ramp(t, 12, 15.5) : 0.45, - borderGone: ramp(t, 54.5, 61.5), - web: ramp(t, 33, 58), - warmth: ramp(t, 33, 45), + land: ramp(t, 6, 10), + cityLife: ramp(t, 6.5, 11), + cityDeath: ramp(t, 13, 22), + borderHeat: t < 34 ? ramp(t, 12, 15.5) : 1 - 0.55 * ramp(t, 34, 38), + borderBurst: ramp(t, 64, 70), + web: ramp(t, 34, 62), + warmth: ramp(t, 34, 46), + personalGlow: ramp(t, 55.5, 60.5), particles: ramp(t, ANTHEM_AT, ANTHEM_AT + 4), opacity: (1 - blackout) * (t >= CREDITS_AT ? 0.35 : 1), } } +/** Formats "{p} places. {c} countries. …" with the traveler's real numbers. */ +function fillStats(template: string, p: number, cOrT: number): string { + return template.replace('{p}', String(p)).replace('{c}', String(cOrT)).replace('{t}', String(cOrT)) +} + export default function NoFearShow({ onClose }: { onClose: () => void }) { const { language } = useTranslation() const copy = useMemo(() => noFearCopy(language), [language]) + const overlayRef = useRef(null) const canvasRef = useRef(null) const audioRef = useRef(null) const sceneRef = useRef(null) const startRef = useRef(performance.now()) + const statsRef = useRef<{ line: string } | null>(null) const [cueIdx, setCueIdx] = useState(-1) + const [flashIdx, setFlashIdx] = useState(-1) const [muted, setMuted] = useState(false) const [credits, setCredits] = useState(false) const [anthem, setAnthem] = useState(false) @@ -72,41 +92,93 @@ export default function NoFearShow({ onClose }: { onClose: () => void }) { audioRef.current?.setAct('end') }, []) - // Boot: audio (user gesture already happened — the beacon click), scene, clock. + // Boot: audio, scene, personal places, chrome side effects. useEffect(() => { + document.body.classList.add('fz-show-open') // hides the dashboard FAB + locks scroll const audio = new NoFearAudio() audioRef.current = audio if (!reducedMotion) audio.start() const scene = new NoFearScene() sceneRef.current = scene + const aborter = new AbortController() const cv = canvasRef.current - if (cv) { - const fit = () => { - const dpr = Math.min(window.devicePixelRatio || 1, 2) - cv.width = Math.round(cv.clientWidth * dpr) - cv.height = Math.round(cv.clientHeight * dpr) - cv.getContext('2d')?.setTransform(dpr, 0, 0, dpr, 0, 0) - scene.layout(cv.clientWidth, cv.clientHeight) - } - fit() - void scene.load(cv.clientWidth, cv.clientHeight) - window.addEventListener('resize', fit) - if (reducedMotion) skipToEnd() - return () => { - window.removeEventListener('resize', fit) - audio.dispose() + const fit = () => { + if (!cv) return + const dpr = Math.min(window.devicePixelRatio || 1, 1.75) + cv.width = Math.round(cv.clientWidth * dpr) + cv.height = Math.round(cv.clientHeight * dpr) + cv.getContext('2d')?.setTransform(dpr, 0, 0, dpr, 0, 0) + scene.layout(cv.clientWidth, cv.clientHeight) + } + fit() + if (cv) void scene.load(cv.clientWidth, cv.clientHeight, aborter.signal) + window.addEventListener('resize', fit) + // The traveler's own places, gathered quietly during the fear act. Fail-soft: + // without them the show falls back to its generic lines. + void (async () => { + try { + const trips = await tripsApi.list() as Trip[] + if (aborter.signal.aborted || !Array.isArray(trips)) return + const lists = await Promise.all(trips.slice(0, 12).map(t => placesApi.list(t.id).catch(() => [] as Place[]))) + if (aborter.signal.aborted) return + const pts = lists.flat().filter((p: Place) => p.lat != null && p.lng != null).map((p: Place) => ({ lat: p.lat as number, lng: p.lng as number })) + if (pts.length >= 3) { + sceneRef.current?.setPersonalPlaces(pts) + // Prefer real country counts from Atlas; fall back to trip counts. + try { + const { data } = await apiClient.get('/addons/atlas/stats', { signal: aborter.signal }) + const countries = data?.stats?.totalCountries + statsRef.current = countries > 0 + ? { line: fillStats(copy.lines.yourStatsCountries, pts.length, countries) } + : { line: fillStats(copy.lines.yourStatsTrips, pts.length, trips.length) } + } catch { + statsRef.current = { line: fillStats(copy.lines.yourStatsTrips, pts.length, trips.length) } + } + } + } catch { /* not signed-in edge / API down — generic show */ } + })() + // Pause the whole show (clock + audio) while the tab is hidden. + let hiddenAt: number | null = null + const onVisibility = () => { + if (document.hidden) { + hiddenAt = performance.now() + audio.setSuspended(true) + } else { + if (hiddenAt !== null) startRef.current += performance.now() - hiddenAt + hiddenAt = null + audio.setSuspended(false) } } - return () => audio.dispose() - }, [reducedMotion, skipToEnd]) + document.addEventListener('visibilitychange', onVisibility) + // Any real gesture inside the overlay doubles as an audio-resume recovery + // for browsers that refused the post-lazy-load AudioContext start. + const onPointer = () => audio.resume() + window.addEventListener('pointerdown', onPointer) + overlayRef.current?.focus() + if (reducedMotion) skipToEnd() + return () => { + document.body.classList.remove('fz-show-open') + window.removeEventListener('resize', fit) + document.removeEventListener('visibilitychange', onVisibility) + window.removeEventListener('pointerdown', onPointer) + aborter.abort() + audio.dispose() + } + }, [reducedMotion, skipToEnd, copy]) // The clock: one rAF loop drives the canvas directly and only touches React - // state on discrete cue changes. + // state on discrete cue/flash changes. Reduced motion renders one static frame. useEffect(() => { - let raf = 0 - let lastCue = -1 const cv = canvasRef.current const ctx = cv?.getContext('2d') + if (reducedMotion) { + setCueIdx(CUES.length - 1) + if (ctx) sceneRef.current?.draw(ctx, { ...sceneAt(CREDITS_AT), particles: 0 }, CREDITS_AT) + return + } + let raf = 0 + let lastCue = -1 + let lastFlash = -1 const tick = () => { const t = (performance.now() - startRef.current) / 1000 let idx = -1 @@ -115,18 +187,27 @@ export default function NoFearShow({ onClose }: { onClose: () => void }) { lastCue = idx const cue = CUES[idx] if (cue.act) audioRef.current?.setAct(cue.act) + if (cue.impact) audioRef.current?.impact(cue.impact) setCueIdx(idx) if (t >= ANTHEM_AT) setAnthem(true) if (t >= CREDITS_AT) setCredits(true) } - if (ctx && cv) sceneRef.current?.draw(ctx, sceneAt(t), t) + let flash = -1 + for (let i = 0; i < FLASH_WINDOWS.length; i++) { + if (t >= FLASH_WINDOWS[i][0] && t < FLASH_WINDOWS[i][1]) flash = i + } + if (flash !== lastFlash) { + lastFlash = flash + setFlashIdx(flash) + } + if (ctx) sceneRef.current?.draw(ctx, sceneAt(t), t) raf = requestAnimationFrame(tick) } raf = requestAnimationFrame(tick) return () => cancelAnimationFrame(raf) - }, []) + }, [reducedMotion]) - // ESC closes, any key after the credits too. + // ESC closes the show at any point. useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() } window.addEventListener('keydown', onKey) @@ -135,19 +216,31 @@ export default function NoFearShow({ onClose }: { onClose: () => void }) { const cue = cueIdx >= 0 ? CUES[cueIdx] : null const lineKey = cue?.line ?? null - const lineText = lineKey ? copy.lines[lineKey] : null + // The personal-stats line only exists when the traveler's data arrived; the + // yourPlaces line falls back to the generic "every light" line without it. + let lineText: string | null = null + if (lineKey === 'yourStatsCountries') lineText = statsRef.current?.line ?? null + else if (lineKey === 'yourPlaces') lineText = statsRef.current ? copy.lines.yourPlaces : copy.lines.everyDot + else if (lineKey) lineText = copy.lines[lineKey] const hard = !!cue?.hard return ( -
+
{/* Red vignette while the borders burn. */} {!anthem && cueIdx >= 2 && cueIdx <= 3 &&
} + {/* Staccato words strobing to the heartbeat, right before the cut. */} + {flashIdx >= 0 && !anthem && ( +
+ {copy.flashes[flashIdx]} +
+ )} + {/* The lines. */} - {!anthem && lineText && ( -
+ {!anthem && lineText && flashIdx < 0 && ( +

{lineText}

)} @@ -170,6 +263,7 @@ export default function NoFearShow({ onClose }: { onClose: () => void }) { {credits && (

{copy.anthem}

+

{copy.tooBeautiful}

{copy.creditTitle}

{copy.creditBody}

diff --git a/client/src/components/FourZero/fourzero.css b/client/src/components/FourZero/fourzero.css index b3e3bd39a..4063e807c 100644 --- a/client/src/components/FourZero/fourzero.css +++ b/client/src/components/FourZero/fourzero.css @@ -65,6 +65,32 @@ to { opacity: 1; transform: translateY(0); filter: blur(0); } } +/* Staccato words strobing to the heartbeat in the late fear act. */ +.fz-flash-wrap { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; +} + +.fz-flash { + font-size: clamp(52px, 9vw, 130px); + font-weight: 900; + letter-spacing: 0.08em; + color: #ff4b3e; + text-shadow: 0 0 42px rgba(235, 50, 45, 0.65), 0 2px 18px rgba(0, 0, 0, 0.9); + animation: fzFlashIn 0.65s cubic-bezier(0.16, 1, 0.3, 1) both; +} + +@keyframes fzFlashIn { + 0% { opacity: 0; transform: scale(1.28); filter: blur(8px); } + 18% { opacity: 1; transform: scale(1); filter: blur(0); } + 80% { opacity: 1; } + 100% { opacity: 0; } +} + /* ── anthem ── */ .fz-anthem { @@ -134,6 +160,15 @@ animation: fzLineIn 1.8s ease-out both; } +.fz-credit-lead { + font-size: clamp(16px, 1.7vw, 23px); + font-weight: 300; + font-style: italic; + opacity: 0.95; + margin: 6px 0 4px; + max-width: 680px; +} + .fz-credit-title { font-size: clamp(17px, 1.8vw, 24px); font-weight: 700; @@ -216,36 +251,46 @@ /* ── beacon (dashboard trigger) ── */ +/* Wrapper is a div so the dismiss can be a real sibling