From 3e6354995a2d24c1d737370ea3e8b2cb65b4cc71 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Fri, 2 Oct 2026 15:15:06 +0700 Subject: [PATCH] =?UTF-8?q?i18n(es):=20batch=203=20part=201=20=E2=80=94=20?= =?UTF-8?q?routing=20UI,=20Sasquatch=20panel,=20collections,=20splash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- src/components/AddToCollectionDialog.tsx | 4 +- src/components/RouteDialog.tsx | 206 +++++++++------- src/components/SasquatchActionCard.tsx | 18 +- src/components/SasquatchAiStatusCard.tsx | 16 +- src/components/SasquatchGuide.tsx | 91 +++---- src/components/SasquatchProDialog.tsx | 83 ++++--- src/components/SplashScreen.tsx | 21 +- src/components/StopDepartures.tsx | 20 +- src/components/TripItineraryCard.tsx | 53 ++-- src/i18n/batch3/bootUi.ts | 27 +++ src/i18n/batch3/collectionUi.ts | 17 ++ src/i18n/batch3/index.ts | 20 ++ src/i18n/batch3/routeUi.ts | 297 +++++++++++++++++++++++ src/i18n/batch3/sasquatchUi.ts | 217 +++++++++++++++++ src/i18n/en.ts | 4 +- src/i18n/es.ts | 4 +- 16 files changed, 872 insertions(+), 226 deletions(-) create mode 100644 src/i18n/batch3/bootUi.ts create mode 100644 src/i18n/batch3/collectionUi.ts create mode 100644 src/i18n/batch3/index.ts create mode 100644 src/i18n/batch3/routeUi.ts create mode 100644 src/i18n/batch3/sasquatchUi.ts diff --git a/src/components/AddToCollectionDialog.tsx b/src/components/AddToCollectionDialog.tsx index aec4644..36229fe 100644 --- a/src/components/AddToCollectionDialog.tsx +++ b/src/components/AddToCollectionDialog.tsx @@ -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} /> diff --git a/src/components/RouteDialog.tsx b/src/components/RouteDialog.tsx index cc02ecf..b19af07 100644 --- a/src/components/RouteDialog.tsx +++ b/src/components/RouteDialog.tsx @@ -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 = { + 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 = { + 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(''); // 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 ( <> @@ -763,7 +789,7 @@ export function RouteDialog({ scroll with the Start/Stop bar pinned at the bottom. */} - Route to {destLabel} + {t('routeUi.routeTo', { label: destLabelText })} {/* 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. */} - Route options and in-app directions. + {t('routeUi.description')} @@ -796,7 +822,7 @@ export function RouteDialog({
{/* Destination search — overlays the map while open. Mounted only @@ -823,7 +849,7 @@ export function RouteDialog({
- {locating && !origin ? 'Finding your location…' : 'Plotting your route…'} + {locating && !origin ? t('routeUi.findingLocation') : t('routeUi.plottingRoute')}
)} @@ -834,13 +860,13 @@ export function RouteDialog({
{cachedAge !== null && (
- Offline · saved route {formatAge(cachedAge)} + {t('routeUi.offlineSavedRoute', { age: formatAge(cachedAge) })}
)} {locating && origin && (
- Refining location… + {t('routeUi.refiningLocation')}
)}
@@ -849,7 +875,7 @@ export function RouteDialog({ {/* "Choose on map" hint */} {pickingStart && (
- Tap the map to set your start point + {t('routeUi.tapMapStart')}
)} @@ -857,18 +883,18 @@ export function RouteDialog({ {following && nav && (

- Next{nav.stepIndex >= 0 ? ` · step ${nav.stepIndex + 1}` : ''} + {nav.stepIndex >= 0 ? t('routeUi.nextStep', { n: nav.stepIndex + 1 }) : t('routeUi.next')}

{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')}

{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')}

)} @@ -886,7 +912,7 @@ export function RouteDialog({

{locError}

@@ -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')} > - Whole route + {t('routeUi.wholeRoute')} )} @@ -922,7 +948,7 @@ export function RouteDialog({ aria-pressed={mode === m.mode} > {m.emoji} - {m.label} + {t(MODE_LABEL_KEYS[m.mode])} ))} @@ -930,7 +956,7 @@ export function RouteDialog({ {/* Start point — default is where you are; you can pick another on the map */}
- From + {t('routeUi.from')}
@@ -983,9 +1009,11 @@ export function RouteDialog({
{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 && }
@@ -1021,13 +1049,13 @@ export function RouteDialog({ {formatDuration(route.durationS)} - ride + {t('routeUi.ride')} - arrive + {t('routeUi.arrive')} {etaClock(route.durationS)} @@ -1040,24 +1068,24 @@ export function RouteDialog({ {route.steps && route.steps.length > 1 - ? `${route.steps.length} steps` - : 'direct'} + ? t('routeUi.stepsCount', { count: route.steps.length }) + : t('routeUi.direct')} {mode === 'foot' - ? · {skateTime(route.distanceM / 1000)} on a board - : speedLabel(route.distanceM, route.durationS) && ( - · {speedLabel(route.distanceM, route.durationS)} + ? · {skateTime(route.distanceM / 1000)} {t('routeUi.onABoard')} + : speed && ( + · {t('routeUi.speedAvg', { speed })} )} {TRAVEL_MODES.find((m) => m.mode === mode)?.approximate && ( - · uses driving roads + · {t('routeUi.usesDrivingRoads')} )} - {route.approximate && · driving estimate} - {routing && · updating…} + {route.approximate && · {t('routeUi.drivingEstimate')}} + {routing && · {t('routeUi.updating')}}

) : (
- {origin ? 'Pick a mode to see your route.' : 'Waiting for your location…'} + {origin ? t('routeUi.pickMode') : t('routeUi.waitingLocation')}
)} @@ -1068,13 +1096,13 @@ export function RouteDialog({

- Route steps + {t('routeUi.routeSteps')} {route.steps.length}

{mode === 'foot' && ( -

walking route — quicker on a board

+

{t('routeUi.walkingRoute')}

)}
    @@ -1109,7 +1137,7 @@ export function RouteDialog({ ))}

- Planning view — tap Start to follow with live turns, voice and arrival. + {t('routeUi.planningView')}

)} @@ -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')} ))} {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) */}
- Only + {t('routeUi.only')} {TRANSIT_MODE_GROUPS.map((g) => { const on = transitModeFilter.includes(g.key); return ( @@ -1166,7 +1194,7 @@ export function RouteDialog({ )} > {g.emoji} - {g.label} + {t(TRANSIT_GROUP_LABEL_KEYS[g.key] ?? g.label)} ); })} @@ -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')} )}
@@ -1210,8 +1238,10 @@ export function RouteDialog({ {modes.join('')} {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 })}
); @@ -1220,7 +1250,7 @@ export function RouteDialog({ )}

- Itinerary + {t('routeUi.itinerary')} {selectedTransit.legs.length} @@ -1251,7 +1281,7 @@ export function RouteDialog({ )} {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 && ( - live + {t('routeUi.live')} )} @@ -1270,14 +1300,14 @@ export function RouteDialog({ <> {leg.toName ? ( <> - to{' '} + {t('routeUi.toStop')}{' '} 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} {' → '} 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')} ) : ''} @@ -1335,7 +1365,7 @@ export function RouteDialog({ className="gap-2 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90" > - Done + {t('routeUi.done')} ) : following ? ( ) : ( )}

{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')}

) : null} @@ -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() })}
)} @@ -1693,8 +1694,8 @@ Instructions: @@ -1702,8 +1703,8 @@ Instructions: @@ -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" > - {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')} ) : (
@@ -1752,7 +1753,7 @@ Instructions: {chatMessages.length === 0 && activeMessage.suggestedQuestions && (
- Quick questions: + {t('sasquatchUi.quickQuestions')}
{activeMessage.suggestedQuestions.map((q) => (
@@ -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:

- Live data from{' '} + {t('sasquatchUi.liveDataFrom')}{' '} - Nostr relays + {t('sasquatchUi.nostrRelays')}

{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')} )}
diff --git a/src/components/SasquatchProDialog.tsx b/src/components/SasquatchProDialog.tsx index 7ebff7c..9f1721c 100644 --- a/src/components/SasquatchProDialog.tsx +++ b/src/components/SasquatchProDialog.tsx @@ -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({ 🦧 Sasquatch Pro - Supercharge your AI skate guide. 100% funds a free, no-ads community app. + {t('sasquatchUi.dialogDesc')} @@ -107,23 +109,23 @@ export function SasquatchProDialog({
-

You're a Pro supporter ⚡

+

{t('sasquatchUi.proSupporter')}

{expiresAt && (

- Active until {new Date(expiresAt * 1000).toLocaleDateString()} + {t('sasquatchUi.activeUntil', { date: new Date(expiresAt * 1000).toLocaleDateString() })}

)}
{PERKS.map((p) => ( -
+
- {p.text} + {t(p.textKey)}
))}

- Want to extend? Pick a tier below to add more time. + {t('sasquatchUi.extendPrompt')}

purchase.purchase(selected)} extend /> @@ -132,26 +134,28 @@ export function SasquatchProDialog({ // ── Manual QR payment ───────────────────────────────────────────

- Scan to pay {selected.sats.toLocaleString()} sats for {selected.label} of Pro + {t('sasquatchUi.scanToPayPre')}{' '} + {t('sasquatchUi.scanToPayAmount', { sats: selected.sats.toLocaleString() })}{' '} + {t('sasquatchUi.scanToPayPost', { label: selected.label })}

- {qr && Lightning invoice QR} + {qr && {t('sasquatchUi.invoiceQrAlt')}}

- Paying to{' '} + {t('sasquatchUi.paysTo')}{' '} ⚡ {PRO_LIGHTNING_ADDRESS}

- Tap "I've paid" after your wallet confirms — this records your pass to your Nostr identity. + {t('sasquatchUi.ivePaidHint')}

) : ( @@ -159,22 +163,21 @@ export function SasquatchProDialog({
{PERKS.map((p) => ( -
+
- {p.text} + {t(p.textKey)}
))}
- Free Sasquatch stays free — public spot/event/coach search, trip planning - + the full trick glossary. Pro adds the personal + action layer. + {t('sasquatchUi.freeStaysPre')}{t('sasquatchUi.freeStaysEmphasis')}{t('sasquatchUi.freeStaysPost')}
{!user && ( -

Log in first to upgrade.

+

{t('sasquatchUi.loginFirst')}

)} {purchase.error && (

{purchase.error}

@@ -183,7 +186,7 @@ export function SasquatchProDialog({ purchase.purchase(selected)} disabled={!user} />

- Pays to ⚡ {PRO_LIGHTNING_ADDRESS} over Lightning. Bound to your npub. + {t('sasquatchUi.paysTo')} ⚡ {PRO_LIGHTNING_ADDRESS} {t('sasquatchUi.overLightning')}

)} @@ -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 (
- {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 ( ); })} @@ -239,6 +243,7 @@ function PayButton({ extend?: boolean; onPay: () => void; }) { + const { t } = useTranslation(); return ( ); } diff --git a/src/components/SplashScreen.tsx b/src/components/SplashScreen.tsx index e2a15ce..d3c3d35 100644 --- a/src/components/SplashScreen.tsx +++ b/src/components/SplashScreen.tsx @@ -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 */}
- Ready + {t('bootUi.ready')} ) : ( - {STATUS_LINES[statusIndex]} + {t(STATUS_KEYS[statusIndex])} {!prefersReducedMotion && …} )} diff --git a/src/components/StopDepartures.tsx b/src/components/StopDepartures.tsx index e4af9d1..00b98d7 100644 --- a/src/components/StopDepartures.tsx +++ b/src/components/StopDepartures.tsx @@ -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(null); const [error, setError] = useState(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 ( @@ -63,12 +65,12 @@ export function StopDepartures({ open, onOpenChange, stopId, stopName }: StopDep - Departures + {t('routeUi.departures')} {stopName} - Upcoming departures from {stopName} + {t('routeUi.upcomingFrom', { stop: stopName })} @@ -77,11 +79,11 @@ export function StopDepartures({ open, onOpenChange, stopId, stopName }: StopDep
{error}
) : !rows ? (
- Loading departures… + {t('routeUi.loadingDepartures')}
) : rows.length === 0 ? (
- No upcoming departures found at this stop. + {t('routeUi.noDepartures')}
) : (
    @@ -112,12 +114,12 @@ export function StopDepartures({ open, onOpenChange, stopId, stopName }: StopDep {d.realTime && !d.cancelled && ( - live + {t('routeUi.live')} )} {d.cancelled && ( - cancelled + {t('routeUi.cancelled')} )} @@ -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" > - Refresh + {t('routeUi.refresh')}
diff --git a/src/components/TripItineraryCard.tsx b/src/components/TripItineraryCard.tsx index 44e409c..f08c476 100644 --- a/src/components/TripItineraryCard.tsx +++ b/src/components/TripItineraryCard.tsx @@ -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 = { + 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 }) {
🛹 - {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 })}
{w && ( @@ -82,7 +99,7 @@ export function TripItineraryCard({ itinerary }: { itinerary: Itinerary }) { {rainy && (
- Wet forecast — hit a local crew for covered/indoor options. + {t('routeUi.wetForecast')}
)}
@@ -92,10 +109,10 @@ export function TripItineraryCard({ itinerary }: { itinerary: Itinerary }) { {itinerary.days.map((day) => (
- 📅 {day.label} + 📅 {t('routeUi.dayLabel', { n: day.dayNumber })}
{day.stops.length === 0 ? ( -
Free skate — explore & film. 🎥
+
{t('routeUi.freeSkate')}
) : (
{day.stops.map((stop, i) => { @@ -109,7 +126,7 @@ export function TripItineraryCard({ itinerary }: { itinerary: Itinerary }) {
{stop.subtitle}
)} {stop.kind === 'coach' && ( -
tap to book / zap ⚡
+
{t('routeUi.tapToBook')}
)}
@@ -138,7 +155,7 @@ export function TripItineraryCard({ itinerary }: { itinerary: Itinerary }) { {itinerary.crews.length > 0 && (
- Local crews + {t('routeUi.localCrews')}
{itinerary.crews.map((c, i) => ( @@ -170,11 +187,13 @@ export function TripItineraryCard({ itinerary }: { itinerary: Itinerary }) { )} > {isPending ? ( - <> Saving… + <> {t('routeUi.saving')} ) : saved ? ( - <> Saved to Collections + <> {t('routeUi.savedToCollections')} ) : ( - <> Save {itinerary.allSpotRefs.length} spots to a Collection + <> {itinerary.allSpotRefs.length === 1 + ? t('routeUi.saveSpotOne', { count: itinerary.allSpotRefs.length }) + : t('routeUi.saveSpots', { count: itinerary.allSpotRefs.length })} )} )} @@ -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" > {link.emoji} - {link.label} + {t(TRAVEL_LINK_KEYS[link.label] ?? link.label)} ))}

- I plan the skating — flights & stays open on the real sites (no KYC, no middleman). + {t('routeUi.noMiddleman')}

diff --git a/src/i18n/batch3/bootUi.ts b/src/i18n/batch3/bootUi.ts new file mode 100644 index 0000000..2ffb959 --- /dev/null +++ b/src/i18n/batch3/bootUi.ts @@ -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; diff --git a/src/i18n/batch3/collectionUi.ts b/src/i18n/batch3/collectionUi.ts new file mode 100644 index 0000000..ca0f67b --- /dev/null +++ b/src/i18n/batch3/collectionUi.ts @@ -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; diff --git a/src/i18n/batch3/index.ts b/src/i18n/batch3/index.ts new file mode 100644 index 0000000..99fa90b --- /dev/null +++ b/src/i18n/batch3/index.ts @@ -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, +}; diff --git a/src/i18n/batch3/routeUi.ts b/src/i18n/batch3/routeUi.ts new file mode 100644 index 0000000..dade0cd --- /dev/null +++ b/src/i18n/batch3/routeUi.ts @@ -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; diff --git a/src/i18n/batch3/sasquatchUi.ts b/src/i18n/batch3/sasquatchUi.ts new file mode 100644 index 0000000..5d42083 --- /dev/null +++ b/src/i18n/batch3/sasquatchUi.ts @@ -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; diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 52e757e..2db196b 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -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 }; diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 394d2c2..c0bc6ad 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -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 };