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.
This commit is contained in:
SkateSpots 2026-10-02 15:15:06 +07:00
parent a6dd32e653
commit 3e6354995a
16 changed files with 872 additions and 226 deletions

View file

@ -206,7 +206,7 @@ export function AddToCollectionDialog({ open, onOpenChange, spot }: AddToCollect
id="title"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder="Barcelona Trip 2024"
placeholder={t('collectionUi.namePlaceholder')}
className="bg-black border-gray-700 text-white h-11"
autoFocus
/>
@ -217,7 +217,7 @@ export function AddToCollectionDialog({ open, onOpenChange, spot }: AddToCollect
id="description"
value={newDescription}
onChange={(e) => setNewDescription(e.target.value)}
placeholder="My favorite spots from my Barcelona trip..."
placeholder={t('collectionUi.descriptionPlaceholder')}
className="bg-black border-gray-700 text-white resize-none"
rows={2}
/>

View file

@ -45,6 +45,25 @@ import {
transitPrimaryEmoji,
} from '@/lib/transitUi';
import { StopDepartures } from '@/components/StopDepartures';
import { useTranslation } from '@/contexts/language';
/** Travel-mode labels live in `lib/routing`; map them to translation keys at render. */
const MODE_LABEL_KEYS: Record<TravelMode, string> = {
foot: 'routeUi.mode.foot',
bike: 'routeUi.mode.bike',
moto: 'routeUi.mode.moto',
car: 'routeUi.mode.car',
transit: 'routeUi.mode.transit',
};
/** Transit mode-group labels live in `lib/transitUi`; translate at render. */
const TRANSIT_GROUP_LABEL_KEYS: Record<string, string> = {
bus: 'routeUi.group.bus',
tram: 'routeUi.group.tram',
metro: 'routeUi.group.metro',
train: 'routeUi.group.train',
ferry: 'routeUi.group.ferry',
};
/** A stop name that opens the departure board, when the feed gave a stop id. */
function StopButton({
@ -88,12 +107,12 @@ interface RouteDialogProps {
* maps app is opened — the route comes from the keyless public OSRM server and
* is drawn here. See src/lib/routing.ts for the honest scope notes.
*/
/** The average speed the routing engine assumed for this mode, e.g. "21 km/h avg". */
function speedLabel(distanceM: number, durationS: number): string | undefined {
/** The average speed the routing engine assumed for this mode, e.g. "21". */
function speedValue(distanceM: number, durationS: number): string | undefined {
if (!(distanceM > 0) || !(durationS > 0)) return undefined;
const kmh = distanceM / 1000 / (durationS / 3600);
if (!Number.isFinite(kmh) || kmh <= 0) return undefined;
return `${kmh < 10 ? kmh.toFixed(1) : Math.round(kmh)} km/h avg`;
return kmh < 10 ? kmh.toFixed(1) : String(Math.round(kmh));
}
/** Local clock time you'd arrive if you set off now. */
@ -108,15 +127,16 @@ export function RouteDialog({
open,
onOpenChange,
destination,
destinationLabel = 'Destination',
destinationLabel,
onDestinationChange,
}: RouteDialogProps) {
const { t } = useTranslation();
// The destination is owned HERE so the in-dialog search can change it without
// the parent re-opening the dialog. Seeded from the prop whenever the dialog
// OPENS (or the parent's target actually changes) — not on every parent
// render, which would revert a search the user just made.
const [dest, setDest] = useState(destination);
const [destLabel, setDestLabel] = useState(destinationLabel);
const [destLabel, setDestLabel] = useState(destinationLabel ?? '');
const [searchOpen, setSearchOpen] = useState(false);
const lastPropKeyRef = useRef<string>('');
// The map's initial centre; read by the init effect without making it a dep
@ -133,7 +153,7 @@ export function RouteDialog({
if (key === lastPropKeyRef.current) return;
lastPropKeyRef.current = key;
setDest(destination);
setDestLabel(destinationLabel);
setDestLabel(destinationLabel ?? '');
}, [open, destination, destinationLabel]);
// The map container is inside Radix Dialog content, which mounts AFTER this
// component's first effect pass. A plain useRef is therefore still null when
@ -362,7 +382,7 @@ export function RouteDialog({
setFollowing(true);
void acquireWakeLock();
// Spoken from the Start tap (a user gesture) — this also unlocks TTS.
speak('Starting navigation');
speak(t('routeUi.voiceStart'));
watchRef.current = watchPosition(
(coords) => {
setLive(coords);
@ -373,14 +393,14 @@ export function RouteDialog({
// loss is normal (tunnels, indoors) and the watcher recovers by itself,
// so we keep running and just say we're waiting.
if (err.kind === 'denied') {
setFollowError('Location permission denied.');
setFollowError(t('routeUi.errPermissionDenied'));
stopFollowing();
} else {
setFollowError('Waiting for a GPS fix…');
setFollowError(t('routeUi.errWaitingGps'));
}
},
);
}, [stopFollowing, acquireWakeLock, speak]);
}, [stopFollowing, acquireWakeLock, speak, t]);
// Never leave the GPS running: stop on close and on unmount.
useEffect(() => {
@ -446,7 +466,7 @@ export function RouteDialog({
if (nav.toDestKm < 0.03) {
if (!arrivedRef.current) {
arrivedRef.current = true;
speak("You've arrived");
speak(t('routeUi.voiceArrived'));
buzz([120, 80, 120]);
setArrived(true);
stopFollowing();
@ -466,16 +486,19 @@ export function RouteDialog({
bands.add(150);
announcedRef.current.set(idx, bands);
const metres = Math.max(50, Math.round((km * 1000) / 10) * 10);
speak(`In ${metres} metres, ${steps[idx]?.instruction ?? 'continue'}`);
speak(t('routeUi.voiceIn', {
metres,
instruction: steps[idx]?.instruction ?? t('routeUi.voiceContinue'),
}));
buzz(60);
} else if (km <= 0.04 && !bands.has(40)) {
// ~40 m out — the turn itself.
bands.add(40);
announcedRef.current.set(idx, bands);
speak(steps[idx]?.instruction ?? 'Continue');
speak(steps[idx]?.instruction ?? t('routeUi.voiceContinueCaps'));
buzz(140);
}
}, [following, nav, route, speak, buzz, stopFollowing]);
}, [following, nav, route, speak, buzz, stopFollowing, t]);
// ─── Get the user's location when the dialog opens ─────────────────────────
const locate = useCallback(async () => {
@ -488,14 +511,14 @@ export function RouteDialog({
const msg =
err instanceof GeolocationError
? err.kind === 'denied'
? 'Location permission denied. Enable location to route from where you are.'
: 'Could not get your location. Try again.'
: 'Could not get your location.';
? t('routeUi.errPermissionDeniedLong')
: t('routeUi.errNoLocation')
: t('routeUi.errNoLocationShort');
setLocError(msg);
} finally {
setLocating(false);
}
}, []);
}, [t]);
useEffect(() => {
if (!open) return;
@ -677,16 +700,16 @@ export function RouteDialog({
setError(
msg === 'No transit route found'
? selectedTransitModes.length > 0 || transitTimeMode !== 'now'
? 'No route for that time or transport mode — try another time, or include more modes.'
: 'No public-transport route found here — this area may not publish open timetables.'
: "Couldn't reach the transit service. Check your connection and try again.",
? t('routeUi.errTransitNoRouteFiltered')
: t('routeUi.errTransitNoRoute')
: t('routeUi.errTransitUnreachable'),
);
} else if (msg === ROUTE_ERR_BUSY) {
setError('The routing service is busy right now — try again in a moment.');
setError(t('routeUi.errRoutingBusy'));
} else if (msg === ROUTE_ERR_NO_ROUTE) {
setError('No route for this mode — the destination may be off the road network. Try another mode.');
setError(t('routeUi.errNoRouteMode'));
} else {
setError("Couldn't reach the routing service. Check your connection and try again.");
setError(t('routeUi.errRoutingUnreachable'));
}
// Offline / server down: fall back to the last route we saved for this
@ -723,7 +746,7 @@ export function RouteDialog({
cancelled = true;
controller.abort();
};
}, [open, effectiveOrigin, dest, mode, transitTimeMode, transitTimeValue, selectedTransitModes]);
}, [open, effectiveOrigin, dest, mode, transitTimeMode, transitTimeValue, selectedTransitModes, t]);
// ─── Draw the selected transit itinerary (redraws when an alternative is
// chosen — no re-query). Walking legs dashed grey, vehicles solid. ──────
@ -754,6 +777,9 @@ export function RouteDialog({
}
}, [selectedTransit]);
const destLabelText = destLabel || t('routeUi.destination');
const speed = route ? speedValue(route.distanceM, route.durationS) : undefined;
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
@ -763,7 +789,7 @@ export function RouteDialog({
scroll with the Start/Stop bar pinned at the bottom. */}
<DialogContent className="flex max-h-[calc(100dvh-2rem)] w-[calc(100%-2rem)] max-w-lg flex-col gap-0 overflow-hidden p-0 sm:max-w-xl">
<DialogHeader className="order-1 shrink-0 pl-4 pr-12 pt-4 pb-3">
<DialogTitle className="sr-only">Route to {destLabel}</DialogTitle>
<DialogTitle className="sr-only">{t('routeUi.routeTo', { label: destLabelText })}</DialogTitle>
<button
type="button"
onClick={() => setSearchOpen(true)}
@ -772,19 +798,19 @@ export function RouteDialog({
<Navigation className="h-5 w-5 shrink-0 text-[#ccff00]" />
<span className="min-w-0 flex-1">
<span className="block text-[10px] font-bold uppercase tracking-widest text-gray-500">
Route to
{t('routeUi.routeToLabel')}
</span>
<span className="block truncate text-base font-bold text-white">{destLabel}</span>
<span className="block truncate text-base font-bold text-white">{destLabelText}</span>
</span>
<span className="flex shrink-0 items-center gap-1.5 rounded-full border border-white/10 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider text-gray-300">
<Search className="h-3 w-3" /> Search
<Search className="h-3 w-3" /> {t('routeUi.search')}
</span>
</button>
{/* Copy kept out of the visible chrome so the route screen stays
clean; the same promise now lives on /privacy → "What leaves your
device" → Directions. sr-only keeps the dialog accessible. */}
<DialogDescription className="sr-only">
Route options and in-app directions.
{t('routeUi.description')}
</DialogDescription>
</DialogHeader>
@ -796,7 +822,7 @@ export function RouteDialog({
<div
ref={setMapDiv}
className="h-full w-full bg-[#0b0b0c]"
aria-label="Route map"
aria-label={t('routeUi.routeMapAria')}
/>
{/* Destination search — overlays the map while open. Mounted only
@ -823,7 +849,7 @@ export function RouteDialog({
<div className="absolute inset-0 flex items-center justify-center bg-background/60 backdrop-blur-sm">
<div className="flex items-center gap-2 text-sm font-medium">
<Loader2 className="h-4 w-4 animate-spin text-[#ccff00]" />
{locating && !origin ? 'Finding your location…' : 'Plotting your route…'}
{locating && !origin ? t('routeUi.findingLocation') : t('routeUi.plottingRoute')}
</div>
</div>
)}
@ -834,13 +860,13 @@ export function RouteDialog({
<div className="pointer-events-none absolute bottom-2 left-2 flex flex-col items-start gap-1">
{cachedAge !== null && (
<div className="rounded-full border border-amber-500/40 bg-amber-500/15 px-2 py-1 text-[10px] font-bold uppercase tracking-wide text-amber-300 backdrop-blur-sm">
Offline · saved route {formatAge(cachedAge)}
{t('routeUi.offlineSavedRoute', { age: formatAge(cachedAge) })}
</div>
)}
{locating && origin && (
<div className="flex items-center gap-1.5 rounded-full bg-background/80 px-2 py-1 text-[10px] font-medium text-muted-foreground backdrop-blur-sm">
<Loader2 className="h-3 w-3 animate-spin" />
Refining location…
{t('routeUi.refiningLocation')}
</div>
)}
</div>
@ -849,7 +875,7 @@ export function RouteDialog({
{/* "Choose on map" hint */}
{pickingStart && (
<div className="pointer-events-none absolute inset-x-2 top-2 rounded-xl bg-black/80 px-3 py-2 text-center text-[11px] font-bold text-[#ccff00] backdrop-blur-sm">
Tap the map to set your start point
{t('routeUi.tapMapStart')}
</div>
)}
@ -857,18 +883,18 @@ export function RouteDialog({
{following && nav && (
<div className="pointer-events-none absolute inset-x-2 top-2 rounded-xl bg-black/85 px-3 py-2 backdrop-blur-sm">
<p className="text-[11px] font-black uppercase tracking-widest text-[#ccff00]">
Next{nav.stepIndex >= 0 ? ` · step ${nav.stepIndex + 1}` : ''}
{nav.stepIndex >= 0 ? t('routeUi.nextStep', { n: nav.stepIndex + 1 }) : t('routeUi.next')}
</p>
<p className="truncate text-base font-black text-white">
{nav.stepIndex >= 0
? route?.steps?.[nav.stepIndex]?.instruction ?? 'Follow the line'
: route?.steps?.[0]?.instruction ?? 'Follow the line'}
? route?.steps?.[nav.stepIndex]?.instruction ?? t('routeUi.followTheLine')
: route?.steps?.[0]?.instruction ?? t('routeUi.followTheLine')}
</p>
<p className="text-xs text-gray-400">
{nav.stepIndex >= 0 && Number.isFinite(nav.stepKm)
? `${formatDistance(nav.stepKm)} to the turn · `
? `${formatDistance(nav.stepKm)} ${t('routeUi.toTheTurn')} · `
: ''}
{formatDistance(nav.toDestKm)} to go
{formatDistance(nav.toDestKm)} {t('routeUi.toGo')}
</p>
</div>
)}
@ -886,7 +912,7 @@ export function RouteDialog({
<p className="text-sm text-muted-foreground">{locError}</p>
<Button size="sm" variant="outline" onClick={locate} className="gap-2">
<LocateFixed className="h-4 w-4" />
Try again
{t('routeUi.tryAgain')}
</Button>
</div>
</div>
@ -898,10 +924,10 @@ export function RouteDialog({
type="button"
onClick={fitRoute}
className="absolute bottom-2 right-2 flex items-center gap-1 rounded-lg border border-white/10 bg-background/85 px-2 py-1.5 text-[10px] font-bold uppercase tracking-wide text-muted-foreground shadow backdrop-blur-sm transition-colors hover:text-foreground"
aria-label="Fit the whole route on the map"
aria-label={t('routeUi.fitRouteAria')}
>
<LocateFixed className="h-3.5 w-3.5" />
Whole route
{t('routeUi.wholeRoute')}
</button>
)}
</div>
@ -922,7 +948,7 @@ export function RouteDialog({
aria-pressed={mode === m.mode}
>
<span className="text-lg leading-none">{m.emoji}</span>
<span className="text-[10px] leading-tight text-center">{m.label}</span>
<span className="text-[10px] leading-tight text-center">{t(MODE_LABEL_KEYS[m.mode])}</span>
</button>
))}
</div>
@ -930,7 +956,7 @@ export function RouteDialog({
{/* Start point — default is where you are; you can pick another on the map */}
<div className="order-4 flex shrink-0 flex-wrap items-center gap-1.5 px-4 pb-3">
<span className="text-[10px] font-black uppercase tracking-widest text-muted-foreground">
From
{t('routeUi.from')}
</span>
<button
type="button"
@ -945,7 +971,7 @@ export function RouteDialog({
: 'border-white/10 text-muted-foreground hover:text-foreground',
)}
>
My location
{t('routeUi.myLocation')}
</button>
<button
type="button"
@ -958,7 +984,7 @@ export function RouteDialog({
)}
>
<Crosshair className="h-3 w-3" />
{pickingStart ? 'Tap the map…' : 'Choose on map'}
{pickingStart ? t('routeUi.tapTheMap') : t('routeUi.chooseOnMap')}
</button>
</div>
@ -983,9 +1009,11 @@ export function RouteDialog({
<div className="text-xs text-muted-foreground">
{formatDuration(selectedTransit.durationS)} ·{' '}
{selectedTransit.transfers === 0
? 'direct'
: `${selectedTransit.transfers} transfer${selectedTransit.transfers > 1 ? 's' : ''}`}
{options.length > 1 && ` · ${options.length} options`}
? t('routeUi.direct')
: selectedTransit.transfers === 1
? t('routeUi.transferOne')
: t('routeUi.transfers', { count: selectedTransit.transfers })}
{options.length > 1 && ` · ${t('routeUi.options', { count: options.length })}`}
{routing && <Loader2 className="ml-1 inline h-3 w-3 animate-spin" />}
</div>
</div>
@ -1021,13 +1049,13 @@ export function RouteDialog({
{formatDuration(route.durationS)}
</span>
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
ride
{t('routeUi.ride')}
</span>
</span>
<span aria-hidden className="h-3.5 w-px bg-white/15" />
<span className="flex items-baseline gap-1.5">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
arrive
{t('routeUi.arrive')}
</span>
<span className="text-base font-black tabular-nums text-white">
{etaClock(route.durationS)}
@ -1040,24 +1068,24 @@ export function RouteDialog({
</span>
<span>
{route.steps && route.steps.length > 1
? `${route.steps.length} steps`
: 'direct'}
? t('routeUi.stepsCount', { count: route.steps.length })
: t('routeUi.direct')}
</span>
{mode === 'foot'
? <span>· {skateTime(route.distanceM / 1000)} on a board</span>
: speedLabel(route.distanceM, route.durationS) && (
<span>· {speedLabel(route.distanceM, route.durationS)}</span>
? <span>· {skateTime(route.distanceM / 1000)} {t('routeUi.onABoard')}</span>
: speed && (
<span>· {t('routeUi.speedAvg', { speed })}</span>
)}
{TRAVEL_MODES.find((m) => m.mode === mode)?.approximate && (
<span>· uses driving roads</span>
<span>· {t('routeUi.usesDrivingRoads')}</span>
)}
{route.approximate && <span>· driving estimate</span>}
{routing && <span>· updating…</span>}
{route.approximate && <span>· {t('routeUi.drivingEstimate')}</span>}
{routing && <span>· {t('routeUi.updating')}</span>}
</p>
</div>
) : (
<div className="text-sm text-muted-foreground">
{origin ? 'Pick a mode to see your route.' : 'Waiting for your location…'}
{origin ? t('routeUi.pickMode') : t('routeUi.waitingLocation')}
</div>
)}
</div>
@ -1068,13 +1096,13 @@ export function RouteDialog({
<div className="border-t bg-muted/20">
<div className="flex flex-wrap items-baseline justify-between gap-x-2 px-4 pt-3">
<p className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-muted-foreground">
Route steps
{t('routeUi.routeSteps')}
<span className="rounded-full bg-white/10 px-1.5 py-0.5 text-[9px] tabular-nums">
{route.steps.length}
</span>
</p>
{mode === 'foot' && (
<p className="text-[10px] text-muted-foreground/70">walking route — quicker on a board</p>
<p className="text-[10px] text-muted-foreground/70">{t('routeUi.walkingRoute')}</p>
)}
</div>
<ol className="max-h-72 space-y-1.5 overflow-y-auto px-4 pb-2 pt-1.5">
@ -1109,7 +1137,7 @@ export function RouteDialog({
))}
</ol>
<p className="px-4 pb-3 text-[10px] text-muted-foreground/60">
Planning view — tap Start to follow with live turns, voice and arrival.
{t('routeUi.planningView')}
</p>
</div>
)}
@ -1133,7 +1161,7 @@ export function RouteDialog({
: 'border-white/10 text-gray-400 hover:border-white/25',
)}
>
{m === 'now' ? 'Leave now' : m === 'depart' ? 'Depart at' : 'Arrive by'}
{m === 'now' ? t('routeUi.leaveNow') : m === 'depart' ? t('routeUi.departAt') : t('routeUi.arriveBy')}
</button>
))}
{transitTimeMode !== 'now' && (
@ -1141,7 +1169,7 @@ export function RouteDialog({
type="datetime-local"
value={transitTimeValue}
onChange={(e) => setTransitTimeValue(e.target.value)}
aria-label={transitTimeMode === 'depart' ? 'Departure time' : 'Arrival time'}
aria-label={transitTimeMode === 'depart' ? t('routeUi.departureTime') : t('routeUi.arrivalTime')}
className="min-h-0 rounded-lg border border-white/15 bg-white/[0.04] px-2 py-1 text-[11px] text-white"
/>
)}
@ -1149,7 +1177,7 @@ export function RouteDialog({
{/* Only: filter the modes (nothing selected = all) */}
<div className="flex flex-wrap items-center gap-1.5 px-4 pt-2">
<span className="text-[10px] font-black uppercase tracking-widest text-muted-foreground">Only</span>
<span className="text-[10px] font-black uppercase tracking-widest text-muted-foreground">{t('routeUi.only')}</span>
{TRANSIT_MODE_GROUPS.map((g) => {
const on = transitModeFilter.includes(g.key);
return (
@ -1166,7 +1194,7 @@ export function RouteDialog({
)}
>
<span aria-hidden>{g.emoji}</span>
{g.label}
{t(TRANSIT_GROUP_LABEL_KEYS[g.key] ?? g.label)}
</button>
);
})}
@ -1176,7 +1204,7 @@ export function RouteDialog({
onClick={() => setTransitModeFilter([])}
className="min-h-0 rounded-full px-2 py-0.5 text-[10px] font-bold text-muted-foreground underline decoration-dotted hover:text-foreground"
>
clear
{t('routeUi.clear')}
</button>
)}
</div>
@ -1210,8 +1238,10 @@ export function RouteDialog({
<span aria-hidden>{modes.join('')}</span>
{formatDuration(opt.durationS)} ·{' '}
{opt.transfers === 0
? 'direct'
: `${opt.transfers} transfer${opt.transfers > 1 ? 's' : ''}`}
? t('routeUi.direct')
: opt.transfers === 1
? t('routeUi.transferOne')
: t('routeUi.transfers', { count: opt.transfers })}
</span>
</button>
);
@ -1220,7 +1250,7 @@ export function RouteDialog({
)}
<p className="flex items-center gap-2 px-4 pt-3 text-[10px] font-black uppercase tracking-widest text-muted-foreground">
Itinerary
{t('routeUi.itinerary')}
<span className="rounded-full bg-white/10 px-1.5 py-0.5 text-[9px] tabular-nums">
{selectedTransit.legs.length}
</span>
@ -1251,7 +1281,7 @@ export function RouteDialog({
)}
<span className="text-xs font-bold text-white">
{isFoot
? leg.mode === 'BIKE' ? 'Ride' : 'Walk'
? leg.mode === 'BIKE' ? t('routeUi.rideVerb') : t('routeUi.walk')
: leg.headsign
? `→ ${leg.headsign}`
: (leg.line ?? leg.mode)}
@ -1261,7 +1291,7 @@ export function RouteDialog({
)}
{leg.realTime && (
<span className="rounded bg-[#00ff88]/15 px-1 text-[9px] font-black uppercase text-[#00ff88]">
live
{t('routeUi.live')}
</span>
)}
</div>
@ -1270,14 +1300,14 @@ export function RouteDialog({
<>
{leg.toName ? (
<>
to{' '}
{t('routeUi.toStop')}{' '}
<StopButton
id={leg.toStopId}
name={leg.toName}
onOpen={(id, name) => setStopBoard({ id, name })}
/>
</>
) : leg.mode === 'BIKE' ? 'ride' : 'walk'}
) : leg.mode === 'BIKE' ? t('routeUi.rideLower') : t('routeUi.walkLower')}
{leg.distanceM ? ` · ${formatDistance(leg.distanceM / 1000)}` : ''}
</>
) : (
@ -1287,16 +1317,16 @@ export function RouteDialog({
name={leg.fromName}
onOpen={(id, name) => setStopBoard({ id, name })}
/>
{leg.fromTrack ? ` · track ${leg.fromTrack}` : ''} {startClock}
{leg.fromTrack ? ` · ${t('routeUi.track', { track: leg.fromTrack })}` : ''} {startClock}
{' → '}
<StopButton
id={leg.toStopId}
name={leg.toName}
onOpen={(id, name) => setStopBoard({ id, name })}
/>
{leg.toTrack ? ` · track ${leg.toTrack}` : ''} {endClock}
{leg.toTrack ? ` · ${t('routeUi.track', { track: leg.toTrack })}` : ''} {endClock}
{typeof leg.stops === 'number' && leg.stops > 0
? ` · ${leg.stops} stop${leg.stops > 1 ? 's' : ''}`
? ` · ${leg.stops === 1 ? t('routeUi.stopOne') : t('routeUi.stopsCount', { count: leg.stops })}`
: ''}
{leg.agencyName ? ` · ${leg.agencyName}` : ''}
{leg.agencyFareUrl ? (
@ -1308,7 +1338,7 @@ export function RouteDialog({
rel="noopener noreferrer"
className="underline decoration-dotted underline-offset-2 hover:text-foreground"
>
tickets ↗
{t('routeUi.tickets')}
</a>
</>
) : ''}
@ -1335,7 +1365,7 @@ export function RouteDialog({
className="gap-2 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
>
<Check className="h-3.5 w-3.5" />
Done
{t('routeUi.done')}
</Button>
) : following ? (
<Button
@ -1345,7 +1375,7 @@ export function RouteDialog({
className="gap-2 border-[#ff0040]/50 text-[#ff0040] hover:bg-[#ff0040]/10"
>
<Square className="h-3.5 w-3.5" />
Stop
{t('routeUi.stop')}
</Button>
) : (
<Button
@ -1354,23 +1384,23 @@ export function RouteDialog({
className="gap-2 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
>
<Play className="h-3.5 w-3.5" />
Start
{t('routeUi.start')}
</Button>
)}
<p className="min-w-0 flex-1 text-[11px] leading-snug text-muted-foreground">
{arrived
? "You've arrived. 🛹"
? t('routeUi.arrived')
: following
? voiceOn
? 'Screen stays on · voice on — turns and arrival are spoken.'
: 'Screen stays on · voice off.'
: 'Turns, voice and arrival as you roll. Keeps the screen on.'}
? t('routeUi.voiceOnNote')
: t('routeUi.voiceOffNote')
: t('routeUi.followNote')}
</p>
<button
type="button"
onClick={toggleVoice}
aria-pressed={voiceOn}
aria-label={voiceOn ? 'Turn voice guidance off' : 'Turn voice guidance on'}
aria-label={voiceOn ? t('routeUi.voiceOffAria') : t('routeUi.voiceOnAria')}
className={cn(
'flex h-9 w-9 shrink-0 items-center justify-center rounded-full border transition-colors',
voiceOn

View file

@ -9,6 +9,7 @@ import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Loader2, Check, X, Wand2 } from 'lucide-react';
import { useTranslation } from '@/contexts/language';
import {
summarizeDraft,
type SasquatchDraft,
@ -24,20 +25,21 @@ interface Props {
}
export function SasquatchActionCard({ draft, onPublish, onCancel, status, resultLabel }: Props) {
const { t } = useTranslation();
const [edited, setEdited] = useState<SasquatchDraft>(draft);
const summary = summarizeDraft(edited);
if (status === 'done') {
return (
<div className="rounded-xl border border-[#ccff00]/30 bg-[#ccff00]/10 p-3 text-xs text-[#ccff00] flex items-center gap-2">
<Check className="h-4 w-4 shrink-0" /> {resultLabel ?? 'Done!'}
<Check className="h-4 w-4 shrink-0" /> {resultLabel ?? t('sasquatchUi.done')}
</div>
);
}
if (status === 'cancelled') {
return (
<div className="rounded-xl border border-white/10 bg-white/[0.03] p-3 text-xs text-gray-500 flex items-center gap-2">
<X className="h-4 w-4 shrink-0" /> Cancelled — nothing was published.
<X className="h-4 w-4 shrink-0" /> {t('sasquatchUi.cancelledNothing')}
</div>
);
}
@ -47,7 +49,7 @@ export function SasquatchActionCard({ draft, onPublish, onCancel, status, result
return (
<div className="rounded-xl border border-red-500/30 bg-red-500/[0.06] p-3 space-y-2.5">
<div className="flex items-center gap-1.5 text-[10px] font-black uppercase tracking-wider text-red-400">
<Wand2 className="h-3 w-3" /> Draft — review & confirm
<Wand2 className="h-3 w-3" /> {t('sasquatchUi.draftReview')}
</div>
<p className="text-sm font-bold text-white">{summary.title}</p>
@ -59,7 +61,7 @@ export function SasquatchActionCard({ draft, onPublish, onCancel, status, result
onChange={(e) => setEdited({ ...edited, text: e.target.value })}
disabled={busy}
className="h-9 bg-black/40 border-white/10 text-white text-sm"
placeholder="Status text"
placeholder={t('sasquatchUi.statusTextPlaceholder')}
/>
) : (
<div className="grid grid-cols-2 gap-2">
@ -68,7 +70,7 @@ export function SasquatchActionCard({ draft, onPublish, onCancel, status, result
onChange={(e) => setEdited({ ...edited, title: e.target.value })}
disabled={busy}
className="col-span-2 h-9 bg-black/40 border-white/10 text-white text-sm"
placeholder="Title"
placeholder={t('sasquatchUi.titlePlaceholder')}
/>
<Input
value={edited.price ?? ''}
@ -77,14 +79,14 @@ export function SasquatchActionCard({ draft, onPublish, onCancel, status, result
type="number"
inputMode="numeric"
className="h-9 bg-black/40 border-white/10 text-white text-sm"
placeholder="Price (sats)"
placeholder={t('sasquatchUi.priceSatsPlaceholder')}
/>
<Input
value={edited.condition ?? ''}
onChange={(e) => setEdited({ ...edited, condition: e.target.value })}
disabled={busy}
className="h-9 bg-black/40 border-white/10 text-white text-sm"
placeholder="Condition"
placeholder={t('sasquatchUi.conditionPlaceholder')}
/>
</div>
)}
@ -104,7 +106,7 @@ export function SasquatchActionCard({ draft, onPublish, onCancel, status, result
onClick={onCancel}
disabled={busy}
>
<X className="h-3.5 w-3.5" /> Cancel
<X className="h-3.5 w-3.5" /> {t('sasquatchUi.cancel')}
</Button>
<Button
size="sm"

View file

@ -3,6 +3,7 @@ import { Card, CardContent } from '@/components/ui/card';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions';
import { useSasquatchAiStatus } from '@/hooks/useSasquatchAiStatus';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils';
/**
@ -11,6 +12,7 @@ import { cn } from '@/lib/utils';
* needed, since the operator can see it live in Settings.
*/
export function SasquatchAiStatusCard() {
const { t } = useTranslation();
const { user } = useCurrentUser();
const isOwner = !!user?.pubkey && PLATFORM_OWNER_PUBKEYS.includes(user.pubkey);
const { data, isLoading, isError } = useSasquatchAiStatus();
@ -30,9 +32,9 @@ export function SasquatchAiStatusCard() {
) : (
<Coins className="h-4 w-4 text-red-400" />
)}
Sasquatch AI
{t('sasquatchUi.aiTitle')}
<span className="rounded bg-red-500/10 px-1.5 py-0.5 text-[9px] font-black uppercase tracking-widest text-red-400/70">
Owner
{t('sasquatchUi.owner')}
</span>
</p>
<span
@ -44,18 +46,18 @@ export function SasquatchAiStatusCard() {
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : isError || data?.configured === false ? (
'not configured'
t('sasquatchUi.notConfigured')
) : balance === null || balance === undefined ? (
'unavailable'
t('sasquatchUi.unavailable')
) : (
`${balance.toLocaleString()} sats`
t('sasquatchUi.satsValue', { balance: balance.toLocaleString() })
)}
</span>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{low
? `Running low — top up at ${data?.provider ?? 'the node'} or AI will fall back to the keyless provider.`
: 'Pay-per-request AI, funded in sats via Routstr. Tops up when low.'}
? t('sasquatchUi.lowBalance', { provider: data?.provider ?? t('sasquatchUi.theNode') })
: t('sasquatchUi.aiDesc')}
</p>
</CardContent>
</Card>

View file

@ -576,6 +576,7 @@ function getStaticAnswer(question: string): string | null {
// ─── CVM connection status indicator ─────────────────────────────────────────
function CVMStatus({ connected }: { connected: boolean | null }) {
const { t } = useTranslation();
if (connected === null) return null;
return (
<div className={cn(
@ -585,9 +586,9 @@ function CVMStatus({ connected }: { connected: boolean | null }) {
: 'bg-white/5 text-gray-500'
)}>
{connected ? (
<><Wifi className="h-2 w-2" /> live</>
<><Wifi className="h-2 w-2" /> {t('sasquatchUi.live')}</>
) : (
<><WifiOff className="h-2 w-2" /> offline</>
<><WifiOff className="h-2 w-2" /> {t('sasquatchUi.offline')}</>
)}
</div>
);
@ -645,7 +646,7 @@ function ChatText({ text }: { text: string }) {
export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
const { user } = useCurrentUser();
const { language } = useTranslation();
const { t, language } = useTranslation();
const cvm = useCVMClient();
const personal = usePersonalContext();
const { isPro, expiresAt, proof } = useSasquatchPro();
@ -849,7 +850,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
const answerNearestSpots = useCallback(async (): Promise<boolean> => {
let loc = getLastLocation();
try {
setStreamingText('📍 Getting your location…');
setStreamingText(t('sasquatchUi.gettingLocation'));
const fix = await getCurrentPosition();
loc = { lat: fix.lat, lng: fix.lng, at: Date.now() };
saveLastLocation(fix.lat, fix.lng);
@ -868,7 +869,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
}
try {
setStreamingText('🔍 Finding the closest spots…');
setStreamingText(t('sasquatchUi.findingClosest'));
const res = await cvm.findSpots({ limit: 500 });
const nearest = (res.spots ?? [])
.map((s) => {
@ -910,7 +911,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
setStreamingText('');
return false;
}
}, [cvm, appendMessage]);
}, [cvm, appendMessage, t]);
// ── Core: handle a user question ───────────────────────────────────────────
@ -990,7 +991,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
const fastIntent = isPersonalQuery ? null : parseLocalIntent(question);
if (fastIntent && fastIntent.tool !== 'plan_trip') {
try {
setStreamingText('🔍 Checking live data…');
setStreamingText(t('sasquatchUi.checkingLive'));
let fastResult: unknown;
switch (fastIntent.tool) {
case 'find_spots':
@ -1208,7 +1209,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
timestamp: Date.now(),
});
} else {
setStreamingText(`🗺️ Planning your ${destination} trip...`);
setStreamingText(t('sasquatchUi.planningTrip', { destination }));
try {
const itinerary = await runTripPlanner(destination, typeof a.days === 'number' ? a.days : 3);
setStreamingText('');
@ -1244,7 +1245,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
if (toolCall) {
const { tool: toolName, args: toolArgs } = toolCall;
setStreamingText(`🔍 Searching the relays for ${toolName.replace(/_/g, ' ')}...`);
setStreamingText(t('sasquatchUi.searchingRelays', { tool: toolName.replace(/_/g, ' ') }));
try {
// Query the Nostr relays directly (useCVMClient) — aggregates across
@ -1394,7 +1395,7 @@ Instructions:
// Trip planning gets its own rich-card path (not a text blob).
if (intent && intent.tool === 'plan_trip' && intent.destination) {
try {
setStreamingText(`🗺️ Planning your ${intent.destination} trip...`);
setStreamingText(t('sasquatchUi.planningTrip', { destination: intent.destination }));
const itinerary = await runTripPlanner(intent.destination, intent.days);
setStreamingText('');
if (itinerary.empty) {
@ -1421,7 +1422,7 @@ Instructions:
if (intent && intent.tool !== 'plan_trip') {
try {
setStreamingText('🔍 Checking live data...');
setStreamingText(t('sasquatchUi.checkingLive'));
let localResult: unknown;
switch (intent.tool) {
case 'find_spots':
@ -1488,7 +1489,7 @@ Instructions:
setIsThinking(false);
setStreamingText('');
}
}, [userInput, isThinking, chatMessages, cvm, personal, isPro, proof, user, appendMessage, runTripPlanner, answerNearestSpots, language]);
}, [userInput, isThinking, chatMessages, cvm, personal, isPro, proof, user, appendMessage, runTripPlanner, answerNearestSpots, language, t]);
// Listen for external events from FAQ/About pages
useEffect(() => {
@ -1543,18 +1544,18 @@ Instructions:
// Build a fallback generic message for pages with no specific context
const activeMessage = message ?? {
id: 'generic',
title: 'SkateSpots Assistant 🦧',
message: "Yo! I'm The Sasquatch. Ask me anything about spots, events, coaches, gear, or how the app works. I've got live data from the SkateSpots relay!",
title: t('sasquatchUi.genericTitle'),
message: t('sasquatchUi.genericGreeting'),
context: undefined,
suggestedQuestions: [
'Plan me 3 days in Lisbon',
'Find spots near me',
'Any upcoming events?',
'What are crews?',
'How do zaps work?',
'What is Nostr?',
'What is My Stash?',
'Is this free?',
t('sasquatchUi.q1'),
t('sasquatchUi.q2'),
t('sasquatchUi.q3'),
t('sasquatchUi.q4'),
t('sasquatchUi.q5'),
t('sasquatchUi.q6'),
t('sasquatchUi.q7'),
t('sasquatchUi.q8'),
],
enableChat: true,
};
@ -1577,7 +1578,7 @@ Instructions:
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onClick={onBubbleClick}
title="Press and drag to move · Tap to open"
title={t('sasquatchUi.bubbleTitle')}
>
<div
className={cn(
@ -1588,7 +1589,7 @@ Instructions:
background: 'radial-gradient(circle at 40% 35%, #2d4a1e 0%, #1a2e0f 50%, #0d1a07 100%)',
boxShadow: '0 0 20px rgba(204,255,0,0.2), 0 4px 20px rgba(0,0,0,0.8)',
}}
aria-label="Open Sasquatch guide"
aria-label={t('sasquatchUi.openGuideAria')}
>
<img
src="/images/sasquatch-guide.webp"
@ -1655,7 +1656,7 @@ Instructions:
{isPro ? (
<span
className="flex items-center gap-0.5 rounded bg-[#ccff00]/20 px-1 py-0.5 text-[8px] font-black uppercase tracking-wider text-[#ccff00]"
title={expiresAt ? `Active until ${new Date(expiresAt * 1000).toLocaleDateString()}` : 'Platform owner'}
title={expiresAt ? t('sasquatchUi.activeUntil', { date: new Date(expiresAt * 1000).toLocaleDateString() }) : t('sasquatchUi.platformOwner')}
>
⚡ Pro{proDaysLeft !== null ? ` · ${proDaysLeft}d` : ''}
</span>
@ -1663,10 +1664,10 @@ Instructions:
<button
onClick={() => setProDialogOpen(true)}
className="ml-0.5 inline-flex items-center gap-1 rounded-full border border-[#ccff00]/40 bg-[#ccff00]/10 px-2 py-0.5 text-[9px] font-black uppercase tracking-wider text-[#ccff00] transition-colors hover:border-[#ccff00]/70 hover:bg-[#ccff00]/20"
title="Upgrade to Sasquatch Pro"
title={t('sasquatchUi.upgradeTitle')}
>
<span aria-hidden>⚡</span>
Go Pro
{t('sasquatchUi.goPro')}
</button>
) : null}
</div>
@ -1681,9 +1682,9 @@ Instructions:
'ml-auto flex-shrink-0 rounded px-1 py-0.5 text-[8px] font-black uppercase tracking-wider',
aiStatus.data.low ? 'bg-amber-500/20 text-amber-400' : 'bg-emerald-500/15 text-emerald-400',
)}
title={`Sasquatch AI credit balance (owner-only) — top up at ${aiStatus.data.provider ?? 'the node'}`}
title={t('sasquatchUi.aiBalanceTitle', { provider: aiStatus.data.provider ?? t('sasquatchUi.theNode') })}
>
⚡ AI {aiStatus.data.balanceSats.toLocaleString()}
{t('sasquatchUi.aiChip', { balance: aiStatus.data.balanceSats.toLocaleString() })}
</span>
)}
</div>
@ -1693,8 +1694,8 @@ Instructions:
<button
onClick={() => { if (recentlyOpened()) return; setIsMinimized(true); }}
className="w-6 h-6 flex items-center justify-center rounded text-[#ccff00]/40 hover:text-[#ccff00] hover:bg-[#ccff00]/10 transition-colors"
aria-label="Minimize to bubble"
title="Minimize"
aria-label={t('sasquatchUi.minimizeToBubbleAria')}
title={t('sasquatchUi.minimize')}
>
<Minimize2 className="h-3 w-3" />
</button>
@ -1702,8 +1703,8 @@ Instructions:
<button
onClick={() => { if (recentlyOpened()) return; setIsVisible(false); if (onDismiss) onDismiss(); }}
className="w-6 h-6 flex items-center justify-center rounded text-white/30 hover:text-white/80 hover:bg-white/10 transition-colors"
aria-label="Close panel"
title="Close"
aria-label={t('sasquatchUi.closePanelAria')}
title={t('sasquatchUi.close')}
>
<X className="h-3 w-3" />
</button>
@ -1740,10 +1741,10 @@ Instructions:
className="w-full py-2 px-3 rounded-lg text-xs font-bold text-[#ccff00] border border-[#ccff00]/30 hover:border-[#ccff00]/60 hover:bg-[#ccff00]/5 transition-all flex items-center justify-center gap-1.5"
>
<HelpCircle className="h-3 w-3" />
{activeMessage.context === 'lab' && 'Ask about Coaching & Filming'}
{activeMessage.context === 'map' && 'Ask about Spots, Shops & Gear'}
{(activeMessage.context === 'home' || activeMessage.context === 'onboarding') && 'Ask me anything 🛹'}
{(!activeMessage.context || (activeMessage.context !== 'lab' && activeMessage.context !== 'map' && activeMessage.context !== 'home' && activeMessage.context !== 'onboarding')) && 'Ask me anything 🛹'}
{activeMessage.context === 'lab' && t('sasquatchUi.askLab')}
{activeMessage.context === 'map' && t('sasquatchUi.askMap')}
{(activeMessage.context === 'home' || activeMessage.context === 'onboarding') && t('sasquatchUi.askAnything')}
{(!activeMessage.context || (activeMessage.context !== 'lab' && activeMessage.context !== 'map' && activeMessage.context !== 'home' && activeMessage.context !== 'onboarding')) && t('sasquatchUi.askAnything')}
</button>
) : (
<div className="space-y-2.5">
@ -1752,7 +1753,7 @@ Instructions:
{chatMessages.length === 0 && activeMessage.suggestedQuestions && (
<div className="space-y-1">
<div className="text-[9px] text-[#ccff00]/50 font-bold uppercase tracking-wider mb-1.5">
Quick questions:
{t('sasquatchUi.quickQuestions')}
</div>
{activeMessage.suggestedQuestions.map((q) => (
<button
@ -1782,10 +1783,10 @@ Instructions:
)}
>
<div className="font-bold text-[9px] mb-1 flex items-center gap-1 opacity-60">
{msg.role === 'user' ? '🧑 You' : '🦧 Sasquatch'}
{msg.role === 'user' ? t('sasquatchUi.you') : '🦧 Sasquatch'}
{msg.isLive && (
<span className="text-[#ccff00] bg-[#ccff00]/10 px-1 rounded text-[8px]">
live
{t('sasquatchUi.live')}
</span>
)}
</div>
@ -1814,7 +1815,7 @@ Instructions:
) : (
<span className="flex items-center gap-1.5 text-[#ccff00]/60">
<Loader2 className="h-2.5 w-2.5 animate-spin" />
<span className="text-[10px]">thinking...</span>
<span className="text-[10px]">{t('sasquatchUi.thinking')}</span>
</span>
)}
</div>
@ -1829,7 +1830,7 @@ Instructions:
value={userInput}
onChange={(e) => setUserInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !inputDisabled && handleSendMessage()}
placeholder="Ask about spots, events, coaches..."
placeholder={t('sasquatchUi.inputPlaceholder')}
disabled={inputDisabled}
className="h-10 text-sm bg-black/40 border-[#ccff00]/20 text-white placeholder:text-gray-600 focus-visible:ring-[#ccff00]/40 focus-visible:border-[#ccff00]/40"
/>
@ -1850,14 +1851,14 @@ Instructions:
<div className="flex items-center justify-between">
<p className="text-[9px] text-gray-600 flex items-center gap-1.5 flex-wrap min-w-0">
<CVMStatus connected={cvmConnected} />
Live data from{' '}
{t('sasquatchUi.liveDataFrom')}{' '}
<a
href="https://goskatespots.nostr1.com"
target="_blank"
rel="noopener noreferrer"
className="text-[#ccff00]/60 hover:text-[#ccff00] underline underline-offset-1 transition-colors"
>
Nostr relays
{t('sasquatchUi.nostrRelays')}
</a>
</p>
{chatMessages.length > 0 && (
@ -1865,7 +1866,7 @@ Instructions:
onClick={() => { setChatMessages([]); setShowChat(false); setCvmConnected(null); }}
className="text-[9px] text-gray-600 hover:text-[#ccff00] transition-colors flex-shrink-0"
>
clear
{t('sasquatchUi.clear')}
</button>
)}
</div>

View file

@ -23,6 +23,7 @@ import { usePurchasePro } from '@/hooks/usePurchasePro';
import { useExchangeRate } from '@/hooks/useExchangeRate';
import { useToast } from '@/hooks/useToast';
import { PRO_TIERS, PRO_LIGHTNING_ADDRESS, type ProTier } from '@/lib/sasquatchPro';
import { useTranslation } from '@/contexts/language';
import {
Zap,
Check,
@ -34,11 +35,11 @@ import {
BadgeCheck,
} from 'lucide-react';
const PERKS: Array<{ icon: React.ComponentType<{ className?: string }>; text: string }> = [
{ icon: Brain, text: 'Personal assistant — Sasquatch reads your own Stash, crews, spots & listings' },
{ icon: Wand2, text: 'Action drafts: list gear, post a skate status, plan a trip — you confirm before anything publishes' },
{ icon: Rocket, text: 'Answers use your personal context, not just public data' },
{ icon: BadgeCheck, text: '⚡ Directly support SkateSpots & keep it free and ad-free' },
const PERKS: Array<{ icon: React.ComponentType<{ className?: string }>; textKey: string }> = [
{ icon: Brain, textKey: 'sasquatchUi.perk1' },
{ icon: Wand2, textKey: 'sasquatchUi.perk2' },
{ icon: Rocket, textKey: 'sasquatchUi.perk3' },
{ icon: BadgeCheck, textKey: 'sasquatchUi.perk4' },
];
export function SasquatchProDialog({
@ -49,6 +50,7 @@ export function SasquatchProDialog({
onOpenChange: (v: boolean) => void;
}) {
const { user } = useCurrentUser();
const { t } = useTranslation();
const { isPro, expiresAt } = useSasquatchPro();
const { toast } = useToast();
const purchase = usePurchasePro();
@ -74,16 +76,16 @@ export function SasquatchProDialog({
// Toast + close on success.
useEffect(() => {
if (purchase.state === 'paid') {
toast({ title: 'Welcome to Sasquatch Pro! 🦧⚡', description: 'Your pass is active — a receipt just landed in your DMs. Thanks for keeping the streets free.' });
const t = setTimeout(() => { onOpenChange(false); purchase.reset(); }, 1400);
return () => clearTimeout(t);
toast({ title: t('sasquatchUi.welcomeTitle'), description: t('sasquatchUi.welcomeDesc') });
const timer = setTimeout(() => { onOpenChange(false); purchase.reset(); }, 1400);
return () => clearTimeout(timer);
}
}, [purchase.state, toast, onOpenChange, purchase]);
}, [purchase.state, toast, onOpenChange, purchase, t]);
const copyInvoice = () => {
if (purchase.invoice) {
navigator.clipboard.writeText(purchase.invoice);
toast({ title: 'Invoice copied' });
toast({ title: t('sasquatchUi.invoiceCopied') });
}
};
@ -98,7 +100,7 @@ export function SasquatchProDialog({
<span className="text-2xl">🦧</span> Sasquatch <span className="text-red-500">Pro</span>
</DialogTitle>
<DialogDescription className="text-gray-400">
Supercharge your AI skate guide. 100% funds a free, no-ads community app.
{t('sasquatchUi.dialogDesc')}
</DialogDescription>
</DialogHeader>
@ -107,23 +109,23 @@ export function SasquatchProDialog({
<div className="space-y-4 py-2">
<div className="rounded-2xl border border-[#ccff00]/30 bg-[#ccff00]/10 p-4 text-center">
<BadgeCheck className="mx-auto mb-2 h-8 w-8 text-[#ccff00]" />
<p className="font-black text-[#ccff00]">You're a Pro supporter ⚡</p>
<p className="font-black text-[#ccff00]">{t('sasquatchUi.proSupporter')}</p>
{expiresAt && (
<p className="mt-1 text-xs text-gray-300">
Active until {new Date(expiresAt * 1000).toLocaleDateString()}
{t('sasquatchUi.activeUntil', { date: new Date(expiresAt * 1000).toLocaleDateString() })}
</p>
)}
</div>
<div className="space-y-2">
{PERKS.map((p) => (
<div key={p.text} className="flex items-start gap-2 text-sm text-gray-300">
<div key={p.textKey} className="flex items-start gap-2 text-sm text-gray-300">
<Check className="mt-0.5 h-4 w-4 shrink-0 text-[#ccff00]" />
{p.text}
{t(p.textKey)}
</div>
))}
</div>
<p className="text-center text-xs text-gray-500">
Want to extend? Pick a tier below to add more time.
{t('sasquatchUi.extendPrompt')}
</p>
<TierPicker selected={selected} onSelect={setSelected} />
<PayButton tier={selected} busy={busy} onPay={() => purchase.purchase(selected)} extend />
@ -132,26 +134,28 @@ export function SasquatchProDialog({
// ── Manual QR payment ───────────────────────────────────────────
<div className="space-y-4 py-2 text-center">
<p className="text-sm text-gray-300">
Scan to pay <span className="font-black text-white">{selected.sats.toLocaleString()} sats</span> for {selected.label} of Pro
{t('sasquatchUi.scanToPayPre')}{' '}
<span className="font-black text-white">{t('sasquatchUi.scanToPayAmount', { sats: selected.sats.toLocaleString() })}</span>{' '}
{t('sasquatchUi.scanToPayPost', { label: selected.label })}
</p>
{qr && <img src={qr} alt="Lightning invoice QR" className="mx-auto rounded-xl bg-white p-2" width={240} height={240} />}
{qr && <img src={qr} alt={t('sasquatchUi.invoiceQrAlt')} className="mx-auto rounded-xl bg-white p-2" width={240} height={240} />}
<div className="flex gap-2">
<Button variant="outline" className="flex-1 gap-2 border-white/15" onClick={copyInvoice}>
<Copy className="h-4 w-4" /> Copy invoice
<Copy className="h-4 w-4" /> {t('sasquatchUi.copyInvoice')}
</Button>
<Button
className="flex-1 gap-2 bg-[#ccff00] text-black hover:bg-[#d4ff1a] font-bold"
onClick={() => purchase.confirmManual(selected)}
>
<Check className="h-4 w-4" /> I've paid
<Check className="h-4 w-4" /> {t('sasquatchUi.ivePaid')}
</Button>
</div>
<p className="text-[11px] text-gray-400">
Paying to{' '}
{t('sasquatchUi.paysTo')}{' '}
<span className="font-mono text-gray-200">⚡ {PRO_LIGHTNING_ADDRESS}</span>
</p>
<p className="text-[11px] text-gray-500">
Tap "I've paid" after your wallet confirms — this records your pass to your Nostr identity.
{t('sasquatchUi.ivePaidHint')}
</p>
</div>
) : (
@ -159,22 +163,21 @@ export function SasquatchProDialog({
<div className="space-y-4 py-2">
<div className="space-y-2">
{PERKS.map((p) => (
<div key={p.text} className="flex items-start gap-2.5 text-sm text-gray-200">
<div key={p.textKey} className="flex items-start gap-2.5 text-sm text-gray-200">
<p.icon className="mt-0.5 h-4 w-4 shrink-0 text-red-400" />
{p.text}
{t(p.textKey)}
</div>
))}
</div>
<div className="rounded-xl border border-white/10 bg-white/[0.03] p-3 text-xs text-gray-400">
Free Sasquatch stays free — public spot/event/coach search, trip planning
+ the full trick glossary. Pro adds the <span className="text-white font-semibold">personal + action</span> layer.
{t('sasquatchUi.freeStaysPre')}<span className="text-white font-semibold">{t('sasquatchUi.freeStaysEmphasis')}</span>{t('sasquatchUi.freeStaysPost')}
</div>
<TierPicker selected={selected} onSelect={setSelected} />
{!user && (
<p className="text-center text-xs text-amber-400">Log in first to upgrade.</p>
<p className="text-center text-xs text-amber-400">{t('sasquatchUi.loginFirst')}</p>
)}
{purchase.error && (
<p className="text-center text-xs text-red-400">{purchase.error}</p>
@ -183,7 +186,7 @@ export function SasquatchProDialog({
<PayButton tier={selected} busy={busy} onPay={() => purchase.purchase(selected)} disabled={!user} />
<p className="text-center text-[11px] text-gray-500">
Pays to <span className="font-mono text-gray-300">⚡ {PRO_LIGHTNING_ADDRESS}</span> over Lightning. Bound to your npub.
{t('sasquatchUi.paysTo')} <span className="font-mono text-gray-300">⚡ {PRO_LIGHTNING_ADDRESS}</span> {t('sasquatchUi.overLightning')}
</p>
</div>
)}
@ -195,15 +198,16 @@ export function SasquatchProDialog({
function TierPicker({ selected, onSelect }: { selected: ProTier; onSelect: (t: ProTier) => void }) {
// Live BTC/USD so the fiat hint never goes stale (it used to be hardcoded).
const { data: btcUsd } = useExchangeRate();
const { t } = useTranslation();
return (
<div className="grid grid-cols-2 gap-2">
{PRO_TIERS.map((t) => {
const active = t.id === selected.id;
const usd = btcUsd ? `~$${((t.sats / 1e8) * btcUsd).toFixed(2)}` : '';
{PRO_TIERS.map((tier) => {
const active = tier.id === selected.id;
const usd = btcUsd ? `~$${((tier.sats / 1e8) * btcUsd).toFixed(2)}` : '';
return (
<button
key={t.id}
onClick={() => onSelect(t)}
key={tier.id}
onClick={() => onSelect(tier)}
className={cn(
'relative rounded-xl border p-3 text-left transition-all',
active
@ -211,14 +215,14 @@ function TierPicker({ selected, onSelect }: { selected: ProTier; onSelect: (t: P
: 'border-white/10 bg-white/[0.02] hover:border-white/25',
)}
>
{t.popular && (
{tier.popular && (
<span className="absolute -top-2 right-2 rounded-full bg-red-500 px-1.5 py-0.5 text-[8px] font-black uppercase text-white">
Popular
{t('sasquatchUi.popular')}
</span>
)}
<p className="text-sm font-black text-white">{t.label}</p>
<p className="text-lg font-black text-red-400">{t.sats.toLocaleString()}<span className="ml-0.5 text-xs text-gray-500">sats</span></p>
<p className="text-[10px] text-gray-500">{[usd, t.badge].filter(Boolean).join(' · ')}</p>
<p className="text-sm font-black text-white">{tier.label}</p>
<p className="text-lg font-black text-red-400">{tier.sats.toLocaleString()}<span className="ml-0.5 text-xs text-gray-500">{t('sasquatchUi.sats')}</span></p>
<p className="text-[10px] text-gray-500">{[usd, tier.badge].filter(Boolean).join(' · ')}</p>
</button>
);
})}
@ -239,6 +243,7 @@ function PayButton({
extend?: boolean;
onPay: () => void;
}) {
const { t } = useTranslation();
return (
<Button
className="w-full gap-2 bg-red-500 hover:bg-red-600 font-black uppercase tracking-wide h-12"
@ -246,7 +251,7 @@ function PayButton({
disabled={busy || disabled}
>
{busy ? <Loader2 className="h-5 w-5 animate-spin" /> : <Zap className="h-5 w-5" />}
{extend ? 'Extend' : 'Go Pro'} · {tier.sats.toLocaleString()} sats
{extend ? t('sasquatchUi.extend') : t('sasquatchUi.goPro')} · {tier.sats.toLocaleString()} {t('sasquatchUi.sats')}
</Button>
);
}

View file

@ -1,5 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from '@/contexts/language';
/**
* Branded cold-start splash — a choreographed, premium launch moment.
*
@ -27,14 +29,15 @@ 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_LINES = [
'Waking the network',
'Tuning the relays',
'Pulling in spots',
'Dropping in',
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';
@ -58,7 +61,7 @@ export function SplashScreen() {
useEffect(() => {
if (!visible || ready) return;
const id = setInterval(() => {
setStatusIndex((i) => (i + 1) % STATUS_LINES.length);
setStatusIndex((i) => (i + 1) % STATUS_KEYS.length);
}, 640);
return () => clearInterval(id);
}, [visible, ready]);
@ -149,7 +152,7 @@ export function SplashScreen() {
}}
role="status"
aria-live="polite"
aria-label={ready ? 'SkateSpots ready' : 'Loading SkateSpots'}
aria-label={ready ? t('bootUi.ariaReady') : t('bootUi.ariaLoading')}
>
{/* Fine grid texture — subtle depth, like a skate map underlay */}
<div
@ -268,7 +271,7 @@ export function SplashScreen() {
strokeLinejoin="round"
/>
</svg>
Ready
{t('bootUi.ready')}
</span>
) : (
<span
@ -276,7 +279,7 @@ export function SplashScreen() {
className="font-mono text-[11px] uppercase tracking-[0.28em] text-gray-500"
style={anim('splashFade 360ms ease-out')}
>
{STATUS_LINES[statusIndex]}
{t(STATUS_KEYS[statusIndex])}
{!prefersReducedMotion && <span className="ml-0.5 animate-pulse">…</span>}
</span>
)}

View file

@ -15,6 +15,7 @@ import {
} from '@/lib/routing';
import { transitLegColor, transitModeEmoji } from '@/lib/transitUi';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
interface StopDeparturesProps {
open: boolean;
@ -29,6 +30,7 @@ interface StopDeparturesProps {
* schedule data, never invented, and says so when nothing comes back.
*/
export function StopDepartures({ open, onOpenChange, stopId, stopName }: StopDeparturesProps) {
const { t } = useTranslation();
const [rows, setRows] = useState<StopDeparture[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [reloadKey, setReloadKey] = useState(0);
@ -47,7 +49,7 @@ export function StopDepartures({ open, onOpenChange, stopId, stopName }: StopDep
} catch (err) {
if (cancelled) return;
if (err instanceof DOMException && err.name === 'AbortError') return;
setError("Couldn't reach the transit service. Check your connection and try again.");
setError(t('routeUi.errTransitUnreachable'));
}
})();
@ -55,7 +57,7 @@ export function StopDepartures({ open, onOpenChange, stopId, stopName }: StopDep
cancelled = true;
controller.abort();
};
}, [open, stopId, reloadKey]);
}, [open, stopId, reloadKey, t]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@ -63,12 +65,12 @@ export function StopDepartures({ open, onOpenChange, stopId, stopName }: StopDep
<DialogHeader className="border-b px-4 py-3">
<DialogTitle className="text-base">
<span className="block text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
Departures
{t('routeUi.departures')}
</span>
<span className="block truncate">{stopName}</span>
</DialogTitle>
<DialogDescription className="sr-only">
Upcoming departures from {stopName}
{t('routeUi.upcomingFrom', { stop: stopName })}
</DialogDescription>
</DialogHeader>
@ -77,11 +79,11 @@ export function StopDepartures({ open, onOpenChange, stopId, stopName }: StopDep
<div className="p-4 text-sm text-amber-500">{error}</div>
) : !rows ? (
<div className="flex items-center justify-center gap-2 p-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Loading departures…
<Loader2 className="h-4 w-4 animate-spin" /> {t('routeUi.loadingDepartures')}
</div>
) : rows.length === 0 ? (
<div className="p-6 text-center text-sm text-muted-foreground">
No upcoming departures found at this stop.
{t('routeUi.noDepartures')}
</div>
) : (
<ul className="divide-y">
@ -112,12 +114,12 @@ export function StopDepartures({ open, onOpenChange, stopId, stopName }: StopDep
</span>
{d.realTime && !d.cancelled && (
<span className="shrink-0 rounded bg-[#00ff88]/15 px-1 text-[9px] font-black uppercase text-[#00ff88]">
live
{t('routeUi.live')}
</span>
)}
{d.cancelled && (
<span className="shrink-0 rounded bg-red-500/15 px-1 text-[9px] font-black uppercase text-red-400">
cancelled
{t('routeUi.cancelled')}
</span>
)}
</li>
@ -133,7 +135,7 @@ export function StopDepartures({ open, onOpenChange, stopId, stopName }: StopDep
onClick={() => setReloadKey((k) => k + 1)}
className="flex min-h-0 items-center gap-1 rounded-full border border-white/10 px-2 py-1 text-[10px] font-bold uppercase tracking-wide text-muted-foreground transition-colors hover:text-foreground"
>
<RefreshCw className="h-3 w-3" /> Refresh
<RefreshCw className="h-3 w-3" /> {t('routeUi.refresh')}
</button>
</div>
</DialogContent>

View file

@ -15,6 +15,7 @@ import { travelLinks } from '@/lib/tripPlanner';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useCreateSpotCollection } from '@/hooks/useSpotCollections';
import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils';
const KIND_ICON = {
@ -23,10 +24,18 @@ const KIND_ICON = {
coach: GraduationCap,
} as const;
/** Travel-link labels live in `lib/tripPlanner`; translate at render. */
const TRAVEL_LINK_KEYS: Record<string, string> = {
Flights: 'routeUi.flights',
Stays: 'routeUi.stays',
'Getting around': 'routeUi.gettingAround',
};
export function TripItineraryCard({ itinerary }: { itinerary: Itinerary }) {
const { user } = useCurrentUser();
const { createCollection, isPending } = useCreateSpotCollection();
const { toast } = useToast();
const { t } = useTranslation();
const [saved, setSaved] = useState(false);
const w = itinerary.weather;
@ -34,21 +43,27 @@ export function TripItineraryCard({ itinerary }: { itinerary: Itinerary }) {
const handleSave = async () => {
if (!user) {
toast({ title: 'Log in to save', description: 'Sign in to keep this trip as a Collection.' });
toast({ title: t('routeUi.tripLoginTitle'), description: t('routeUi.tripLoginDesc') });
return;
}
try {
const dayCount = itinerary.days.length;
await createCollection({
title: `${itinerary.destination} skate trip 🛹`,
description: `Trip plan by The Sasquatch — ${itinerary.days.length} days in ${itinerary.destination}.`,
title: t('routeUi.tripCollectionTitle', { destination: itinerary.destination }),
description: dayCount === 1
? t('routeUi.tripCollectionDescOne', { count: dayCount, destination: itinerary.destination })
: t('routeUi.tripCollectionDesc', { count: dayCount, destination: itinerary.destination }),
spotRefs: itinerary.allSpotRefs,
});
setSaved(true);
toast({ title: 'Saved to Collections 🦧', description: `Your ${itinerary.destination} trip is bookmarked.` });
toast({
title: t('routeUi.tripSavedTitle'),
description: t('routeUi.tripSavedDesc', { destination: itinerary.destination }),
});
} catch (err) {
toast({
title: "Couldn't save",
description: err instanceof Error ? err.message : 'Try again in a sec.',
title: t('routeUi.tripSaveFailed'),
description: err instanceof Error ? err.message : t('routeUi.tripSaveFailedDesc'),
variant: 'destructive',
});
}
@ -64,7 +79,9 @@ export function TripItineraryCard({ itinerary }: { itinerary: Itinerary }) {
<div className="flex items-center gap-1.5">
<span className="text-[13px]">🛹</span>
<span className="font-black text-[11px] text-[#ccff00] uppercase tracking-wider truncate">
{itinerary.days.length} days in {itinerary.destination}
{itinerary.days.length === 1
? t('routeUi.daysInDestinationOne', { count: itinerary.days.length, destination: itinerary.destination })
: t('routeUi.daysInDestination', { count: itinerary.days.length, destination: itinerary.destination })}
</span>
</div>
{w && (
@ -82,7 +99,7 @@ export function TripItineraryCard({ itinerary }: { itinerary: Itinerary }) {
{rainy && (
<div className="mt-1 flex items-start gap-1 text-[9px] text-orange-300/90">
<CloudRain className="h-2.5 w-2.5 mt-0.5 flex-shrink-0" />
<span>Wet forecast — hit a local crew for covered/indoor options.</span>
<span>{t('routeUi.wetForecast')}</span>
</div>
)}
</div>
@ -92,10 +109,10 @@ export function TripItineraryCard({ itinerary }: { itinerary: Itinerary }) {
{itinerary.days.map((day) => (
<div key={day.dayNumber} className="px-2.5 py-2">
<div className="text-[9px] font-black text-[#ccff00]/70 uppercase tracking-widest mb-1">
📅 {day.label}
📅 {t('routeUi.dayLabel', { n: day.dayNumber })}
</div>
{day.stops.length === 0 ? (
<div className="text-[9px] text-gray-500 italic">Free skate — explore & film. 🎥</div>
<div className="text-[9px] text-gray-500 italic">{t('routeUi.freeSkate')}</div>
) : (
<div className="space-y-1">
{day.stops.map((stop, i) => {
@ -109,7 +126,7 @@ export function TripItineraryCard({ itinerary }: { itinerary: Itinerary }) {
<div className="text-[8px] text-gray-500 truncate">{stop.subtitle}</div>
)}
{stop.kind === 'coach' && (
<div className="text-[8px] text-[#ccff00]/70">tap to book / zap ⚡</div>
<div className="text-[8px] text-[#ccff00]/70">{t('routeUi.tapToBook')}</div>
)}
</div>
</div>
@ -138,7 +155,7 @@ export function TripItineraryCard({ itinerary }: { itinerary: Itinerary }) {
{itinerary.crews.length > 0 && (
<div className="px-2.5 py-2 border-t border-white/5">
<div className="text-[9px] font-black text-[#ccff00]/70 uppercase tracking-widest mb-1 flex items-center gap-1">
<Users className="h-2.5 w-2.5" /> Local crews
<Users className="h-2.5 w-2.5" /> {t('routeUi.localCrews')}
</div>
<div className="flex flex-wrap gap-1">
{itinerary.crews.map((c, i) => (
@ -170,11 +187,13 @@ export function TripItineraryCard({ itinerary }: { itinerary: Itinerary }) {
)}
>
{isPending ? (
<><Loader2 className="h-3 w-3 animate-spin" /> Saving…</>
<><Loader2 className="h-3 w-3 animate-spin" /> {t('routeUi.saving')}</>
) : saved ? (
<><Check className="h-3 w-3" /> Saved to Collections</>
<><Check className="h-3 w-3" /> {t('routeUi.savedToCollections')}</>
) : (
<><Bookmark className="h-3 w-3" /> Save {itinerary.allSpotRefs.length} spots to a Collection</>
<><Bookmark className="h-3 w-3" /> {itinerary.allSpotRefs.length === 1
? t('routeUi.saveSpotOne', { count: itinerary.allSpotRefs.length })
: t('routeUi.saveSpots', { count: itinerary.allSpotRefs.length })}</>
)}
</button>
)}
@ -190,12 +209,12 @@ export function TripItineraryCard({ itinerary }: { itinerary: Itinerary }) {
className="flex-1 flex items-center justify-center gap-1 rounded-md border border-white/10 py-1 text-[9px] text-gray-300 hover:border-[#ccff00]/40 hover:text-[#ccff00] transition-colors"
>
<span>{link.emoji}</span>
<span className="truncate">{link.label}</span>
<span className="truncate">{t(TRAVEL_LINK_KEYS[link.label] ?? link.label)}</span>
</a>
))}
</div>
<p className="text-[8px] text-gray-600 flex items-center gap-1 justify-center">
<Plane className="h-2 w-2" /> I plan the skating — flights & stays open on the real sites (no KYC, no middleman).
<Plane className="h-2 w-2" /> {t('routeUi.noMiddleman')}
</p>
</div>
</div>

27
src/i18n/batch3/bootUi.ts Normal file
View file

@ -0,0 +1,27 @@
/**
* Batch 3 — boot / splash translations (`bootUi.*`).
*
* Filled by the boot translation pass; keys must exist in BOTH languages.
*/
const bootUi = {
en: {
ariaLoading: 'Loading SkateSpots',
ariaReady: 'SkateSpots ready',
ready: 'Ready',
statusWaking: 'Waking the network',
statusTuning: 'Tuning the relays',
statusPulling: 'Pulling in spots',
statusDropping: 'Dropping in',
},
es: {
ariaLoading: 'Cargando SkateSpots',
ariaReady: 'SkateSpots listo',
ready: 'Listo',
statusWaking: 'Despertando la red',
statusTuning: 'Ajustando los relays',
statusPulling: 'Cargando spots',
statusDropping: 'Entrando',
},
};
export default bootUi;

View file

@ -0,0 +1,17 @@
/**
* Batch 3 — collections dialog translations (`collectionUi.*`).
*
* Filled by the collections translation pass; keys must exist in BOTH languages.
*/
const collectionUi = {
en: {
namePlaceholder: 'Barcelona Trip 2024',
descriptionPlaceholder: 'My favorite spots from my Barcelona trip...',
},
es: {
namePlaceholder: 'Viaje a Barcelona 2024',
descriptionPlaceholder: 'Mis spots favoritos de mi viaje a Barcelona...',
},
};
export default collectionUi;

20
src/i18n/batch3/index.ts Normal file
View file

@ -0,0 +1,20 @@
import routeUi from './routeUi';
import sasquatchUi from './sasquatchUi';
import collectionUi from './collectionUi';
import bootUi from './bootUi';
/** Namespaces added by the batch-3 translation pass, spread into `en`. */
export const batch3En = {
routeUi: routeUi.en,
sasquatchUi: sasquatchUi.en,
collectionUi: collectionUi.en,
bootUi: bootUi.en,
};
/** Namespaces added by the batch-3 translation pass, spread into `es`. */
export const batch3Es = {
routeUi: routeUi.es,
sasquatchUi: sasquatchUi.es,
collectionUi: collectionUi.es,
bootUi: bootUi.es,
};

297
src/i18n/batch3/routeUi.ts Normal file
View file

@ -0,0 +1,297 @@
/**
* Batch 3 — routing UI translations (`routeUi.*`).
*
* Filled by the routing-UI translation pass; keys must exist in BOTH languages.
*/
const routeUi = {
en: {
// Header / search
destination: 'Destination',
routeTo: 'Route to {{label}}',
routeToLabel: 'Route to',
search: 'Search',
description: 'Route options and in-app directions.',
routeMapAria: 'Route map',
// Loading / refining
findingLocation: 'Finding your location…',
plottingRoute: 'Plotting your route…',
refiningLocation: 'Refining location…',
offlineSavedRoute: 'Offline · saved route {{age}}',
// Map hints / controls
tapMapStart: 'Tap the map to set your start point',
fitRouteAria: 'Fit the whole route on the map',
wholeRoute: 'Whole route',
// Follow navigation banner
next: 'Next',
nextStep: 'Next · step {{n}}',
followTheLine: 'Follow the line',
toTheTurn: 'to the turn',
toGo: 'to go',
tryAgain: 'Try again',
// Travel mode toggle
mode: {
foot: 'Skate / Walk',
bike: 'Bike',
moto: 'Moto',
car: 'Car',
transit: 'Transit',
},
// Start point
from: 'From',
myLocation: 'My location',
tapTheMap: 'Tap the map…',
chooseOnMap: 'Choose on map',
// Route summary
direct: 'direct',
transferOne: '1 transfer',
transfers: '{{count}} transfers',
options: '{{count}} options',
ride: 'ride',
arrive: 'arrive',
speedAvg: '{{speed}} km/h avg',
stepsCount: '{{count}} steps',
onABoard: 'on a board',
usesDrivingRoads: 'uses driving roads',
drivingEstimate: 'driving estimate',
updating: 'updating…',
pickMode: 'Pick a mode to see your route.',
waitingLocation: 'Waiting for your location…',
// Turn-by-turn planning list
routeSteps: 'Route steps',
walkingRoute: 'walking route — quicker on a board',
planningView: 'Planning view — tap Start to follow with live turns, voice and arrival.',
// Transit controls
leaveNow: 'Leave now',
departAt: 'Depart at',
arriveBy: 'Arrive by',
departureTime: 'Departure time',
arrivalTime: 'Arrival time',
only: 'Only',
clear: 'clear',
itinerary: 'Itinerary',
group: {
bus: 'Bus',
tram: 'Tram',
metro: 'Metro',
train: 'Train',
ferry: 'Ferry',
},
// Itinerary legs
walk: 'Walk',
rideVerb: 'Ride',
walkLower: 'walk',
rideLower: 'ride',
toStop: 'to',
track: 'track {{track}}',
stopOne: '1 stop',
stopsCount: '{{count}} stops',
tickets: 'tickets ↗',
// Follow bar
done: 'Done',
stop: 'Stop',
start: 'Start',
arrived: "You've arrived. 🛹",
voiceOnNote: 'Screen stays on · voice on — turns and arrival are spoken.',
voiceOffNote: 'Screen stays on · voice off.',
followNote: 'Turns, voice and arrival as you roll. Keeps the screen on.',
voiceOffAria: 'Turn voice guidance off',
voiceOnAria: 'Turn voice guidance on',
// Spoken guidance
voiceStart: 'Starting navigation',
voiceArrived: "You've arrived",
voiceIn: 'In {{metres}} metres, {{instruction}}',
voiceContinue: 'continue',
voiceContinueCaps: 'Continue',
// Errors
errPermissionDenied: 'Location permission denied.',
errWaitingGps: 'Waiting for a GPS fix…',
errPermissionDeniedLong: 'Location permission denied. Enable location to route from where you are.',
errNoLocation: 'Could not get your location. Try again.',
errNoLocationShort: 'Could not get your location.',
errTransitNoRouteFiltered: 'No route for that time or transport mode — try another time, or include more modes.',
errTransitNoRoute: 'No public-transport route found here — this area may not publish open timetables.',
errTransitUnreachable: "Couldn't reach the transit service. Check your connection and try again.",
errRoutingBusy: 'The routing service is busy right now — try again in a moment.',
errNoRouteMode: 'No route for this mode — the destination may be off the road network. Try another mode.',
errRoutingUnreachable: "Couldn't reach the routing service. Check your connection and try again.",
// Departure board
departures: 'Departures',
upcomingFrom: 'Upcoming departures from {{stop}}',
loadingDepartures: 'Loading departures…',
noDepartures: 'No upcoming departures found at this stop.',
live: 'live',
cancelled: 'cancelled',
refresh: 'Refresh',
// Trip itinerary card
tripLoginTitle: 'Log in to save',
tripLoginDesc: 'Sign in to keep this trip as a Collection.',
tripCollectionTitle: '{{destination}} skate trip 🛹',
tripCollectionDesc: 'Trip plan by The Sasquatch — {{count}} days in {{destination}}.',
tripCollectionDescOne: 'Trip plan by The Sasquatch — {{count}} day in {{destination}}.',
tripSavedTitle: 'Saved to Collections 🦧',
tripSavedDesc: 'Your {{destination}} trip is bookmarked.',
tripSaveFailed: "Couldn't save",
tripSaveFailedDesc: 'Try again in a sec.',
daysInDestinationOne: '{{count}} day in {{destination}}',
daysInDestination: '{{count}} days in {{destination}}',
wetForecast: 'Wet forecast — hit a local crew for covered/indoor options.',
dayLabel: 'Day {{n}}',
freeSkate: 'Free skate — explore & film. 🎥',
tapToBook: 'tap to book / zap ⚡',
localCrews: 'Local crews',
saving: 'Saving…',
savedToCollections: 'Saved to Collections',
saveSpotOne: 'Save {{count}} spot to a Collection',
saveSpots: 'Save {{count}} spots to a Collection',
flights: 'Flights',
stays: 'Stays',
gettingAround: 'Getting around',
noMiddleman: 'I plan the skating — flights & stays open on the real sites (no KYC, no middleman).',
},
es: {
// Header / search
destination: 'Destino',
routeTo: 'Ruta a {{label}}',
routeToLabel: 'Ruta a',
search: 'Buscar',
description: 'Opciones de ruta e indicaciones en la app.',
routeMapAria: 'Mapa de ruta',
// Loading / refining
findingLocation: 'Buscando tu ubicación…',
plottingRoute: 'Trazando tu ruta…',
refiningLocation: 'Afinando la ubicación…',
offlineSavedRoute: 'Sin conexión · ruta guardada {{age}}',
// Map hints / controls
tapMapStart: 'Toca el mapa para fijar tu punto de inicio',
fitRouteAria: 'Encajar toda la ruta en el mapa',
wholeRoute: 'Ruta completa',
// Follow navigation banner
next: 'Siguiente',
nextStep: 'Siguiente · paso {{n}}',
followTheLine: 'Sigue la línea',
toTheTurn: 'hasta el giro',
toGo: 'restantes',
tryAgain: 'Reintentar',
// Travel mode toggle
mode: {
foot: 'Skate / Caminar',
bike: 'Bici',
moto: 'Moto',
car: 'Coche',
transit: 'Transporte público',
},
// Start point
from: 'Desde',
myLocation: 'Mi ubicación',
tapTheMap: 'Toca el mapa…',
chooseOnMap: 'Elegir en el mapa',
// Route summary
direct: 'directo',
transferOne: '1 transbordo',
transfers: '{{count}} transbordos',
options: '{{count}} opciones',
ride: 'recorrido',
arrive: 'llegada',
speedAvg: '{{speed}} km/h media',
stepsCount: '{{count}} pasos',
onABoard: 'en tabla',
usesDrivingRoads: 'usa vías de coche',
drivingEstimate: 'estimación en coche',
updating: 'actualizando…',
pickMode: 'Elige un modo para ver tu ruta.',
waitingLocation: 'Esperando tu ubicación…',
// Turn-by-turn planning list
routeSteps: 'Pasos de la ruta',
walkingRoute: 'ruta a pie — más rápida en tabla',
planningView: 'Vista de planificación — toca Empezar para seguir con giros en vivo, voz y llegada.',
// Transit controls
leaveNow: 'Salir ahora',
departAt: 'Salir a las',
arriveBy: 'Llegar antes de',
departureTime: 'Hora de salida',
arrivalTime: 'Hora de llegada',
only: 'Solo',
clear: 'borrar',
itinerary: 'Itinerario',
group: {
bus: 'Autobús',
tram: 'Tranvía',
metro: 'Metro',
train: 'Tren',
ferry: 'Ferry',
},
// Itinerary legs
walk: 'Andar',
rideVerb: 'En bici',
walkLower: 'andar',
rideLower: 'en bici',
toStop: 'hasta',
track: 'vía {{track}}',
stopOne: '1 parada',
stopsCount: '{{count}} paradas',
tickets: 'billetes ↗',
// Follow bar
done: 'Hecho',
stop: 'Parar',
start: 'Empezar',
arrived: 'Has llegado. 🛹',
voiceOnNote: 'La pantalla no se apaga · voz activada — los giros y la llegada se anuncian.',
voiceOffNote: 'La pantalla no se apaga · voz desactivada.',
followNote: 'Giros, voz y llegada mientras patinas. Mantiene la pantalla encendida.',
voiceOffAria: 'Desactivar la guía por voz',
voiceOnAria: 'Activar la guía por voz',
// Spoken guidance
voiceStart: 'Empezando la navegación',
voiceArrived: 'Has llegado',
voiceIn: 'En {{metres}} metros, {{instruction}}',
voiceContinue: 'continúa',
voiceContinueCaps: 'Continúa',
// Errors
errPermissionDenied: 'Permiso de ubicación denegado.',
errWaitingGps: 'Esperando señal GPS…',
errPermissionDeniedLong: 'Permiso de ubicación denegado. Activa la ubicación para trazar la ruta desde donde estás.',
errNoLocation: 'No se pudo obtener tu ubicación. Inténtalo de nuevo.',
errNoLocationShort: 'No se pudo obtener tu ubicación.',
errTransitNoRouteFiltered: 'No hay ruta para esa hora o modo de transporte — prueba otra hora o incluye más modos.',
errTransitNoRoute: 'No se encontró ruta de transporte público aquí — puede que esta zona no publique horarios abiertos.',
errTransitUnreachable: 'No se pudo contactar con el servicio de transporte. Comprueba tu conexión e inténtalo de nuevo.',
errRoutingBusy: 'El servicio de rutas está saturado ahora mismo — inténtalo de nuevo en un momento.',
errNoRouteMode: 'No hay ruta para este modo — puede que el destino esté fuera de la red de carreteras. Prueba otro modo.',
errRoutingUnreachable: 'No se pudo contactar con el servicio de rutas. Comprueba tu conexión e inténtalo de nuevo.',
// Departure board
departures: 'Salidas',
upcomingFrom: 'Próximas salidas desde {{stop}}',
loadingDepartures: 'Cargando salidas…',
noDepartures: 'No se encontraron próximas salidas en esta parada.',
live: 'en vivo',
cancelled: 'cancelado',
refresh: 'Actualizar',
// Trip itinerary card
tripLoginTitle: 'Inicia sesión para guardar',
tripLoginDesc: 'Inicia sesión para guardar este viaje como Colección.',
tripCollectionTitle: 'Viaje de skate a {{destination}} 🛹',
tripCollectionDesc: 'Plan de viaje de The Sasquatch — {{count}} días en {{destination}}.',
tripCollectionDescOne: 'Plan de viaje de The Sasquatch — {{count}} día en {{destination}}.',
tripSavedTitle: 'Guardado en Colecciones 🦧',
tripSavedDesc: 'Tu viaje a {{destination}} está guardado.',
tripSaveFailed: 'No se pudo guardar',
tripSaveFailedDesc: 'Inténtalo de nuevo en un momento.',
daysInDestinationOne: '{{count}} día en {{destination}}',
daysInDestination: '{{count}} días en {{destination}}',
wetForecast: 'Pronóstico de lluvia — contacta con una crew local para opciones cubiertas/interiores.',
dayLabel: 'Día {{n}}',
freeSkate: 'Skate libre — explora y graba. 🎥',
tapToBook: 'toca para reservar / zap ⚡',
localCrews: 'Crews locales',
saving: 'Guardando…',
savedToCollections: 'Guardado en Colecciones',
saveSpotOne: 'Guardar {{count}} spot en una Colección',
saveSpots: 'Guardar {{count}} spots en una Colección',
flights: 'Vuelos',
stays: 'Alojamiento',
gettingAround: 'Moverse',
noMiddleman: 'Yo planifico el skate — los vuelos y alojamientos se abren en los sitios reales (sin KYC, sin intermediarios).',
},
};
export default routeUi;

View file

@ -0,0 +1,217 @@
/**
* Batch 3 — Sasquatch AI panel translations (`sasquatchUi.*`).
*
* Filled by the Sasquatch translation pass; keys must exist in BOTH languages.
*
* NOT translated here (deliberately): the LLM system prompt / language
* directive, the static FAQ answer bodies and the locally-formatted chat
* responses — those are AI/conversational content, not UI chrome.
*/
const sasquatchUi = {
en: {
// ── Connection status ──
live: 'live',
offline: 'offline',
// ── Panel default content ──
genericTitle: 'SkateSpots Assistant 🦧',
genericGreeting:
"Yo! I'm The Sasquatch. Ask me anything about spots, events, coaches, gear, or how the app works. I've got live data from the SkateSpots relay!",
q1: 'Plan me 3 days in Lisbon',
q2: 'Find spots near me',
q3: 'Any upcoming events?',
q4: 'What are crews?',
q5: 'How do zaps work?',
q6: 'What is Nostr?',
q7: 'What is My Stash?',
q8: 'Is this free?',
// ── Bubble + header ──
bubbleTitle: 'Press and drag to move · Tap to open',
openGuideAria: 'Open Sasquatch guide',
activeUntil: 'Active until {{date}}',
platformOwner: 'Platform owner',
upgradeTitle: 'Upgrade to Sasquatch Pro',
goPro: 'Go Pro',
aiBalanceTitle: 'Sasquatch AI credit balance (owner-only) — top up at {{provider}}',
aiChip: '⚡ AI {{balance}}',
minimizeToBubbleAria: 'Minimize to bubble',
minimize: 'Minimize',
closePanelAria: 'Close panel',
close: 'Close',
// ── Chat chrome ──
askLab: 'Ask about Coaching & Filming',
askMap: 'Ask about Spots, Shops & Gear',
askAnything: 'Ask me anything 🛹',
quickQuestions: 'Quick questions:',
you: '🧑 You',
thinking: 'thinking...',
inputPlaceholder: 'Ask about spots, events, coaches...',
liveDataFrom: 'Live data from',
nostrRelays: 'Nostr relays',
clear: 'clear',
// ── Loading / streaming states ──
gettingLocation: '📍 Getting your location…',
findingClosest: '🔍 Finding the closest spots…',
checkingLive: '🔍 Checking live data…',
planningTrip: '🗺️ Planning your {{destination}} trip...',
searchingRelays: '🔍 Searching the relays for {{tool}}...',
// ── Sasquatch Pro dialog ──
perk1: 'Personal assistant — Sasquatch reads your own Stash, crews, spots & listings',
perk2: 'Action drafts: list gear, post a skate status, plan a trip — you confirm before anything publishes',
perk3: 'Answers use your personal context, not just public data',
perk4: '⚡ Directly support SkateSpots & keep it free and ad-free',
welcomeTitle: 'Welcome to Sasquatch Pro! 🦧⚡',
welcomeDesc:
'Your pass is active — a receipt just landed in your DMs. Thanks for keeping the streets free.',
invoiceCopied: 'Invoice copied',
dialogDesc: 'Supercharge your AI skate guide. 100% funds a free, no-ads community app.',
proSupporter: "You're a Pro supporter ⚡",
extendPrompt: 'Want to extend? Pick a tier below to add more time.',
scanToPayPre: 'Scan to pay',
scanToPayAmount: '{{sats}} sats',
scanToPayPost: 'for {{label}} of Pro',
invoiceQrAlt: 'Lightning invoice QR',
copyInvoice: 'Copy invoice',
ivePaid: "I've paid",
ivePaidHint:
'Tap "I\'ve paid" after your wallet confirms — this records your pass to your Nostr identity.',
freeStaysPre:
'Free Sasquatch stays free — public spot/event/coach search, trip planning + the full trick glossary. Pro adds the ',
freeStaysEmphasis: 'personal + action',
freeStaysPost: ' layer.',
loginFirst: 'Log in first to upgrade.',
paysTo: 'Pays to',
overLightning: 'over Lightning. Bound to your npub.',
popular: 'Popular',
sats: 'sats',
extend: 'Extend',
// ── Draft action card ──
done: 'Done!',
cancelledNothing: 'Cancelled — nothing was published.',
draftReview: 'Draft — review & confirm',
statusTextPlaceholder: 'Status text',
titlePlaceholder: 'Title',
priceSatsPlaceholder: 'Price (sats)',
conditionPlaceholder: 'Condition',
cancel: 'Cancel',
// ── Owner AI balance card ──
aiTitle: 'Sasquatch AI',
owner: 'Owner',
notConfigured: 'not configured',
unavailable: 'unavailable',
satsValue: '{{balance}} sats',
theNode: 'the node',
lowBalance: 'Running low — top up at {{provider}} or AI will fall back to the keyless provider.',
aiDesc: 'Pay-per-request AI, funded in sats via Routstr. Tops up when low.',
},
es: {
// ── Connection status ──
live: 'en vivo',
offline: 'sin conexión',
// ── Panel default content ──
genericTitle: 'Asistente de SkateSpots 🦧',
genericGreeting:
'¡Hola! Soy el Sasquatch. Pregúntame lo que quieras sobre spots, eventos, entrenadores, material o cómo funciona la app. ¡Tengo datos en vivo del relé de SkateSpots!',
q1: 'Planifícame 3 días en Lisboa',
q2: 'Encuentra spots cerca de mí',
q3: '¿Próximos eventos?',
q4: '¿Qué son los crews?',
q5: '¿Cómo funcionan los zaps?',
q6: '¿Qué es Nostr?',
q7: '¿Qué es Mi Stash?',
q8: '¿Es gratis?',
// ── Bubble + header ──
bubbleTitle: 'Pulsa y arrastra para mover · Toca para abrir',
openGuideAria: 'Abrir la guía del Sasquatch',
activeUntil: 'Activo hasta el {{date}}',
platformOwner: 'Propietario de la plataforma',
upgradeTitle: 'Mejora a Sasquatch Pro',
goPro: 'Hazte Pro',
aiBalanceTitle: 'Saldo de crédito de la IA del Sasquatch (solo propietario) — recarga en {{provider}}',
aiChip: '⚡ IA {{balance}}',
minimizeToBubbleAria: 'Minimizar a la burbuja',
minimize: 'Minimizar',
closePanelAria: 'Cerrar el panel',
close: 'Cerrar',
// ── Chat chrome ──
askLab: 'Pregunta sobre entrenamiento y grabación',
askMap: 'Pregunta sobre spots, tiendas y material',
askAnything: 'Pregúntame lo que quieras 🛹',
quickQuestions: 'Preguntas rápidas:',
you: '🧑 Tú',
thinking: 'pensando...',
inputPlaceholder: 'Pregunta sobre spots, eventos, entrenadores...',
liveDataFrom: 'Datos en vivo de',
nostrRelays: 'relés de Nostr',
clear: 'borrar',
// ── Loading / streaming states ──
gettingLocation: '📍 Obteniendo tu ubicación…',
findingClosest: '🔍 Buscando los spots más cercanos…',
checkingLive: '🔍 Comprobando datos en vivo…',
planningTrip: '🗺️ Planificando tu viaje a {{destination}}...',
searchingRelays: '🔍 Buscando en los relés: {{tool}}...',
// ── Sasquatch Pro dialog ──
perk1: 'Asistente personal — el Sasquatch lee tu propio Stash, crews, spots y anuncios',
perk2: 'Borradores de acciones: publica material, comparte tu estado de skate, planifica un viaje — tú confirmas antes de que se publique nada',
perk3: 'Las respuestas usan tu contexto personal, no solo datos públicos',
perk4: '⚡ Apoya directamente a SkateSpots y ayúdanos a mantenerla gratis y sin anuncios',
welcomeTitle: '¡Bienvenido a Sasquatch Pro! 🦧⚡',
welcomeDesc:
'Tu pase está activo — acaba de llegar un recibo a tus MD. Gracias por mantener las calles libres.',
invoiceCopied: 'Factura copiada',
dialogDesc: 'Potencia tu guía de skate con IA. El 100 % financia una app comunitaria gratis y sin anuncios.',
proSupporter: 'Eres un colaborador Pro ⚡',
extendPrompt: '¿Quieres ampliar? Elige una tarifa abajo para añadir más tiempo.',
scanToPayPre: 'Escanea para pagar',
scanToPayAmount: '{{sats}} sats',
scanToPayPost: 'por {{label}} de Pro',
invoiceQrAlt: 'QR de la factura Lightning',
copyInvoice: 'Copiar factura',
ivePaid: 'He pagado',
ivePaidHint:
'Toca "He pagado" cuando tu cartera lo confirme — esto registra tu pase en tu identidad de Nostr.',
freeStaysPre:
'El Sasquatch gratis sigue siendo gratis — búsqueda pública de spots, eventos y entrenadores, planificación de viajes + el glosario completo de trucos. Pro añade la ',
freeStaysEmphasis: 'capa personal y de acciones',
freeStaysPost: '.',
loginFirst: 'Inicia sesión primero para mejorar.',
paysTo: 'Paga a',
overLightning: 'por Lightning. Vinculado a tu npub.',
popular: 'Popular',
sats: 'sats',
extend: 'Ampliar',
// ── Draft action card ──
done: '¡Hecho!',
cancelledNothing: 'Cancelado — no se publicó nada.',
draftReview: 'Borrador — revisa y confirma',
statusTextPlaceholder: 'Texto del estado',
titlePlaceholder: 'Título',
priceSatsPlaceholder: 'Precio (sats)',
conditionPlaceholder: 'Estado',
cancel: 'Cancelar',
// ── Owner AI balance card ──
aiTitle: 'Sasquatch IA',
owner: 'Propietario',
notConfigured: 'sin configurar',
unavailable: 'no disponible',
satsValue: '{{balance}} sats',
theNode: 'el nodo',
lowBalance: 'Saldo bajo — recarga en {{provider}} o la IA recurrirá al proveedor sin clave.',
aiDesc: 'IA de pago por uso, financiada en sats vía Routstr. Se recarga cuando baja.',
},
};
export default sasquatchUi;

View file

@ -2,6 +2,8 @@
* English translations
* Note: Skateboarding trick names and terminology stay in English
*/
import { batch3En } from './batch3';
const en = {
// Common
common: {
@ -6527,4 +6529,4 @@ walletUi: {
},
};
export default en;
export default { ...en, ...batch3En };

View file

@ -3,6 +3,8 @@
* Note: Skateboarding trick names and terminology stay in English
* (kickflip, ollie, grind, etc. are universal in skate culture)
*/
import { batch3Es } from './batch3';
const es = {
// Common
common: {
@ -6527,4 +6529,4 @@ walletUi: {
},
};
export default es;
export default { ...es, ...batch3Es };