@@ -1752,7 +1753,7 @@ Instructions:
{chatMessages.length === 0 && activeMessage.suggestedQuestions && (
@@ -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:
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({
@@ -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 &&
}
+ {qr &&
}
- Copy invoice
+ {t('sasquatchUi.copyInvoice')}
purchase.confirmManual(selected)}
>
- I've paid
+ {t('sasquatchUi.ivePaid')}
- 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 (
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 && (
- Popular
+ {t('sasquatchUi.popular')}
)}
- {t.label}
- {t.sats.toLocaleString()}sats
- {[usd, t.badge].filter(Boolean).join(' · ')}
+ {tier.label}
+ {tier.sats.toLocaleString()}{t('sasquatchUi.sats')}
+ {[usd, tier.badge].filter(Boolean).join(' · ')}
);
})}
@@ -239,6 +243,7 @@ function PayButton({
extend?: boolean;
onPay: () => void;
}) {
+ const { t } = useTranslation();
return (
{busy ? : }
- {extend ? 'Extend' : 'Go Pro'} · {tier.sats.toLocaleString()} sats
+ {extend ? t('sasquatchUi.extend') : t('sasquatchUi.goPro')} · {tier.sats.toLocaleString()} {t('sasquatchUi.sats')}
);
}
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 };