- routeUi.* (123 keys): RouteDialog (incl. follow-mode banner, transit time picker + mode filters, itinerary legs), StopDepartures, TripItineraryCard. - sasquatchUi.* (81 keys): SasquatchGuide, SasquatchProDialog, SasquatchActionCard, SasquatchAiStatusCard. LLM prompts + chat bodies deliberately left English. - collectionUi.* / bootUi.*: AddToCollectionDialog example placeholders, SplashScreen rotating status lines + aria labels. - New src/i18n/batch3/* fragment files, spread into en/es so parallel passes never edit the big literal.
292 lines
11 KiB
TypeScript
292 lines
11 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
||
|
||
import { useTranslation } from '@/contexts/language';
|
||
|
||
/**
|
||
* Branded cold-start splash — a choreographed, premium launch moment.
|
||
*
|
||
* Nostr relays connect over WebSockets that NPool opens lazily; on a fresh load
|
||
* the first content arrives only after the handshakes complete. Rather than show
|
||
* a half-empty map that reads as "is it broken?", we present a short, intentional
|
||
* branded moment while the pre-warmed relays connect and the first spots stream
|
||
* in underneath. It NEVER traps the user:
|
||
*
|
||
* - Determinate progress driven by real milestones (mount → warm → content).
|
||
* - Reveals the instant real content arrives (`skatespots:content-ready`),
|
||
* with a short grace after connection (`skatespots:relays-warm`).
|
||
* - A hard max timeout guarantees dismissal even on a dead/slow network.
|
||
* - A minimum on-screen time prevents a jarring flash on instant loads.
|
||
*
|
||
* Shown once per browser session so in-session navigation never replays it.
|
||
* Fully respects prefers-reduced-motion and safe-area insets; identical on
|
||
* phone and desktop.
|
||
*/
|
||
|
||
const SESSION_KEY = 'splash-shown-session';
|
||
const MIN_MS = 900; // choreography needs room to breathe
|
||
const WARM_GRACE_MS = 550; // wait after connection for content to stream in
|
||
const MAX_MS = 2600; // hard cap — never block on a slow/offline network
|
||
const FADE_MS = 480;
|
||
const READY_HOLD_MS = 340; // savour the 100% "ready" beat before revealing
|
||
|
||
const STATUS_KEYS = [
|
||
'bootUi.statusWaking',
|
||
'bootUi.statusTuning',
|
||
'bootUi.statusPulling',
|
||
'bootUi.statusDropping',
|
||
];
|
||
|
||
export function SplashScreen() {
|
||
const { t } = useTranslation();
|
||
const [visible, setVisible] = useState(() => {
|
||
try {
|
||
return sessionStorage.getItem(SESSION_KEY) !== 'true';
|
||
} catch {
|
||
return true;
|
||
}
|
||
});
|
||
const [leaving, setLeaving] = useState(false);
|
||
const [ready, setReady] = useState(false);
|
||
const [statusIndex, setStatusIndex] = useState(0);
|
||
// Determinate progress (0–100). Creeps forward on its own, snaps toward
|
||
// milestones, and finishes at 100 the moment we're ready.
|
||
const [progress, setProgress] = useState(8);
|
||
const mountedAt = useRef(Date.now());
|
||
|
||
const prefersReducedMotion =
|
||
typeof window !== 'undefined' &&
|
||
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
||
|
||
// Rotate the status line (paused once ready — the label swaps to "Ready").
|
||
useEffect(() => {
|
||
if (!visible || ready) return;
|
||
const id = setInterval(() => {
|
||
setStatusIndex((i) => (i + 1) % STATUS_KEYS.length);
|
||
}, 640);
|
||
return () => clearInterval(id);
|
||
}, [visible, ready]);
|
||
|
||
// Ambient progress creep — asymptotically approaches ~85% so it always feels
|
||
// like it's making headway without ever "completing" before we're truly ready.
|
||
useEffect(() => {
|
||
if (!visible || ready) return;
|
||
const id = setInterval(() => {
|
||
setProgress((p) => (p >= 85 ? p : p + Math.max(0.6, (85 - p) * 0.045)));
|
||
}, 90);
|
||
return () => clearInterval(id);
|
||
}, [visible, ready]);
|
||
|
||
// Dismissal logic. Priority:
|
||
// 1. Real content arrived (`content-ready`) → reveal a populated app.
|
||
// 2. Relays connected (`relays-warm`) → short grace, then reveal.
|
||
// 3. Hard cap (MAX_MS) → always dismiss, even offline. Never traps anyone.
|
||
// All paths respect the MIN_MS floor, then play the 100%→hold→fade sequence.
|
||
useEffect(() => {
|
||
if (!visible) return;
|
||
|
||
let done = false;
|
||
let graceTimer: ReturnType<typeof setTimeout> | undefined;
|
||
|
||
const finish = () => {
|
||
if (done) return;
|
||
done = true;
|
||
if (graceTimer) clearTimeout(graceTimer);
|
||
|
||
const elapsed = Date.now() - mountedAt.current;
|
||
const wait = Math.max(0, MIN_MS - elapsed);
|
||
|
||
setTimeout(() => {
|
||
// Land on 100% + a satisfying "ready" beat, then crossfade out.
|
||
setReady(true);
|
||
setProgress(100);
|
||
setTimeout(() => {
|
||
setLeaving(true);
|
||
setTimeout(() => {
|
||
try {
|
||
sessionStorage.setItem(SESSION_KEY, 'true');
|
||
} catch {
|
||
// ignore private-mode storage failures
|
||
}
|
||
setVisible(false);
|
||
}, FADE_MS);
|
||
}, READY_HOLD_MS);
|
||
}, wait);
|
||
};
|
||
|
||
const onContent = () => finish();
|
||
const onWarm = () => {
|
||
if (done || graceTimer) return;
|
||
// Nudge progress up on connection so the bar visibly reacts to a milestone.
|
||
setProgress((p) => Math.max(p, 78));
|
||
graceTimer = setTimeout(finish, WARM_GRACE_MS);
|
||
};
|
||
|
||
window.addEventListener('skatespots:content-ready', onContent);
|
||
window.addEventListener('skatespots:relays-warm', onWarm);
|
||
const cap = setTimeout(finish, MAX_MS);
|
||
|
||
return () => {
|
||
window.removeEventListener('skatespots:content-ready', onContent);
|
||
window.removeEventListener('skatespots:relays-warm', onWarm);
|
||
if (graceTimer) clearTimeout(graceTimer);
|
||
clearTimeout(cap);
|
||
};
|
||
}, [visible]);
|
||
|
||
if (!visible) return null;
|
||
|
||
const anim = (a: string): React.CSSProperties =>
|
||
prefersReducedMotion ? {} : { animation: a };
|
||
|
||
return (
|
||
<div
|
||
className="fixed inset-0 z-[9999] flex flex-col items-center justify-center bg-[#050505] overflow-hidden"
|
||
style={{
|
||
opacity: leaving ? 0 : 1,
|
||
transform: leaving && !prefersReducedMotion ? 'scale(1.04)' : 'scale(1)',
|
||
transition: `opacity ${FADE_MS}ms cubic-bezier(0.4,0,0.2,1), transform ${FADE_MS}ms cubic-bezier(0.4,0,0.2,1)`,
|
||
paddingTop: 'env(safe-area-inset-top, 0px)',
|
||
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
|
||
paddingLeft: 'env(safe-area-inset-left, 0px)',
|
||
paddingRight: 'env(safe-area-inset-right, 0px)',
|
||
}}
|
||
role="status"
|
||
aria-live="polite"
|
||
aria-label={ready ? t('bootUi.ariaReady') : t('bootUi.ariaLoading')}
|
||
>
|
||
{/* Fine grid texture — subtle depth, like a skate map underlay */}
|
||
<div
|
||
className="pointer-events-none absolute inset-0 opacity-[0.04]"
|
||
style={{
|
||
backgroundImage:
|
||
'linear-gradient(#fff 1px, transparent 1px), linear-gradient(90deg, #fff 1px, transparent 1px)',
|
||
backgroundSize: '38px 38px',
|
||
maskImage: 'radial-gradient(ellipse at center, #000 30%, transparent 72%)',
|
||
WebkitMaskImage: 'radial-gradient(ellipse at center, #000 30%, transparent 72%)',
|
||
}}
|
||
/>
|
||
|
||
{/* Dual breathing glow — red core with a lime undertone */}
|
||
<div
|
||
className="pointer-events-none absolute left-1/2 top-1/2 h-[520px] w-[520px] -translate-x-1/2 -translate-y-1/2 rounded-full blur-[110px]"
|
||
style={{
|
||
background:
|
||
'radial-gradient(circle, rgba(255,0,64,0.30) 0%, rgba(204,255,0,0.07) 46%, transparent 70%)',
|
||
...anim('splashGlow 3.4s ease-in-out infinite'),
|
||
}}
|
||
/>
|
||
|
||
<div className="relative z-10 flex flex-col items-center px-8">
|
||
{/* Logo in a glowing conic halo ring */}
|
||
<div className="relative" style={anim('splashRise 620ms cubic-bezier(0.22,1,0.36,1) both')}>
|
||
{/* Rotating conic ring */}
|
||
<div
|
||
className="absolute -inset-3 rounded-[26px]"
|
||
style={{
|
||
background:
|
||
'conic-gradient(from 0deg, transparent 0deg, #ff0040 90deg, #ccff00 180deg, transparent 300deg)',
|
||
opacity: ready ? 0.9 : 0.55,
|
||
filter: 'blur(6px)',
|
||
transition: 'opacity 400ms ease',
|
||
...anim('splashSpin 3.2s linear infinite'),
|
||
}}
|
||
aria-hidden="true"
|
||
/>
|
||
{/* Mask ring interior so only a thin halo shows */}
|
||
<div className="absolute -inset-1 rounded-[22px] bg-[#050505]" aria-hidden="true" />
|
||
<div style={anim('splashPulse 2.2s ease-in-out infinite')} className="relative">
|
||
<img
|
||
src="/images/icon-1024.png"
|
||
alt="SkateSpots"
|
||
className="relative h-24 w-24 rounded-[20px] shadow-[0_0_50px_rgba(255,0,64,0.55)]"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Wordmark */}
|
||
<h1
|
||
className="font-display neon-red mt-7 text-4xl tracking-[0.06em]"
|
||
style={{
|
||
textShadow: '0 0 14px #ff0040, 0 0 30px rgba(255,0,64,0.6)',
|
||
...anim('splashRise 620ms cubic-bezier(0.22,1,0.36,1) both'),
|
||
animationDelay: prefersReducedMotion ? undefined : '120ms',
|
||
}}
|
||
>
|
||
SKATESPOTS
|
||
</h1>
|
||
|
||
{/* Determinate progress bar with a moving shine */}
|
||
<div
|
||
className="relative mt-8 h-1.5 w-52 overflow-hidden rounded-full bg-white/[0.06] ring-1 ring-white/[0.05]"
|
||
style={{
|
||
...anim('splashRise 620ms cubic-bezier(0.22,1,0.36,1) both'),
|
||
animationDelay: prefersReducedMotion ? undefined : '220ms',
|
||
}}
|
||
role="progressbar"
|
||
aria-valuemin={0}
|
||
aria-valuemax={100}
|
||
aria-valuenow={Math.round(progress)}
|
||
>
|
||
<div
|
||
className="relative h-full rounded-full bg-gradient-to-r from-[#ff0040] via-[#ff0040] to-[#ccff00]"
|
||
style={{
|
||
width: `${progress}%`,
|
||
transition: 'width 420ms cubic-bezier(0.4,0,0.2,1)',
|
||
boxShadow: '0 0 12px rgba(255,0,64,0.6)',
|
||
}}
|
||
>
|
||
{/* Sliding shine highlight */}
|
||
{!prefersReducedMotion && (
|
||
<span
|
||
className="absolute inset-y-0 w-1/3 rounded-full"
|
||
style={{
|
||
background:
|
||
'linear-gradient(90deg, transparent, rgba(255,255,255,0.55), transparent)',
|
||
animation: 'splashShine 1.4s ease-in-out infinite',
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Status line — swaps to a confident "Ready" with a check when done */}
|
||
<div
|
||
className="mt-5 flex h-4 items-center gap-2"
|
||
style={{
|
||
...anim('splashRise 620ms cubic-bezier(0.22,1,0.36,1) both'),
|
||
animationDelay: prefersReducedMotion ? undefined : '320ms',
|
||
}}
|
||
>
|
||
{ready ? (
|
||
<span
|
||
className="flex items-center gap-1.5 font-mono text-[11px] uppercase tracking-[0.28em] text-[#ccff00]"
|
||
style={anim('splashFade 300ms ease-out')}
|
||
>
|
||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||
<path
|
||
d="M20 6L9 17l-5-5"
|
||
stroke="#ccff00"
|
||
strokeWidth="3"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
/>
|
||
</svg>
|
||
{t('bootUi.ready')}
|
||
</span>
|
||
) : (
|
||
<span
|
||
key={statusIndex}
|
||
className="font-mono text-[11px] uppercase tracking-[0.28em] text-gray-500"
|
||
style={anim('splashFade 360ms ease-out')}
|
||
>
|
||
{t(STATUS_KEYS[statusIndex])}
|
||
{!prefersReducedMotion && <span className="ml-0.5 animate-pulse">…</span>}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default SplashScreen;
|