SkateSpots/src/components/SplashScreen.tsx
SkateSpots 3e6354995a i18n(es): batch 3 part 1 — routing UI, Sasquatch panel, collections, splash
- 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.
2026-10-02 15:15:06 +07:00

292 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;