i18n(es): batch 3 part 1 — routing UI, Sasquatch panel, collections, splash
- routeUi.* (123 keys): RouteDialog (incl. follow-mode banner, transit time picker + mode filters, itinerary legs), StopDepartures, TripItineraryCard. - sasquatchUi.* (81 keys): SasquatchGuide, SasquatchProDialog, SasquatchActionCard, SasquatchAiStatusCard. LLM prompts + chat bodies deliberately left English. - collectionUi.* / bootUi.*: AddToCollectionDialog example placeholders, SplashScreen rotating status lines + aria labels. - New src/i18n/batch3/* fragment files, spread into en/es so parallel passes never edit the big literal.
This commit is contained in:
parent
a6dd32e653
commit
3e6354995a
16 changed files with 872 additions and 226 deletions
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -45,6 +45,25 @@ import {
|
|||
transitPrimaryEmoji,
|
||||
} from '@/lib/transitUi';
|
||||
import { StopDepartures } from '@/components/StopDepartures';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
/** Travel-mode labels live in `lib/routing`; map them to translation keys at render. */
|
||||
const MODE_LABEL_KEYS: Record<TravelMode, string> = {
|
||||
foot: 'routeUi.mode.foot',
|
||||
bike: 'routeUi.mode.bike',
|
||||
moto: 'routeUi.mode.moto',
|
||||
car: 'routeUi.mode.car',
|
||||
transit: 'routeUi.mode.transit',
|
||||
};
|
||||
|
||||
/** Transit mode-group labels live in `lib/transitUi`; translate at render. */
|
||||
const TRANSIT_GROUP_LABEL_KEYS: Record<string, string> = {
|
||||
bus: 'routeUi.group.bus',
|
||||
tram: 'routeUi.group.tram',
|
||||
metro: 'routeUi.group.metro',
|
||||
train: 'routeUi.group.train',
|
||||
ferry: 'routeUi.group.ferry',
|
||||
};
|
||||
|
||||
/** A stop name that opens the departure board, when the feed gave a stop id. */
|
||||
function StopButton({
|
||||
|
|
@ -88,12 +107,12 @@ interface RouteDialogProps {
|
|||
* maps app is opened — the route comes from the keyless public OSRM server and
|
||||
* is drawn here. See src/lib/routing.ts for the honest scope notes.
|
||||
*/
|
||||
/** The average speed the routing engine assumed for this mode, e.g. "21 km/h avg". */
|
||||
function speedLabel(distanceM: number, durationS: number): string | undefined {
|
||||
/** The average speed the routing engine assumed for this mode, e.g. "21". */
|
||||
function speedValue(distanceM: number, durationS: number): string | undefined {
|
||||
if (!(distanceM > 0) || !(durationS > 0)) return undefined;
|
||||
const kmh = distanceM / 1000 / (durationS / 3600);
|
||||
if (!Number.isFinite(kmh) || kmh <= 0) return undefined;
|
||||
return `${kmh < 10 ? kmh.toFixed(1) : Math.round(kmh)} km/h avg`;
|
||||
return kmh < 10 ? kmh.toFixed(1) : String(Math.round(kmh));
|
||||
}
|
||||
|
||||
/** Local clock time you'd arrive if you set off now. */
|
||||
|
|
@ -108,15 +127,16 @@ export function RouteDialog({
|
|||
open,
|
||||
onOpenChange,
|
||||
destination,
|
||||
destinationLabel = 'Destination',
|
||||
destinationLabel,
|
||||
onDestinationChange,
|
||||
}: RouteDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
// The destination is owned HERE so the in-dialog search can change it without
|
||||
// the parent re-opening the dialog. Seeded from the prop whenever the dialog
|
||||
// OPENS (or the parent's target actually changes) — not on every parent
|
||||
// render, which would revert a search the user just made.
|
||||
const [dest, setDest] = useState(destination);
|
||||
const [destLabel, setDestLabel] = useState(destinationLabel);
|
||||
const [destLabel, setDestLabel] = useState(destinationLabel ?? '');
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const lastPropKeyRef = useRef<string>('');
|
||||
// The map's initial centre; read by the init effect without making it a dep
|
||||
|
|
@ -133,7 +153,7 @@ export function RouteDialog({
|
|||
if (key === lastPropKeyRef.current) return;
|
||||
lastPropKeyRef.current = key;
|
||||
setDest(destination);
|
||||
setDestLabel(destinationLabel);
|
||||
setDestLabel(destinationLabel ?? '');
|
||||
}, [open, destination, destinationLabel]);
|
||||
// The map container is inside Radix Dialog content, which mounts AFTER this
|
||||
// component's first effect pass. A plain useRef is therefore still null when
|
||||
|
|
@ -362,7 +382,7 @@ export function RouteDialog({
|
|||
setFollowing(true);
|
||||
void acquireWakeLock();
|
||||
// Spoken from the Start tap (a user gesture) — this also unlocks TTS.
|
||||
speak('Starting navigation');
|
||||
speak(t('routeUi.voiceStart'));
|
||||
watchRef.current = watchPosition(
|
||||
(coords) => {
|
||||
setLive(coords);
|
||||
|
|
@ -373,14 +393,14 @@ export function RouteDialog({
|
|||
// loss is normal (tunnels, indoors) and the watcher recovers by itself,
|
||||
// so we keep running and just say we're waiting.
|
||||
if (err.kind === 'denied') {
|
||||
setFollowError('Location permission denied.');
|
||||
setFollowError(t('routeUi.errPermissionDenied'));
|
||||
stopFollowing();
|
||||
} else {
|
||||
setFollowError('Waiting for a GPS fix…');
|
||||
setFollowError(t('routeUi.errWaitingGps'));
|
||||
}
|
||||
},
|
||||
);
|
||||
}, [stopFollowing, acquireWakeLock, speak]);
|
||||
}, [stopFollowing, acquireWakeLock, speak, t]);
|
||||
|
||||
// Never leave the GPS running: stop on close and on unmount.
|
||||
useEffect(() => {
|
||||
|
|
@ -446,7 +466,7 @@ export function RouteDialog({
|
|||
if (nav.toDestKm < 0.03) {
|
||||
if (!arrivedRef.current) {
|
||||
arrivedRef.current = true;
|
||||
speak("You've arrived");
|
||||
speak(t('routeUi.voiceArrived'));
|
||||
buzz([120, 80, 120]);
|
||||
setArrived(true);
|
||||
stopFollowing();
|
||||
|
|
@ -466,16 +486,19 @@ export function RouteDialog({
|
|||
bands.add(150);
|
||||
announcedRef.current.set(idx, bands);
|
||||
const metres = Math.max(50, Math.round((km * 1000) / 10) * 10);
|
||||
speak(`In ${metres} metres, ${steps[idx]?.instruction ?? 'continue'}`);
|
||||
speak(t('routeUi.voiceIn', {
|
||||
metres,
|
||||
instruction: steps[idx]?.instruction ?? t('routeUi.voiceContinue'),
|
||||
}));
|
||||
buzz(60);
|
||||
} else if (km <= 0.04 && !bands.has(40)) {
|
||||
// ~40 m out — the turn itself.
|
||||
bands.add(40);
|
||||
announcedRef.current.set(idx, bands);
|
||||
speak(steps[idx]?.instruction ?? 'Continue');
|
||||
speak(steps[idx]?.instruction ?? t('routeUi.voiceContinueCaps'));
|
||||
buzz(140);
|
||||
}
|
||||
}, [following, nav, route, speak, buzz, stopFollowing]);
|
||||
}, [following, nav, route, speak, buzz, stopFollowing, t]);
|
||||
|
||||
// ─── Get the user's location when the dialog opens ─────────────────────────
|
||||
const locate = useCallback(async () => {
|
||||
|
|
@ -488,14 +511,14 @@ export function RouteDialog({
|
|||
const msg =
|
||||
err instanceof GeolocationError
|
||||
? err.kind === 'denied'
|
||||
? 'Location permission denied. Enable location to route from where you are.'
|
||||
: 'Could not get your location. Try again.'
|
||||
: 'Could not get your location.';
|
||||
? t('routeUi.errPermissionDeniedLong')
|
||||
: t('routeUi.errNoLocation')
|
||||
: t('routeUi.errNoLocationShort');
|
||||
setLocError(msg);
|
||||
} finally {
|
||||
setLocating(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
|
@ -677,16 +700,16 @@ export function RouteDialog({
|
|||
setError(
|
||||
msg === 'No transit route found'
|
||||
? selectedTransitModes.length > 0 || transitTimeMode !== 'now'
|
||||
? 'No route for that time or transport mode — try another time, or include more modes.'
|
||||
: 'No public-transport route found here — this area may not publish open timetables.'
|
||||
: "Couldn't reach the transit service. Check your connection and try again.",
|
||||
? t('routeUi.errTransitNoRouteFiltered')
|
||||
: t('routeUi.errTransitNoRoute')
|
||||
: t('routeUi.errTransitUnreachable'),
|
||||
);
|
||||
} else if (msg === ROUTE_ERR_BUSY) {
|
||||
setError('The routing service is busy right now — try again in a moment.');
|
||||
setError(t('routeUi.errRoutingBusy'));
|
||||
} else if (msg === ROUTE_ERR_NO_ROUTE) {
|
||||
setError('No route for this mode — the destination may be off the road network. Try another mode.');
|
||||
setError(t('routeUi.errNoRouteMode'));
|
||||
} else {
|
||||
setError("Couldn't reach the routing service. Check your connection and try again.");
|
||||
setError(t('routeUi.errRoutingUnreachable'));
|
||||
}
|
||||
|
||||
// Offline / server down: fall back to the last route we saved for this
|
||||
|
|
@ -723,7 +746,7 @@ export function RouteDialog({
|
|||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [open, effectiveOrigin, dest, mode, transitTimeMode, transitTimeValue, selectedTransitModes]);
|
||||
}, [open, effectiveOrigin, dest, mode, transitTimeMode, transitTimeValue, selectedTransitModes, t]);
|
||||
|
||||
// ─── Draw the selected transit itinerary (redraws when an alternative is
|
||||
// chosen — no re-query). Walking legs dashed grey, vehicles solid. ──────
|
||||
|
|
@ -754,6 +777,9 @@ export function RouteDialog({
|
|||
}
|
||||
}, [selectedTransit]);
|
||||
|
||||
const destLabelText = destLabel || t('routeUi.destination');
|
||||
const speed = route ? speedValue(route.distanceM, route.durationS) : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
|
|
@ -763,7 +789,7 @@ export function RouteDialog({
|
|||
scroll with the Start/Stop bar pinned at the bottom. */}
|
||||
<DialogContent className="flex max-h-[calc(100dvh-2rem)] w-[calc(100%-2rem)] max-w-lg flex-col gap-0 overflow-hidden p-0 sm:max-w-xl">
|
||||
<DialogHeader className="order-1 shrink-0 pl-4 pr-12 pt-4 pb-3">
|
||||
<DialogTitle className="sr-only">Route to {destLabel}</DialogTitle>
|
||||
<DialogTitle className="sr-only">{t('routeUi.routeTo', { label: destLabelText })}</DialogTitle>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchOpen(true)}
|
||||
|
|
@ -772,19 +798,19 @@ export function RouteDialog({
|
|||
<Navigation className="h-5 w-5 shrink-0 text-[#ccff00]" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-[10px] font-bold uppercase tracking-widest text-gray-500">
|
||||
Route to
|
||||
{t('routeUi.routeToLabel')}
|
||||
</span>
|
||||
<span className="block truncate text-base font-bold text-white">{destLabel}</span>
|
||||
<span className="block truncate text-base font-bold text-white">{destLabelText}</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1.5 rounded-full border border-white/10 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider text-gray-300">
|
||||
<Search className="h-3 w-3" /> Search
|
||||
<Search className="h-3 w-3" /> {t('routeUi.search')}
|
||||
</span>
|
||||
</button>
|
||||
{/* Copy kept out of the visible chrome so the route screen stays
|
||||
clean; the same promise now lives on /privacy → "What leaves your
|
||||
device" → Directions. sr-only keeps the dialog accessible. */}
|
||||
<DialogDescription className="sr-only">
|
||||
Route options and in-app directions.
|
||||
{t('routeUi.description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
@ -796,7 +822,7 @@ export function RouteDialog({
|
|||
<div
|
||||
ref={setMapDiv}
|
||||
className="h-full w-full bg-[#0b0b0c]"
|
||||
aria-label="Route map"
|
||||
aria-label={t('routeUi.routeMapAria')}
|
||||
/>
|
||||
|
||||
{/* Destination search — overlays the map while open. Mounted only
|
||||
|
|
@ -823,7 +849,7 @@ export function RouteDialog({
|
|||
<div className="absolute inset-0 flex items-center justify-center bg-background/60 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-[#ccff00]" />
|
||||
{locating && !origin ? 'Finding your location…' : 'Plotting your route…'}
|
||||
{locating && !origin ? t('routeUi.findingLocation') : t('routeUi.plottingRoute')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -834,13 +860,13 @@ export function RouteDialog({
|
|||
<div className="pointer-events-none absolute bottom-2 left-2 flex flex-col items-start gap-1">
|
||||
{cachedAge !== null && (
|
||||
<div className="rounded-full border border-amber-500/40 bg-amber-500/15 px-2 py-1 text-[10px] font-bold uppercase tracking-wide text-amber-300 backdrop-blur-sm">
|
||||
Offline · saved route {formatAge(cachedAge)}
|
||||
{t('routeUi.offlineSavedRoute', { age: formatAge(cachedAge) })}
|
||||
</div>
|
||||
)}
|
||||
{locating && origin && (
|
||||
<div className="flex items-center gap-1.5 rounded-full bg-background/80 px-2 py-1 text-[10px] font-medium text-muted-foreground backdrop-blur-sm">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Refining location…
|
||||
{t('routeUi.refiningLocation')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -849,7 +875,7 @@ export function RouteDialog({
|
|||
{/* "Choose on map" hint */}
|
||||
{pickingStart && (
|
||||
<div className="pointer-events-none absolute inset-x-2 top-2 rounded-xl bg-black/80 px-3 py-2 text-center text-[11px] font-bold text-[#ccff00] backdrop-blur-sm">
|
||||
Tap the map to set your start point
|
||||
{t('routeUi.tapMapStart')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -857,18 +883,18 @@ export function RouteDialog({
|
|||
{following && nav && (
|
||||
<div className="pointer-events-none absolute inset-x-2 top-2 rounded-xl bg-black/85 px-3 py-2 backdrop-blur-sm">
|
||||
<p className="text-[11px] font-black uppercase tracking-widest text-[#ccff00]">
|
||||
Next{nav.stepIndex >= 0 ? ` · step ${nav.stepIndex + 1}` : ''}
|
||||
{nav.stepIndex >= 0 ? t('routeUi.nextStep', { n: nav.stepIndex + 1 }) : t('routeUi.next')}
|
||||
</p>
|
||||
<p className="truncate text-base font-black text-white">
|
||||
{nav.stepIndex >= 0
|
||||
? route?.steps?.[nav.stepIndex]?.instruction ?? 'Follow the line'
|
||||
: route?.steps?.[0]?.instruction ?? 'Follow the line'}
|
||||
? route?.steps?.[nav.stepIndex]?.instruction ?? t('routeUi.followTheLine')
|
||||
: route?.steps?.[0]?.instruction ?? t('routeUi.followTheLine')}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{nav.stepIndex >= 0 && Number.isFinite(nav.stepKm)
|
||||
? `${formatDistance(nav.stepKm)} to the turn · `
|
||||
? `${formatDistance(nav.stepKm)} ${t('routeUi.toTheTurn')} · `
|
||||
: ''}
|
||||
{formatDistance(nav.toDestKm)} to go
|
||||
{formatDistance(nav.toDestKm)} {t('routeUi.toGo')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -886,7 +912,7 @@ export function RouteDialog({
|
|||
<p className="text-sm text-muted-foreground">{locError}</p>
|
||||
<Button size="sm" variant="outline" onClick={locate} className="gap-2">
|
||||
<LocateFixed className="h-4 w-4" />
|
||||
Try again
|
||||
{t('routeUi.tryAgain')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -898,10 +924,10 @@ export function RouteDialog({
|
|||
type="button"
|
||||
onClick={fitRoute}
|
||||
className="absolute bottom-2 right-2 flex items-center gap-1 rounded-lg border border-white/10 bg-background/85 px-2 py-1.5 text-[10px] font-bold uppercase tracking-wide text-muted-foreground shadow backdrop-blur-sm transition-colors hover:text-foreground"
|
||||
aria-label="Fit the whole route on the map"
|
||||
aria-label={t('routeUi.fitRouteAria')}
|
||||
>
|
||||
<LocateFixed className="h-3.5 w-3.5" />
|
||||
Whole route
|
||||
{t('routeUi.wholeRoute')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -922,7 +948,7 @@ export function RouteDialog({
|
|||
aria-pressed={mode === m.mode}
|
||||
>
|
||||
<span className="text-lg leading-none">{m.emoji}</span>
|
||||
<span className="text-[10px] leading-tight text-center">{m.label}</span>
|
||||
<span className="text-[10px] leading-tight text-center">{t(MODE_LABEL_KEYS[m.mode])}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -930,7 +956,7 @@ export function RouteDialog({
|
|||
{/* Start point — default is where you are; you can pick another on the map */}
|
||||
<div className="order-4 flex shrink-0 flex-wrap items-center gap-1.5 px-4 pb-3">
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-muted-foreground">
|
||||
From
|
||||
{t('routeUi.from')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -945,7 +971,7 @@ export function RouteDialog({
|
|||
: 'border-white/10 text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
My location
|
||||
{t('routeUi.myLocation')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -958,7 +984,7 @@ export function RouteDialog({
|
|||
)}
|
||||
>
|
||||
<Crosshair className="h-3 w-3" />
|
||||
{pickingStart ? 'Tap the map…' : 'Choose on map'}
|
||||
{pickingStart ? t('routeUi.tapTheMap') : t('routeUi.chooseOnMap')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -983,9 +1009,11 @@ export function RouteDialog({
|
|||
<div className="text-xs text-muted-foreground">
|
||||
{formatDuration(selectedTransit.durationS)} ·{' '}
|
||||
{selectedTransit.transfers === 0
|
||||
? 'direct'
|
||||
: `${selectedTransit.transfers} transfer${selectedTransit.transfers > 1 ? 's' : ''}`}
|
||||
{options.length > 1 && ` · ${options.length} options`}
|
||||
? t('routeUi.direct')
|
||||
: selectedTransit.transfers === 1
|
||||
? t('routeUi.transferOne')
|
||||
: t('routeUi.transfers', { count: selectedTransit.transfers })}
|
||||
{options.length > 1 && ` · ${t('routeUi.options', { count: options.length })}`}
|
||||
{routing && <Loader2 className="ml-1 inline h-3 w-3 animate-spin" />}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1021,13 +1049,13 @@ export function RouteDialog({
|
|||
{formatDuration(route.durationS)}
|
||||
</span>
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
ride
|
||||
{t('routeUi.ride')}
|
||||
</span>
|
||||
</span>
|
||||
<span aria-hidden className="h-3.5 w-px bg-white/15" />
|
||||
<span className="flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
arrive
|
||||
{t('routeUi.arrive')}
|
||||
</span>
|
||||
<span className="text-base font-black tabular-nums text-white">
|
||||
{etaClock(route.durationS)}
|
||||
|
|
@ -1040,24 +1068,24 @@ export function RouteDialog({
|
|||
</span>
|
||||
<span>
|
||||
{route.steps && route.steps.length > 1
|
||||
? `${route.steps.length} steps`
|
||||
: 'direct'}
|
||||
? t('routeUi.stepsCount', { count: route.steps.length })
|
||||
: t('routeUi.direct')}
|
||||
</span>
|
||||
{mode === 'foot'
|
||||
? <span>· {skateTime(route.distanceM / 1000)} on a board</span>
|
||||
: speedLabel(route.distanceM, route.durationS) && (
|
||||
<span>· {speedLabel(route.distanceM, route.durationS)}</span>
|
||||
? <span>· {skateTime(route.distanceM / 1000)} {t('routeUi.onABoard')}</span>
|
||||
: speed && (
|
||||
<span>· {t('routeUi.speedAvg', { speed })}</span>
|
||||
)}
|
||||
{TRAVEL_MODES.find((m) => m.mode === mode)?.approximate && (
|
||||
<span>· uses driving roads</span>
|
||||
<span>· {t('routeUi.usesDrivingRoads')}</span>
|
||||
)}
|
||||
{route.approximate && <span>· driving estimate</span>}
|
||||
{routing && <span>· updating…</span>}
|
||||
{route.approximate && <span>· {t('routeUi.drivingEstimate')}</span>}
|
||||
{routing && <span>· {t('routeUi.updating')}</span>}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{origin ? 'Pick a mode to see your route.' : 'Waiting for your location…'}
|
||||
{origin ? t('routeUi.pickMode') : t('routeUi.waitingLocation')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1068,13 +1096,13 @@ export function RouteDialog({
|
|||
<div className="border-t bg-muted/20">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-2 px-4 pt-3">
|
||||
<p className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-muted-foreground">
|
||||
Route steps
|
||||
{t('routeUi.routeSteps')}
|
||||
<span className="rounded-full bg-white/10 px-1.5 py-0.5 text-[9px] tabular-nums">
|
||||
{route.steps.length}
|
||||
</span>
|
||||
</p>
|
||||
{mode === 'foot' && (
|
||||
<p className="text-[10px] text-muted-foreground/70">walking route — quicker on a board</p>
|
||||
<p className="text-[10px] text-muted-foreground/70">{t('routeUi.walkingRoute')}</p>
|
||||
)}
|
||||
</div>
|
||||
<ol className="max-h-72 space-y-1.5 overflow-y-auto px-4 pb-2 pt-1.5">
|
||||
|
|
@ -1109,7 +1137,7 @@ export function RouteDialog({
|
|||
))}
|
||||
</ol>
|
||||
<p className="px-4 pb-3 text-[10px] text-muted-foreground/60">
|
||||
Planning view — tap Start to follow with live turns, voice and arrival.
|
||||
{t('routeUi.planningView')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -1133,7 +1161,7 @@ export function RouteDialog({
|
|||
: 'border-white/10 text-gray-400 hover:border-white/25',
|
||||
)}
|
||||
>
|
||||
{m === 'now' ? 'Leave now' : m === 'depart' ? 'Depart at' : 'Arrive by'}
|
||||
{m === 'now' ? t('routeUi.leaveNow') : m === 'depart' ? t('routeUi.departAt') : t('routeUi.arriveBy')}
|
||||
</button>
|
||||
))}
|
||||
{transitTimeMode !== 'now' && (
|
||||
|
|
@ -1141,7 +1169,7 @@ export function RouteDialog({
|
|||
type="datetime-local"
|
||||
value={transitTimeValue}
|
||||
onChange={(e) => setTransitTimeValue(e.target.value)}
|
||||
aria-label={transitTimeMode === 'depart' ? 'Departure time' : 'Arrival time'}
|
||||
aria-label={transitTimeMode === 'depart' ? t('routeUi.departureTime') : t('routeUi.arrivalTime')}
|
||||
className="min-h-0 rounded-lg border border-white/15 bg-white/[0.04] px-2 py-1 text-[11px] text-white"
|
||||
/>
|
||||
)}
|
||||
|
|
@ -1149,7 +1177,7 @@ export function RouteDialog({
|
|||
|
||||
{/* Only: filter the modes (nothing selected = all) */}
|
||||
<div className="flex flex-wrap items-center gap-1.5 px-4 pt-2">
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-muted-foreground">Only</span>
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-muted-foreground">{t('routeUi.only')}</span>
|
||||
{TRANSIT_MODE_GROUPS.map((g) => {
|
||||
const on = transitModeFilter.includes(g.key);
|
||||
return (
|
||||
|
|
@ -1166,7 +1194,7 @@ export function RouteDialog({
|
|||
)}
|
||||
>
|
||||
<span aria-hidden>{g.emoji}</span>
|
||||
{g.label}
|
||||
{t(TRANSIT_GROUP_LABEL_KEYS[g.key] ?? g.label)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
|
@ -1176,7 +1204,7 @@ export function RouteDialog({
|
|||
onClick={() => setTransitModeFilter([])}
|
||||
className="min-h-0 rounded-full px-2 py-0.5 text-[10px] font-bold text-muted-foreground underline decoration-dotted hover:text-foreground"
|
||||
>
|
||||
clear
|
||||
{t('routeUi.clear')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1210,8 +1238,10 @@ export function RouteDialog({
|
|||
<span aria-hidden>{modes.join('')}</span>
|
||||
{formatDuration(opt.durationS)} ·{' '}
|
||||
{opt.transfers === 0
|
||||
? 'direct'
|
||||
: `${opt.transfers} transfer${opt.transfers > 1 ? 's' : ''}`}
|
||||
? t('routeUi.direct')
|
||||
: opt.transfers === 1
|
||||
? t('routeUi.transferOne')
|
||||
: t('routeUi.transfers', { count: opt.transfers })}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
|
@ -1220,7 +1250,7 @@ export function RouteDialog({
|
|||
)}
|
||||
|
||||
<p className="flex items-center gap-2 px-4 pt-3 text-[10px] font-black uppercase tracking-widest text-muted-foreground">
|
||||
Itinerary
|
||||
{t('routeUi.itinerary')}
|
||||
<span className="rounded-full bg-white/10 px-1.5 py-0.5 text-[9px] tabular-nums">
|
||||
{selectedTransit.legs.length}
|
||||
</span>
|
||||
|
|
@ -1251,7 +1281,7 @@ export function RouteDialog({
|
|||
)}
|
||||
<span className="text-xs font-bold text-white">
|
||||
{isFoot
|
||||
? leg.mode === 'BIKE' ? 'Ride' : 'Walk'
|
||||
? leg.mode === 'BIKE' ? t('routeUi.rideVerb') : t('routeUi.walk')
|
||||
: leg.headsign
|
||||
? `→ ${leg.headsign}`
|
||||
: (leg.line ?? leg.mode)}
|
||||
|
|
@ -1261,7 +1291,7 @@ export function RouteDialog({
|
|||
)}
|
||||
{leg.realTime && (
|
||||
<span className="rounded bg-[#00ff88]/15 px-1 text-[9px] font-black uppercase text-[#00ff88]">
|
||||
live
|
||||
{t('routeUi.live')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1270,14 +1300,14 @@ export function RouteDialog({
|
|||
<>
|
||||
{leg.toName ? (
|
||||
<>
|
||||
to{' '}
|
||||
{t('routeUi.toStop')}{' '}
|
||||
<StopButton
|
||||
id={leg.toStopId}
|
||||
name={leg.toName}
|
||||
onOpen={(id, name) => setStopBoard({ id, name })}
|
||||
/>
|
||||
</>
|
||||
) : leg.mode === 'BIKE' ? 'ride' : 'walk'}
|
||||
) : leg.mode === 'BIKE' ? t('routeUi.rideLower') : t('routeUi.walkLower')}
|
||||
{leg.distanceM ? ` · ${formatDistance(leg.distanceM / 1000)}` : ''}
|
||||
</>
|
||||
) : (
|
||||
|
|
@ -1287,16 +1317,16 @@ export function RouteDialog({
|
|||
name={leg.fromName}
|
||||
onOpen={(id, name) => setStopBoard({ id, name })}
|
||||
/>
|
||||
{leg.fromTrack ? ` · track ${leg.fromTrack}` : ''} {startClock}
|
||||
{leg.fromTrack ? ` · ${t('routeUi.track', { track: leg.fromTrack })}` : ''} {startClock}
|
||||
{' → '}
|
||||
<StopButton
|
||||
id={leg.toStopId}
|
||||
name={leg.toName}
|
||||
onOpen={(id, name) => setStopBoard({ id, name })}
|
||||
/>
|
||||
{leg.toTrack ? ` · track ${leg.toTrack}` : ''} {endClock}
|
||||
{leg.toTrack ? ` · ${t('routeUi.track', { track: leg.toTrack })}` : ''} {endClock}
|
||||
{typeof leg.stops === 'number' && leg.stops > 0
|
||||
? ` · ${leg.stops} stop${leg.stops > 1 ? 's' : ''}`
|
||||
? ` · ${leg.stops === 1 ? t('routeUi.stopOne') : t('routeUi.stopsCount', { count: leg.stops })}`
|
||||
: ''}
|
||||
{leg.agencyName ? ` · ${leg.agencyName}` : ''}
|
||||
{leg.agencyFareUrl ? (
|
||||
|
|
@ -1308,7 +1338,7 @@ export function RouteDialog({
|
|||
rel="noopener noreferrer"
|
||||
className="underline decoration-dotted underline-offset-2 hover:text-foreground"
|
||||
>
|
||||
tickets ↗
|
||||
{t('routeUi.tickets')}
|
||||
</a>
|
||||
</>
|
||||
) : ''}
|
||||
|
|
@ -1335,7 +1365,7 @@ export function RouteDialog({
|
|||
className="gap-2 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
Done
|
||||
{t('routeUi.done')}
|
||||
</Button>
|
||||
) : following ? (
|
||||
<Button
|
||||
|
|
@ -1345,7 +1375,7 @@ export function RouteDialog({
|
|||
className="gap-2 border-[#ff0040]/50 text-[#ff0040] hover:bg-[#ff0040]/10"
|
||||
>
|
||||
<Square className="h-3.5 w-3.5" />
|
||||
Stop
|
||||
{t('routeUi.stop')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
|
|
@ -1354,23 +1384,23 @@ export function RouteDialog({
|
|||
className="gap-2 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
|
||||
>
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
Start
|
||||
{t('routeUi.start')}
|
||||
</Button>
|
||||
)}
|
||||
<p className="min-w-0 flex-1 text-[11px] leading-snug text-muted-foreground">
|
||||
{arrived
|
||||
? "You've arrived. 🛹"
|
||||
? t('routeUi.arrived')
|
||||
: following
|
||||
? voiceOn
|
||||
? 'Screen stays on · voice on — turns and arrival are spoken.'
|
||||
: 'Screen stays on · voice off.'
|
||||
: 'Turns, voice and arrival as you roll. Keeps the screen on.'}
|
||||
? t('routeUi.voiceOnNote')
|
||||
: t('routeUi.voiceOffNote')
|
||||
: t('routeUi.followNote')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleVoice}
|
||||
aria-pressed={voiceOn}
|
||||
aria-label={voiceOn ? 'Turn voice guidance off' : 'Turn voice guidance on'}
|
||||
aria-label={voiceOn ? t('routeUi.voiceOffAria') : t('routeUi.voiceOnAria')}
|
||||
className={cn(
|
||||
'flex h-9 w-9 shrink-0 items-center justify-center rounded-full border transition-colors',
|
||||
voiceOn
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { useState } from 'react';
|
|||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Loader2, Check, X, Wand2 } from 'lucide-react';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import {
|
||||
summarizeDraft,
|
||||
type SasquatchDraft,
|
||||
|
|
@ -24,20 +25,21 @@ interface Props {
|
|||
}
|
||||
|
||||
export function SasquatchActionCard({ draft, onPublish, onCancel, status, resultLabel }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [edited, setEdited] = useState<SasquatchDraft>(draft);
|
||||
const summary = summarizeDraft(edited);
|
||||
|
||||
if (status === 'done') {
|
||||
return (
|
||||
<div className="rounded-xl border border-[#ccff00]/30 bg-[#ccff00]/10 p-3 text-xs text-[#ccff00] flex items-center gap-2">
|
||||
<Check className="h-4 w-4 shrink-0" /> {resultLabel ?? 'Done!'}
|
||||
<Check className="h-4 w-4 shrink-0" /> {resultLabel ?? t('sasquatchUi.done')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (status === 'cancelled') {
|
||||
return (
|
||||
<div className="rounded-xl border border-white/10 bg-white/[0.03] p-3 text-xs text-gray-500 flex items-center gap-2">
|
||||
<X className="h-4 w-4 shrink-0" /> Cancelled — nothing was published.
|
||||
<X className="h-4 w-4 shrink-0" /> {t('sasquatchUi.cancelledNothing')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -47,7 +49,7 @@ export function SasquatchActionCard({ draft, onPublish, onCancel, status, result
|
|||
return (
|
||||
<div className="rounded-xl border border-red-500/30 bg-red-500/[0.06] p-3 space-y-2.5">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-black uppercase tracking-wider text-red-400">
|
||||
<Wand2 className="h-3 w-3" /> Draft — review & confirm
|
||||
<Wand2 className="h-3 w-3" /> {t('sasquatchUi.draftReview')}
|
||||
</div>
|
||||
|
||||
<p className="text-sm font-bold text-white">{summary.title}</p>
|
||||
|
|
@ -59,7 +61,7 @@ export function SasquatchActionCard({ draft, onPublish, onCancel, status, result
|
|||
onChange={(e) => setEdited({ ...edited, text: e.target.value })}
|
||||
disabled={busy}
|
||||
className="h-9 bg-black/40 border-white/10 text-white text-sm"
|
||||
placeholder="Status text"
|
||||
placeholder={t('sasquatchUi.statusTextPlaceholder')}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
|
|
@ -68,7 +70,7 @@ export function SasquatchActionCard({ draft, onPublish, onCancel, status, result
|
|||
onChange={(e) => setEdited({ ...edited, title: e.target.value })}
|
||||
disabled={busy}
|
||||
className="col-span-2 h-9 bg-black/40 border-white/10 text-white text-sm"
|
||||
placeholder="Title"
|
||||
placeholder={t('sasquatchUi.titlePlaceholder')}
|
||||
/>
|
||||
<Input
|
||||
value={edited.price ?? ''}
|
||||
|
|
@ -77,14 +79,14 @@ export function SasquatchActionCard({ draft, onPublish, onCancel, status, result
|
|||
type="number"
|
||||
inputMode="numeric"
|
||||
className="h-9 bg-black/40 border-white/10 text-white text-sm"
|
||||
placeholder="Price (sats)"
|
||||
placeholder={t('sasquatchUi.priceSatsPlaceholder')}
|
||||
/>
|
||||
<Input
|
||||
value={edited.condition ?? ''}
|
||||
onChange={(e) => setEdited({ ...edited, condition: e.target.value })}
|
||||
disabled={busy}
|
||||
className="h-9 bg-black/40 border-white/10 text-white text-sm"
|
||||
placeholder="Condition"
|
||||
placeholder={t('sasquatchUi.conditionPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -104,7 +106,7 @@ export function SasquatchActionCard({ draft, onPublish, onCancel, status, result
|
|||
onClick={onCancel}
|
||||
disabled={busy}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" /> Cancel
|
||||
<X className="h-3.5 w-3.5" /> {t('sasquatchUi.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Card, CardContent } from '@/components/ui/card';
|
|||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions';
|
||||
import { useSasquatchAiStatus } from '@/hooks/useSasquatchAiStatus';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
|
|
@ -11,6 +12,7 @@ import { cn } from '@/lib/utils';
|
|||
* needed, since the operator can see it live in Settings.
|
||||
*/
|
||||
export function SasquatchAiStatusCard() {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const isOwner = !!user?.pubkey && PLATFORM_OWNER_PUBKEYS.includes(user.pubkey);
|
||||
const { data, isLoading, isError } = useSasquatchAiStatus();
|
||||
|
|
@ -30,9 +32,9 @@ export function SasquatchAiStatusCard() {
|
|||
) : (
|
||||
<Coins className="h-4 w-4 text-red-400" />
|
||||
)}
|
||||
Sasquatch AI
|
||||
{t('sasquatchUi.aiTitle')}
|
||||
<span className="rounded bg-red-500/10 px-1.5 py-0.5 text-[9px] font-black uppercase tracking-widest text-red-400/70">
|
||||
Owner
|
||||
{t('sasquatchUi.owner')}
|
||||
</span>
|
||||
</p>
|
||||
<span
|
||||
|
|
@ -44,18 +46,18 @@ export function SasquatchAiStatusCard() {
|
|||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : isError || data?.configured === false ? (
|
||||
'not configured'
|
||||
t('sasquatchUi.notConfigured')
|
||||
) : balance === null || balance === undefined ? (
|
||||
'unavailable'
|
||||
t('sasquatchUi.unavailable')
|
||||
) : (
|
||||
`${balance.toLocaleString()} sats`
|
||||
t('sasquatchUi.satsValue', { balance: balance.toLocaleString() })
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{low
|
||||
? `Running low — top up at ${data?.provider ?? 'the node'} or AI will fall back to the keyless provider.`
|
||||
: 'Pay-per-request AI, funded in sats via Routstr. Tops up when low.'}
|
||||
? t('sasquatchUi.lowBalance', { provider: data?.provider ?? t('sasquatchUi.theNode') })
|
||||
: t('sasquatchUi.aiDesc')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -576,6 +576,7 @@ function getStaticAnswer(question: string): string | null {
|
|||
// ─── CVM connection status indicator ─────────────────────────────────────────
|
||||
|
||||
function CVMStatus({ connected }: { connected: boolean | null }) {
|
||||
const { t } = useTranslation();
|
||||
if (connected === null) return null;
|
||||
return (
|
||||
<div className={cn(
|
||||
|
|
@ -585,9 +586,9 @@ function CVMStatus({ connected }: { connected: boolean | null }) {
|
|||
: 'bg-white/5 text-gray-500'
|
||||
)}>
|
||||
{connected ? (
|
||||
<><Wifi className="h-2 w-2" /> live</>
|
||||
<><Wifi className="h-2 w-2" /> {t('sasquatchUi.live')}</>
|
||||
) : (
|
||||
<><WifiOff className="h-2 w-2" /> offline</>
|
||||
<><WifiOff className="h-2 w-2" /> {t('sasquatchUi.offline')}</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -645,7 +646,7 @@ function ChatText({ text }: { text: string }) {
|
|||
|
||||
export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { language } = useTranslation();
|
||||
const { t, language } = useTranslation();
|
||||
const cvm = useCVMClient();
|
||||
const personal = usePersonalContext();
|
||||
const { isPro, expiresAt, proof } = useSasquatchPro();
|
||||
|
|
@ -849,7 +850,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
|
|||
const answerNearestSpots = useCallback(async (): Promise<boolean> => {
|
||||
let loc = getLastLocation();
|
||||
try {
|
||||
setStreamingText('📍 Getting your location…');
|
||||
setStreamingText(t('sasquatchUi.gettingLocation'));
|
||||
const fix = await getCurrentPosition();
|
||||
loc = { lat: fix.lat, lng: fix.lng, at: Date.now() };
|
||||
saveLastLocation(fix.lat, fix.lng);
|
||||
|
|
@ -868,7 +869,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
|
|||
}
|
||||
|
||||
try {
|
||||
setStreamingText('🔍 Finding the closest spots…');
|
||||
setStreamingText(t('sasquatchUi.findingClosest'));
|
||||
const res = await cvm.findSpots({ limit: 500 });
|
||||
const nearest = (res.spots ?? [])
|
||||
.map((s) => {
|
||||
|
|
@ -910,7 +911,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
|
|||
setStreamingText('');
|
||||
return false;
|
||||
}
|
||||
}, [cvm, appendMessage]);
|
||||
}, [cvm, appendMessage, t]);
|
||||
|
||||
// ── Core: handle a user question ───────────────────────────────────────────
|
||||
|
||||
|
|
@ -990,7 +991,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
|
|||
const fastIntent = isPersonalQuery ? null : parseLocalIntent(question);
|
||||
if (fastIntent && fastIntent.tool !== 'plan_trip') {
|
||||
try {
|
||||
setStreamingText('🔍 Checking live data…');
|
||||
setStreamingText(t('sasquatchUi.checkingLive'));
|
||||
let fastResult: unknown;
|
||||
switch (fastIntent.tool) {
|
||||
case 'find_spots':
|
||||
|
|
@ -1208,7 +1209,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
|
|||
timestamp: Date.now(),
|
||||
});
|
||||
} else {
|
||||
setStreamingText(`🗺️ Planning your ${destination} trip...`);
|
||||
setStreamingText(t('sasquatchUi.planningTrip', { destination }));
|
||||
try {
|
||||
const itinerary = await runTripPlanner(destination, typeof a.days === 'number' ? a.days : 3);
|
||||
setStreamingText('');
|
||||
|
|
@ -1244,7 +1245,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
|
|||
|
||||
if (toolCall) {
|
||||
const { tool: toolName, args: toolArgs } = toolCall;
|
||||
setStreamingText(`🔍 Searching the relays for ${toolName.replace(/_/g, ' ')}...`);
|
||||
setStreamingText(t('sasquatchUi.searchingRelays', { tool: toolName.replace(/_/g, ' ') }));
|
||||
|
||||
try {
|
||||
// Query the Nostr relays directly (useCVMClient) — aggregates across
|
||||
|
|
@ -1394,7 +1395,7 @@ Instructions:
|
|||
// Trip planning gets its own rich-card path (not a text blob).
|
||||
if (intent && intent.tool === 'plan_trip' && intent.destination) {
|
||||
try {
|
||||
setStreamingText(`🗺️ Planning your ${intent.destination} trip...`);
|
||||
setStreamingText(t('sasquatchUi.planningTrip', { destination: intent.destination }));
|
||||
const itinerary = await runTripPlanner(intent.destination, intent.days);
|
||||
setStreamingText('');
|
||||
if (itinerary.empty) {
|
||||
|
|
@ -1421,7 +1422,7 @@ Instructions:
|
|||
|
||||
if (intent && intent.tool !== 'plan_trip') {
|
||||
try {
|
||||
setStreamingText('🔍 Checking live data...');
|
||||
setStreamingText(t('sasquatchUi.checkingLive'));
|
||||
let localResult: unknown;
|
||||
switch (intent.tool) {
|
||||
case 'find_spots':
|
||||
|
|
@ -1488,7 +1489,7 @@ Instructions:
|
|||
setIsThinking(false);
|
||||
setStreamingText('');
|
||||
}
|
||||
}, [userInput, isThinking, chatMessages, cvm, personal, isPro, proof, user, appendMessage, runTripPlanner, answerNearestSpots, language]);
|
||||
}, [userInput, isThinking, chatMessages, cvm, personal, isPro, proof, user, appendMessage, runTripPlanner, answerNearestSpots, language, t]);
|
||||
|
||||
// Listen for external events from FAQ/About pages
|
||||
useEffect(() => {
|
||||
|
|
@ -1543,18 +1544,18 @@ Instructions:
|
|||
// Build a fallback generic message for pages with no specific context
|
||||
const activeMessage = message ?? {
|
||||
id: 'generic',
|
||||
title: 'SkateSpots Assistant 🦧',
|
||||
message: "Yo! I'm The Sasquatch. Ask me anything about spots, events, coaches, gear, or how the app works. I've got live data from the SkateSpots relay!",
|
||||
title: t('sasquatchUi.genericTitle'),
|
||||
message: t('sasquatchUi.genericGreeting'),
|
||||
context: undefined,
|
||||
suggestedQuestions: [
|
||||
'Plan me 3 days in Lisbon',
|
||||
'Find spots near me',
|
||||
'Any upcoming events?',
|
||||
'What are crews?',
|
||||
'How do zaps work?',
|
||||
'What is Nostr?',
|
||||
'What is My Stash?',
|
||||
'Is this free?',
|
||||
t('sasquatchUi.q1'),
|
||||
t('sasquatchUi.q2'),
|
||||
t('sasquatchUi.q3'),
|
||||
t('sasquatchUi.q4'),
|
||||
t('sasquatchUi.q5'),
|
||||
t('sasquatchUi.q6'),
|
||||
t('sasquatchUi.q7'),
|
||||
t('sasquatchUi.q8'),
|
||||
],
|
||||
enableChat: true,
|
||||
};
|
||||
|
|
@ -1577,7 +1578,7 @@ Instructions:
|
|||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onClick={onBubbleClick}
|
||||
title="Press and drag to move · Tap to open"
|
||||
title={t('sasquatchUi.bubbleTitle')}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
|
|
@ -1588,7 +1589,7 @@ Instructions:
|
|||
background: 'radial-gradient(circle at 40% 35%, #2d4a1e 0%, #1a2e0f 50%, #0d1a07 100%)',
|
||||
boxShadow: '0 0 20px rgba(204,255,0,0.2), 0 4px 20px rgba(0,0,0,0.8)',
|
||||
}}
|
||||
aria-label="Open Sasquatch guide"
|
||||
aria-label={t('sasquatchUi.openGuideAria')}
|
||||
>
|
||||
<img
|
||||
src="/images/sasquatch-guide.webp"
|
||||
|
|
@ -1655,7 +1656,7 @@ Instructions:
|
|||
{isPro ? (
|
||||
<span
|
||||
className="flex items-center gap-0.5 rounded bg-[#ccff00]/20 px-1 py-0.5 text-[8px] font-black uppercase tracking-wider text-[#ccff00]"
|
||||
title={expiresAt ? `Active until ${new Date(expiresAt * 1000).toLocaleDateString()}` : 'Platform owner'}
|
||||
title={expiresAt ? t('sasquatchUi.activeUntil', { date: new Date(expiresAt * 1000).toLocaleDateString() }) : t('sasquatchUi.platformOwner')}
|
||||
>
|
||||
⚡ Pro{proDaysLeft !== null ? ` · ${proDaysLeft}d` : ''}
|
||||
</span>
|
||||
|
|
@ -1663,10 +1664,10 @@ Instructions:
|
|||
<button
|
||||
onClick={() => setProDialogOpen(true)}
|
||||
className="ml-0.5 inline-flex items-center gap-1 rounded-full border border-[#ccff00]/40 bg-[#ccff00]/10 px-2 py-0.5 text-[9px] font-black uppercase tracking-wider text-[#ccff00] transition-colors hover:border-[#ccff00]/70 hover:bg-[#ccff00]/20"
|
||||
title="Upgrade to Sasquatch Pro"
|
||||
title={t('sasquatchUi.upgradeTitle')}
|
||||
>
|
||||
<span aria-hidden>⚡</span>
|
||||
Go Pro
|
||||
{t('sasquatchUi.goPro')}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -1681,9 +1682,9 @@ Instructions:
|
|||
'ml-auto flex-shrink-0 rounded px-1 py-0.5 text-[8px] font-black uppercase tracking-wider',
|
||||
aiStatus.data.low ? 'bg-amber-500/20 text-amber-400' : 'bg-emerald-500/15 text-emerald-400',
|
||||
)}
|
||||
title={`Sasquatch AI credit balance (owner-only) — top up at ${aiStatus.data.provider ?? 'the node'}`}
|
||||
title={t('sasquatchUi.aiBalanceTitle', { provider: aiStatus.data.provider ?? t('sasquatchUi.theNode') })}
|
||||
>
|
||||
⚡ AI {aiStatus.data.balanceSats.toLocaleString()}
|
||||
{t('sasquatchUi.aiChip', { balance: aiStatus.data.balanceSats.toLocaleString() })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1693,8 +1694,8 @@ Instructions:
|
|||
<button
|
||||
onClick={() => { if (recentlyOpened()) return; setIsMinimized(true); }}
|
||||
className="w-6 h-6 flex items-center justify-center rounded text-[#ccff00]/40 hover:text-[#ccff00] hover:bg-[#ccff00]/10 transition-colors"
|
||||
aria-label="Minimize to bubble"
|
||||
title="Minimize"
|
||||
aria-label={t('sasquatchUi.minimizeToBubbleAria')}
|
||||
title={t('sasquatchUi.minimize')}
|
||||
>
|
||||
<Minimize2 className="h-3 w-3" />
|
||||
</button>
|
||||
|
|
@ -1702,8 +1703,8 @@ Instructions:
|
|||
<button
|
||||
onClick={() => { if (recentlyOpened()) return; setIsVisible(false); if (onDismiss) onDismiss(); }}
|
||||
className="w-6 h-6 flex items-center justify-center rounded text-white/30 hover:text-white/80 hover:bg-white/10 transition-colors"
|
||||
aria-label="Close panel"
|
||||
title="Close"
|
||||
aria-label={t('sasquatchUi.closePanelAria')}
|
||||
title={t('sasquatchUi.close')}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
|
|
@ -1740,10 +1741,10 @@ Instructions:
|
|||
className="w-full py-2 px-3 rounded-lg text-xs font-bold text-[#ccff00] border border-[#ccff00]/30 hover:border-[#ccff00]/60 hover:bg-[#ccff00]/5 transition-all flex items-center justify-center gap-1.5"
|
||||
>
|
||||
<HelpCircle className="h-3 w-3" />
|
||||
{activeMessage.context === 'lab' && 'Ask about Coaching & Filming'}
|
||||
{activeMessage.context === 'map' && 'Ask about Spots, Shops & Gear'}
|
||||
{(activeMessage.context === 'home' || activeMessage.context === 'onboarding') && 'Ask me anything 🛹'}
|
||||
{(!activeMessage.context || (activeMessage.context !== 'lab' && activeMessage.context !== 'map' && activeMessage.context !== 'home' && activeMessage.context !== 'onboarding')) && 'Ask me anything 🛹'}
|
||||
{activeMessage.context === 'lab' && t('sasquatchUi.askLab')}
|
||||
{activeMessage.context === 'map' && t('sasquatchUi.askMap')}
|
||||
{(activeMessage.context === 'home' || activeMessage.context === 'onboarding') && t('sasquatchUi.askAnything')}
|
||||
{(!activeMessage.context || (activeMessage.context !== 'lab' && activeMessage.context !== 'map' && activeMessage.context !== 'home' && activeMessage.context !== 'onboarding')) && t('sasquatchUi.askAnything')}
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-2.5">
|
||||
|
|
@ -1752,7 +1753,7 @@ Instructions:
|
|||
{chatMessages.length === 0 && activeMessage.suggestedQuestions && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-[9px] text-[#ccff00]/50 font-bold uppercase tracking-wider mb-1.5">
|
||||
Quick questions:
|
||||
{t('sasquatchUi.quickQuestions')}
|
||||
</div>
|
||||
{activeMessage.suggestedQuestions.map((q) => (
|
||||
<button
|
||||
|
|
@ -1782,10 +1783,10 @@ Instructions:
|
|||
)}
|
||||
>
|
||||
<div className="font-bold text-[9px] mb-1 flex items-center gap-1 opacity-60">
|
||||
{msg.role === 'user' ? '🧑 You' : '🦧 Sasquatch'}
|
||||
{msg.role === 'user' ? t('sasquatchUi.you') : '🦧 Sasquatch'}
|
||||
{msg.isLive && (
|
||||
<span className="text-[#ccff00] bg-[#ccff00]/10 px-1 rounded text-[8px]">
|
||||
live
|
||||
{t('sasquatchUi.live')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1814,7 +1815,7 @@ Instructions:
|
|||
) : (
|
||||
<span className="flex items-center gap-1.5 text-[#ccff00]/60">
|
||||
<Loader2 className="h-2.5 w-2.5 animate-spin" />
|
||||
<span className="text-[10px]">thinking...</span>
|
||||
<span className="text-[10px]">{t('sasquatchUi.thinking')}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1829,7 +1830,7 @@ Instructions:
|
|||
value={userInput}
|
||||
onChange={(e) => setUserInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && !inputDisabled && handleSendMessage()}
|
||||
placeholder="Ask about spots, events, coaches..."
|
||||
placeholder={t('sasquatchUi.inputPlaceholder')}
|
||||
disabled={inputDisabled}
|
||||
className="h-10 text-sm bg-black/40 border-[#ccff00]/20 text-white placeholder:text-gray-600 focus-visible:ring-[#ccff00]/40 focus-visible:border-[#ccff00]/40"
|
||||
/>
|
||||
|
|
@ -1850,14 +1851,14 @@ Instructions:
|
|||
<div className="flex items-center justify-between">
|
||||
<p className="text-[9px] text-gray-600 flex items-center gap-1.5 flex-wrap min-w-0">
|
||||
<CVMStatus connected={cvmConnected} />
|
||||
Live data from{' '}
|
||||
{t('sasquatchUi.liveDataFrom')}{' '}
|
||||
<a
|
||||
href="https://goskatespots.nostr1.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[#ccff00]/60 hover:text-[#ccff00] underline underline-offset-1 transition-colors"
|
||||
>
|
||||
Nostr relays
|
||||
{t('sasquatchUi.nostrRelays')}
|
||||
</a>
|
||||
</p>
|
||||
{chatMessages.length > 0 && (
|
||||
|
|
@ -1865,7 +1866,7 @@ Instructions:
|
|||
onClick={() => { setChatMessages([]); setShowChat(false); setCvmConnected(null); }}
|
||||
className="text-[9px] text-gray-600 hover:text-[#ccff00] transition-colors flex-shrink-0"
|
||||
>
|
||||
clear
|
||||
{t('sasquatchUi.clear')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { usePurchasePro } from '@/hooks/usePurchasePro';
|
|||
import { useExchangeRate } from '@/hooks/useExchangeRate';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { PRO_TIERS, PRO_LIGHTNING_ADDRESS, type ProTier } from '@/lib/sasquatchPro';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import {
|
||||
Zap,
|
||||
Check,
|
||||
|
|
@ -34,11 +35,11 @@ import {
|
|||
BadgeCheck,
|
||||
} from 'lucide-react';
|
||||
|
||||
const PERKS: Array<{ icon: React.ComponentType<{ className?: string }>; text: string }> = [
|
||||
{ icon: Brain, text: 'Personal assistant — Sasquatch reads your own Stash, crews, spots & listings' },
|
||||
{ icon: Wand2, text: 'Action drafts: list gear, post a skate status, plan a trip — you confirm before anything publishes' },
|
||||
{ icon: Rocket, text: 'Answers use your personal context, not just public data' },
|
||||
{ icon: BadgeCheck, text: '⚡ Directly support SkateSpots & keep it free and ad-free' },
|
||||
const PERKS: Array<{ icon: React.ComponentType<{ className?: string }>; textKey: string }> = [
|
||||
{ icon: Brain, textKey: 'sasquatchUi.perk1' },
|
||||
{ icon: Wand2, textKey: 'sasquatchUi.perk2' },
|
||||
{ icon: Rocket, textKey: 'sasquatchUi.perk3' },
|
||||
{ icon: BadgeCheck, textKey: 'sasquatchUi.perk4' },
|
||||
];
|
||||
|
||||
export function SasquatchProDialog({
|
||||
|
|
@ -49,6 +50,7 @@ export function SasquatchProDialog({
|
|||
onOpenChange: (v: boolean) => void;
|
||||
}) {
|
||||
const { user } = useCurrentUser();
|
||||
const { t } = useTranslation();
|
||||
const { isPro, expiresAt } = useSasquatchPro();
|
||||
const { toast } = useToast();
|
||||
const purchase = usePurchasePro();
|
||||
|
|
@ -74,16 +76,16 @@ export function SasquatchProDialog({
|
|||
// Toast + close on success.
|
||||
useEffect(() => {
|
||||
if (purchase.state === 'paid') {
|
||||
toast({ title: 'Welcome to Sasquatch Pro! 🦧⚡', description: 'Your pass is active — a receipt just landed in your DMs. Thanks for keeping the streets free.' });
|
||||
const t = setTimeout(() => { onOpenChange(false); purchase.reset(); }, 1400);
|
||||
return () => clearTimeout(t);
|
||||
toast({ title: t('sasquatchUi.welcomeTitle'), description: t('sasquatchUi.welcomeDesc') });
|
||||
const timer = setTimeout(() => { onOpenChange(false); purchase.reset(); }, 1400);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [purchase.state, toast, onOpenChange, purchase]);
|
||||
}, [purchase.state, toast, onOpenChange, purchase, t]);
|
||||
|
||||
const copyInvoice = () => {
|
||||
if (purchase.invoice) {
|
||||
navigator.clipboard.writeText(purchase.invoice);
|
||||
toast({ title: 'Invoice copied' });
|
||||
toast({ title: t('sasquatchUi.invoiceCopied') });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -98,7 +100,7 @@ export function SasquatchProDialog({
|
|||
<span className="text-2xl">🦧</span> Sasquatch <span className="text-red-500">Pro</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-gray-400">
|
||||
Supercharge your AI skate guide. 100% funds a free, no-ads community app.
|
||||
{t('sasquatchUi.dialogDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
@ -107,23 +109,23 @@ export function SasquatchProDialog({
|
|||
<div className="space-y-4 py-2">
|
||||
<div className="rounded-2xl border border-[#ccff00]/30 bg-[#ccff00]/10 p-4 text-center">
|
||||
<BadgeCheck className="mx-auto mb-2 h-8 w-8 text-[#ccff00]" />
|
||||
<p className="font-black text-[#ccff00]">You're a Pro supporter ⚡</p>
|
||||
<p className="font-black text-[#ccff00]">{t('sasquatchUi.proSupporter')}</p>
|
||||
{expiresAt && (
|
||||
<p className="mt-1 text-xs text-gray-300">
|
||||
Active until {new Date(expiresAt * 1000).toLocaleDateString()}
|
||||
{t('sasquatchUi.activeUntil', { date: new Date(expiresAt * 1000).toLocaleDateString() })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{PERKS.map((p) => (
|
||||
<div key={p.text} className="flex items-start gap-2 text-sm text-gray-300">
|
||||
<div key={p.textKey} className="flex items-start gap-2 text-sm text-gray-300">
|
||||
<Check className="mt-0.5 h-4 w-4 shrink-0 text-[#ccff00]" />
|
||||
{p.text}
|
||||
{t(p.textKey)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-center text-xs text-gray-500">
|
||||
Want to extend? Pick a tier below to add more time.
|
||||
{t('sasquatchUi.extendPrompt')}
|
||||
</p>
|
||||
<TierPicker selected={selected} onSelect={setSelected} />
|
||||
<PayButton tier={selected} busy={busy} onPay={() => purchase.purchase(selected)} extend />
|
||||
|
|
@ -132,26 +134,28 @@ export function SasquatchProDialog({
|
|||
// ── Manual QR payment ───────────────────────────────────────────
|
||||
<div className="space-y-4 py-2 text-center">
|
||||
<p className="text-sm text-gray-300">
|
||||
Scan to pay <span className="font-black text-white">{selected.sats.toLocaleString()} sats</span> for {selected.label} of Pro
|
||||
{t('sasquatchUi.scanToPayPre')}{' '}
|
||||
<span className="font-black text-white">{t('sasquatchUi.scanToPayAmount', { sats: selected.sats.toLocaleString() })}</span>{' '}
|
||||
{t('sasquatchUi.scanToPayPost', { label: selected.label })}
|
||||
</p>
|
||||
{qr && <img src={qr} alt="Lightning invoice QR" className="mx-auto rounded-xl bg-white p-2" width={240} height={240} />}
|
||||
{qr && <img src={qr} alt={t('sasquatchUi.invoiceQrAlt')} className="mx-auto rounded-xl bg-white p-2" width={240} height={240} />}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" className="flex-1 gap-2 border-white/15" onClick={copyInvoice}>
|
||||
<Copy className="h-4 w-4" /> Copy invoice
|
||||
<Copy className="h-4 w-4" /> {t('sasquatchUi.copyInvoice')}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 gap-2 bg-[#ccff00] text-black hover:bg-[#d4ff1a] font-bold"
|
||||
onClick={() => purchase.confirmManual(selected)}
|
||||
>
|
||||
<Check className="h-4 w-4" /> I've paid
|
||||
<Check className="h-4 w-4" /> {t('sasquatchUi.ivePaid')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-400">
|
||||
Paying to{' '}
|
||||
{t('sasquatchUi.paysTo')}{' '}
|
||||
<span className="font-mono text-gray-200">⚡ {PRO_LIGHTNING_ADDRESS}</span>
|
||||
</p>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
Tap "I've paid" after your wallet confirms — this records your pass to your Nostr identity.
|
||||
{t('sasquatchUi.ivePaidHint')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -159,22 +163,21 @@ export function SasquatchProDialog({
|
|||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
{PERKS.map((p) => (
|
||||
<div key={p.text} className="flex items-start gap-2.5 text-sm text-gray-200">
|
||||
<div key={p.textKey} className="flex items-start gap-2.5 text-sm text-gray-200">
|
||||
<p.icon className="mt-0.5 h-4 w-4 shrink-0 text-red-400" />
|
||||
{p.text}
|
||||
{t(p.textKey)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-white/10 bg-white/[0.03] p-3 text-xs text-gray-400">
|
||||
Free Sasquatch stays free — public spot/event/coach search, trip planning
|
||||
+ the full trick glossary. Pro adds the <span className="text-white font-semibold">personal + action</span> layer.
|
||||
{t('sasquatchUi.freeStaysPre')}<span className="text-white font-semibold">{t('sasquatchUi.freeStaysEmphasis')}</span>{t('sasquatchUi.freeStaysPost')}
|
||||
</div>
|
||||
|
||||
<TierPicker selected={selected} onSelect={setSelected} />
|
||||
|
||||
{!user && (
|
||||
<p className="text-center text-xs text-amber-400">Log in first to upgrade.</p>
|
||||
<p className="text-center text-xs text-amber-400">{t('sasquatchUi.loginFirst')}</p>
|
||||
)}
|
||||
{purchase.error && (
|
||||
<p className="text-center text-xs text-red-400">{purchase.error}</p>
|
||||
|
|
@ -183,7 +186,7 @@ export function SasquatchProDialog({
|
|||
<PayButton tier={selected} busy={busy} onPay={() => purchase.purchase(selected)} disabled={!user} />
|
||||
|
||||
<p className="text-center text-[11px] text-gray-500">
|
||||
Pays to <span className="font-mono text-gray-300">⚡ {PRO_LIGHTNING_ADDRESS}</span> over Lightning. Bound to your npub.
|
||||
{t('sasquatchUi.paysTo')} <span className="font-mono text-gray-300">⚡ {PRO_LIGHTNING_ADDRESS}</span> {t('sasquatchUi.overLightning')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -195,15 +198,16 @@ export function SasquatchProDialog({
|
|||
function TierPicker({ selected, onSelect }: { selected: ProTier; onSelect: (t: ProTier) => void }) {
|
||||
// Live BTC/USD so the fiat hint never goes stale (it used to be hardcoded).
|
||||
const { data: btcUsd } = useExchangeRate();
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{PRO_TIERS.map((t) => {
|
||||
const active = t.id === selected.id;
|
||||
const usd = btcUsd ? `~$${((t.sats / 1e8) * btcUsd).toFixed(2)}` : '';
|
||||
{PRO_TIERS.map((tier) => {
|
||||
const active = tier.id === selected.id;
|
||||
const usd = btcUsd ? `~$${((tier.sats / 1e8) * btcUsd).toFixed(2)}` : '';
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => onSelect(t)}
|
||||
key={tier.id}
|
||||
onClick={() => onSelect(tier)}
|
||||
className={cn(
|
||||
'relative rounded-xl border p-3 text-left transition-all',
|
||||
active
|
||||
|
|
@ -211,14 +215,14 @@ function TierPicker({ selected, onSelect }: { selected: ProTier; onSelect: (t: P
|
|||
: 'border-white/10 bg-white/[0.02] hover:border-white/25',
|
||||
)}
|
||||
>
|
||||
{t.popular && (
|
||||
{tier.popular && (
|
||||
<span className="absolute -top-2 right-2 rounded-full bg-red-500 px-1.5 py-0.5 text-[8px] font-black uppercase text-white">
|
||||
Popular
|
||||
{t('sasquatchUi.popular')}
|
||||
</span>
|
||||
)}
|
||||
<p className="text-sm font-black text-white">{t.label}</p>
|
||||
<p className="text-lg font-black text-red-400">{t.sats.toLocaleString()}<span className="ml-0.5 text-xs text-gray-500">sats</span></p>
|
||||
<p className="text-[10px] text-gray-500">{[usd, t.badge].filter(Boolean).join(' · ')}</p>
|
||||
<p className="text-sm font-black text-white">{tier.label}</p>
|
||||
<p className="text-lg font-black text-red-400">{tier.sats.toLocaleString()}<span className="ml-0.5 text-xs text-gray-500">{t('sasquatchUi.sats')}</span></p>
|
||||
<p className="text-[10px] text-gray-500">{[usd, tier.badge].filter(Boolean).join(' · ')}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
|
@ -239,6 +243,7 @@ function PayButton({
|
|||
extend?: boolean;
|
||||
onPay: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Button
|
||||
className="w-full gap-2 bg-red-500 hover:bg-red-600 font-black uppercase tracking-wide h-12"
|
||||
|
|
@ -246,7 +251,7 @@ function PayButton({
|
|||
disabled={busy || disabled}
|
||||
>
|
||||
{busy ? <Loader2 className="h-5 w-5 animate-spin" /> : <Zap className="h-5 w-5" />}
|
||||
{extend ? 'Extend' : 'Go Pro'} · {tier.sats.toLocaleString()} sats
|
||||
{extend ? t('sasquatchUi.extend') : t('sasquatchUi.goPro')} · {tier.sats.toLocaleString()} {t('sasquatchUi.sats')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
/**
|
||||
* Branded cold-start splash — a choreographed, premium launch moment.
|
||||
*
|
||||
|
|
@ -27,14 +29,15 @@ const MAX_MS = 2600; // hard cap — never block on a slow/offline network
|
|||
const FADE_MS = 480;
|
||||
const READY_HOLD_MS = 340; // savour the 100% "ready" beat before revealing
|
||||
|
||||
const STATUS_LINES = [
|
||||
'Waking the network',
|
||||
'Tuning the relays',
|
||||
'Pulling in spots',
|
||||
'Dropping in',
|
||||
const STATUS_KEYS = [
|
||||
'bootUi.statusWaking',
|
||||
'bootUi.statusTuning',
|
||||
'bootUi.statusPulling',
|
||||
'bootUi.statusDropping',
|
||||
];
|
||||
|
||||
export function SplashScreen() {
|
||||
const { t } = useTranslation();
|
||||
const [visible, setVisible] = useState(() => {
|
||||
try {
|
||||
return sessionStorage.getItem(SESSION_KEY) !== 'true';
|
||||
|
|
@ -58,7 +61,7 @@ export function SplashScreen() {
|
|||
useEffect(() => {
|
||||
if (!visible || ready) return;
|
||||
const id = setInterval(() => {
|
||||
setStatusIndex((i) => (i + 1) % STATUS_LINES.length);
|
||||
setStatusIndex((i) => (i + 1) % STATUS_KEYS.length);
|
||||
}, 640);
|
||||
return () => clearInterval(id);
|
||||
}, [visible, ready]);
|
||||
|
|
@ -149,7 +152,7 @@ export function SplashScreen() {
|
|||
}}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label={ready ? 'SkateSpots ready' : 'Loading SkateSpots'}
|
||||
aria-label={ready ? t('bootUi.ariaReady') : t('bootUi.ariaLoading')}
|
||||
>
|
||||
{/* Fine grid texture — subtle depth, like a skate map underlay */}
|
||||
<div
|
||||
|
|
@ -268,7 +271,7 @@ export function SplashScreen() {
|
|||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
Ready
|
||||
{t('bootUi.ready')}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
|
|
@ -276,7 +279,7 @@ export function SplashScreen() {
|
|||
className="font-mono text-[11px] uppercase tracking-[0.28em] text-gray-500"
|
||||
style={anim('splashFade 360ms ease-out')}
|
||||
>
|
||||
{STATUS_LINES[statusIndex]}
|
||||
{t(STATUS_KEYS[statusIndex])}
|
||||
{!prefersReducedMotion && <span className="ml-0.5 animate-pulse">…</span>}
|
||||
</span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
} from '@/lib/routing';
|
||||
import { transitLegColor, transitModeEmoji } from '@/lib/transitUi';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
interface StopDeparturesProps {
|
||||
open: boolean;
|
||||
|
|
@ -29,6 +30,7 @@ interface StopDeparturesProps {
|
|||
* schedule data, never invented, and says so when nothing comes back.
|
||||
*/
|
||||
export function StopDepartures({ open, onOpenChange, stopId, stopName }: StopDeparturesProps) {
|
||||
const { t } = useTranslation();
|
||||
const [rows, setRows] = useState<StopDeparture[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
|
|
@ -47,7 +49,7 @@ export function StopDepartures({ open, onOpenChange, stopId, stopName }: StopDep
|
|||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return;
|
||||
setError("Couldn't reach the transit service. Check your connection and try again.");
|
||||
setError(t('routeUi.errTransitUnreachable'));
|
||||
}
|
||||
})();
|
||||
|
||||
|
|
@ -55,7 +57,7 @@ export function StopDepartures({ open, onOpenChange, stopId, stopName }: StopDep
|
|||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [open, stopId, reloadKey]);
|
||||
}, [open, stopId, reloadKey, t]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
|
|
@ -63,12 +65,12 @@ export function StopDepartures({ open, onOpenChange, stopId, stopName }: StopDep
|
|||
<DialogHeader className="border-b px-4 py-3">
|
||||
<DialogTitle className="text-base">
|
||||
<span className="block text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
Departures
|
||||
{t('routeUi.departures')}
|
||||
</span>
|
||||
<span className="block truncate">{stopName}</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Upcoming departures from {stopName}
|
||||
{t('routeUi.upcomingFrom', { stop: stopName })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
@ -77,11 +79,11 @@ export function StopDepartures({ open, onOpenChange, stopId, stopName }: StopDep
|
|||
<div className="p-4 text-sm text-amber-500">{error}</div>
|
||||
) : !rows ? (
|
||||
<div className="flex items-center justify-center gap-2 p-6 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Loading departures…
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> {t('routeUi.loadingDepartures')}
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="p-6 text-center text-sm text-muted-foreground">
|
||||
No upcoming departures found at this stop.
|
||||
{t('routeUi.noDepartures')}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
|
|
@ -112,12 +114,12 @@ export function StopDepartures({ open, onOpenChange, stopId, stopName }: StopDep
|
|||
</span>
|
||||
{d.realTime && !d.cancelled && (
|
||||
<span className="shrink-0 rounded bg-[#00ff88]/15 px-1 text-[9px] font-black uppercase text-[#00ff88]">
|
||||
live
|
||||
{t('routeUi.live')}
|
||||
</span>
|
||||
)}
|
||||
{d.cancelled && (
|
||||
<span className="shrink-0 rounded bg-red-500/15 px-1 text-[9px] font-black uppercase text-red-400">
|
||||
cancelled
|
||||
{t('routeUi.cancelled')}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
|
|
@ -133,7 +135,7 @@ export function StopDepartures({ open, onOpenChange, stopId, stopName }: StopDep
|
|||
onClick={() => setReloadKey((k) => k + 1)}
|
||||
className="flex min-h-0 items-center gap-1 rounded-full border border-white/10 px-2 py-1 text-[10px] font-bold uppercase tracking-wide text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" /> Refresh
|
||||
<RefreshCw className="h-3 w-3" /> {t('routeUi.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { travelLinks } from '@/lib/tripPlanner';
|
|||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useCreateSpotCollection } from '@/hooks/useSpotCollections';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const KIND_ICON = {
|
||||
|
|
@ -23,10 +24,18 @@ const KIND_ICON = {
|
|||
coach: GraduationCap,
|
||||
} as const;
|
||||
|
||||
/** Travel-link labels live in `lib/tripPlanner`; translate at render. */
|
||||
const TRAVEL_LINK_KEYS: Record<string, string> = {
|
||||
Flights: 'routeUi.flights',
|
||||
Stays: 'routeUi.stays',
|
||||
'Getting around': 'routeUi.gettingAround',
|
||||
};
|
||||
|
||||
export function TripItineraryCard({ itinerary }: { itinerary: Itinerary }) {
|
||||
const { user } = useCurrentUser();
|
||||
const { createCollection, isPending } = useCreateSpotCollection();
|
||||
const { toast } = useToast();
|
||||
const { t } = useTranslation();
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const w = itinerary.weather;
|
||||
|
|
@ -34,21 +43,27 @@ export function TripItineraryCard({ itinerary }: { itinerary: Itinerary }) {
|
|||
|
||||
const handleSave = async () => {
|
||||
if (!user) {
|
||||
toast({ title: 'Log in to save', description: 'Sign in to keep this trip as a Collection.' });
|
||||
toast({ title: t('routeUi.tripLoginTitle'), description: t('routeUi.tripLoginDesc') });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const dayCount = itinerary.days.length;
|
||||
await createCollection({
|
||||
title: `${itinerary.destination} skate trip 🛹`,
|
||||
description: `Trip plan by The Sasquatch — ${itinerary.days.length} days in ${itinerary.destination}.`,
|
||||
title: t('routeUi.tripCollectionTitle', { destination: itinerary.destination }),
|
||||
description: dayCount === 1
|
||||
? t('routeUi.tripCollectionDescOne', { count: dayCount, destination: itinerary.destination })
|
||||
: t('routeUi.tripCollectionDesc', { count: dayCount, destination: itinerary.destination }),
|
||||
spotRefs: itinerary.allSpotRefs,
|
||||
});
|
||||
setSaved(true);
|
||||
toast({ title: 'Saved to Collections 🦧', description: `Your ${itinerary.destination} trip is bookmarked.` });
|
||||
toast({
|
||||
title: t('routeUi.tripSavedTitle'),
|
||||
description: t('routeUi.tripSavedDesc', { destination: itinerary.destination }),
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Couldn't save",
|
||||
description: err instanceof Error ? err.message : 'Try again in a sec.',
|
||||
title: t('routeUi.tripSaveFailed'),
|
||||
description: err instanceof Error ? err.message : t('routeUi.tripSaveFailedDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -64,7 +79,9 @@ export function TripItineraryCard({ itinerary }: { itinerary: Itinerary }) {
|
|||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[13px]">🛹</span>
|
||||
<span className="font-black text-[11px] text-[#ccff00] uppercase tracking-wider truncate">
|
||||
{itinerary.days.length} days in {itinerary.destination}
|
||||
{itinerary.days.length === 1
|
||||
? t('routeUi.daysInDestinationOne', { count: itinerary.days.length, destination: itinerary.destination })
|
||||
: t('routeUi.daysInDestination', { count: itinerary.days.length, destination: itinerary.destination })}
|
||||
</span>
|
||||
</div>
|
||||
{w && (
|
||||
|
|
@ -82,7 +99,7 @@ export function TripItineraryCard({ itinerary }: { itinerary: Itinerary }) {
|
|||
{rainy && (
|
||||
<div className="mt-1 flex items-start gap-1 text-[9px] text-orange-300/90">
|
||||
<CloudRain className="h-2.5 w-2.5 mt-0.5 flex-shrink-0" />
|
||||
<span>Wet forecast — hit a local crew for covered/indoor options.</span>
|
||||
<span>{t('routeUi.wetForecast')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -92,10 +109,10 @@ export function TripItineraryCard({ itinerary }: { itinerary: Itinerary }) {
|
|||
{itinerary.days.map((day) => (
|
||||
<div key={day.dayNumber} className="px-2.5 py-2">
|
||||
<div className="text-[9px] font-black text-[#ccff00]/70 uppercase tracking-widest mb-1">
|
||||
📅 {day.label}
|
||||
📅 {t('routeUi.dayLabel', { n: day.dayNumber })}
|
||||
</div>
|
||||
{day.stops.length === 0 ? (
|
||||
<div className="text-[9px] text-gray-500 italic">Free skate — explore & film. 🎥</div>
|
||||
<div className="text-[9px] text-gray-500 italic">{t('routeUi.freeSkate')}</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{day.stops.map((stop, i) => {
|
||||
|
|
@ -109,7 +126,7 @@ export function TripItineraryCard({ itinerary }: { itinerary: Itinerary }) {
|
|||
<div className="text-[8px] text-gray-500 truncate">{stop.subtitle}</div>
|
||||
)}
|
||||
{stop.kind === 'coach' && (
|
||||
<div className="text-[8px] text-[#ccff00]/70">tap to book / zap ⚡</div>
|
||||
<div className="text-[8px] text-[#ccff00]/70">{t('routeUi.tapToBook')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -138,7 +155,7 @@ export function TripItineraryCard({ itinerary }: { itinerary: Itinerary }) {
|
|||
{itinerary.crews.length > 0 && (
|
||||
<div className="px-2.5 py-2 border-t border-white/5">
|
||||
<div className="text-[9px] font-black text-[#ccff00]/70 uppercase tracking-widest mb-1 flex items-center gap-1">
|
||||
<Users className="h-2.5 w-2.5" /> Local crews
|
||||
<Users className="h-2.5 w-2.5" /> {t('routeUi.localCrews')}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{itinerary.crews.map((c, i) => (
|
||||
|
|
@ -170,11 +187,13 @@ export function TripItineraryCard({ itinerary }: { itinerary: Itinerary }) {
|
|||
)}
|
||||
>
|
||||
{isPending ? (
|
||||
<><Loader2 className="h-3 w-3 animate-spin" /> Saving…</>
|
||||
<><Loader2 className="h-3 w-3 animate-spin" /> {t('routeUi.saving')}</>
|
||||
) : saved ? (
|
||||
<><Check className="h-3 w-3" /> Saved to Collections</>
|
||||
<><Check className="h-3 w-3" /> {t('routeUi.savedToCollections')}</>
|
||||
) : (
|
||||
<><Bookmark className="h-3 w-3" /> Save {itinerary.allSpotRefs.length} spots to a Collection</>
|
||||
<><Bookmark className="h-3 w-3" /> {itinerary.allSpotRefs.length === 1
|
||||
? t('routeUi.saveSpotOne', { count: itinerary.allSpotRefs.length })
|
||||
: t('routeUi.saveSpots', { count: itinerary.allSpotRefs.length })}</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
|
@ -190,12 +209,12 @@ export function TripItineraryCard({ itinerary }: { itinerary: Itinerary }) {
|
|||
className="flex-1 flex items-center justify-center gap-1 rounded-md border border-white/10 py-1 text-[9px] text-gray-300 hover:border-[#ccff00]/40 hover:text-[#ccff00] transition-colors"
|
||||
>
|
||||
<span>{link.emoji}</span>
|
||||
<span className="truncate">{link.label}</span>
|
||||
<span className="truncate">{t(TRAVEL_LINK_KEYS[link.label] ?? link.label)}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[8px] text-gray-600 flex items-center gap-1 justify-center">
|
||||
<Plane className="h-2 w-2" /> I plan the skating — flights & stays open on the real sites (no KYC, no middleman).
|
||||
<Plane className="h-2 w-2" /> {t('routeUi.noMiddleman')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
27
src/i18n/batch3/bootUi.ts
Normal file
27
src/i18n/batch3/bootUi.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/**
|
||||
* Batch 3 — boot / splash translations (`bootUi.*`).
|
||||
*
|
||||
* Filled by the boot translation pass; keys must exist in BOTH languages.
|
||||
*/
|
||||
const bootUi = {
|
||||
en: {
|
||||
ariaLoading: 'Loading SkateSpots',
|
||||
ariaReady: 'SkateSpots ready',
|
||||
ready: 'Ready',
|
||||
statusWaking: 'Waking the network',
|
||||
statusTuning: 'Tuning the relays',
|
||||
statusPulling: 'Pulling in spots',
|
||||
statusDropping: 'Dropping in',
|
||||
},
|
||||
es: {
|
||||
ariaLoading: 'Cargando SkateSpots',
|
||||
ariaReady: 'SkateSpots listo',
|
||||
ready: 'Listo',
|
||||
statusWaking: 'Despertando la red',
|
||||
statusTuning: 'Ajustando los relays',
|
||||
statusPulling: 'Cargando spots',
|
||||
statusDropping: 'Entrando',
|
||||
},
|
||||
};
|
||||
|
||||
export default bootUi;
|
||||
17
src/i18n/batch3/collectionUi.ts
Normal file
17
src/i18n/batch3/collectionUi.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
/**
|
||||
* Batch 3 — collections dialog translations (`collectionUi.*`).
|
||||
*
|
||||
* Filled by the collections translation pass; keys must exist in BOTH languages.
|
||||
*/
|
||||
const collectionUi = {
|
||||
en: {
|
||||
namePlaceholder: 'Barcelona Trip 2024',
|
||||
descriptionPlaceholder: 'My favorite spots from my Barcelona trip...',
|
||||
},
|
||||
es: {
|
||||
namePlaceholder: 'Viaje a Barcelona 2024',
|
||||
descriptionPlaceholder: 'Mis spots favoritos de mi viaje a Barcelona...',
|
||||
},
|
||||
};
|
||||
|
||||
export default collectionUi;
|
||||
20
src/i18n/batch3/index.ts
Normal file
20
src/i18n/batch3/index.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import routeUi from './routeUi';
|
||||
import sasquatchUi from './sasquatchUi';
|
||||
import collectionUi from './collectionUi';
|
||||
import bootUi from './bootUi';
|
||||
|
||||
/** Namespaces added by the batch-3 translation pass, spread into `en`. */
|
||||
export const batch3En = {
|
||||
routeUi: routeUi.en,
|
||||
sasquatchUi: sasquatchUi.en,
|
||||
collectionUi: collectionUi.en,
|
||||
bootUi: bootUi.en,
|
||||
};
|
||||
|
||||
/** Namespaces added by the batch-3 translation pass, spread into `es`. */
|
||||
export const batch3Es = {
|
||||
routeUi: routeUi.es,
|
||||
sasquatchUi: sasquatchUi.es,
|
||||
collectionUi: collectionUi.es,
|
||||
bootUi: bootUi.es,
|
||||
};
|
||||
297
src/i18n/batch3/routeUi.ts
Normal file
297
src/i18n/batch3/routeUi.ts
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
/**
|
||||
* Batch 3 — routing UI translations (`routeUi.*`).
|
||||
*
|
||||
* Filled by the routing-UI translation pass; keys must exist in BOTH languages.
|
||||
*/
|
||||
const routeUi = {
|
||||
en: {
|
||||
// Header / search
|
||||
destination: 'Destination',
|
||||
routeTo: 'Route to {{label}}',
|
||||
routeToLabel: 'Route to',
|
||||
search: 'Search',
|
||||
description: 'Route options and in-app directions.',
|
||||
routeMapAria: 'Route map',
|
||||
// Loading / refining
|
||||
findingLocation: 'Finding your location…',
|
||||
plottingRoute: 'Plotting your route…',
|
||||
refiningLocation: 'Refining location…',
|
||||
offlineSavedRoute: 'Offline · saved route {{age}}',
|
||||
// Map hints / controls
|
||||
tapMapStart: 'Tap the map to set your start point',
|
||||
fitRouteAria: 'Fit the whole route on the map',
|
||||
wholeRoute: 'Whole route',
|
||||
// Follow navigation banner
|
||||
next: 'Next',
|
||||
nextStep: 'Next · step {{n}}',
|
||||
followTheLine: 'Follow the line',
|
||||
toTheTurn: 'to the turn',
|
||||
toGo: 'to go',
|
||||
tryAgain: 'Try again',
|
||||
// Travel mode toggle
|
||||
mode: {
|
||||
foot: 'Skate / Walk',
|
||||
bike: 'Bike',
|
||||
moto: 'Moto',
|
||||
car: 'Car',
|
||||
transit: 'Transit',
|
||||
},
|
||||
// Start point
|
||||
from: 'From',
|
||||
myLocation: 'My location',
|
||||
tapTheMap: 'Tap the map…',
|
||||
chooseOnMap: 'Choose on map',
|
||||
// Route summary
|
||||
direct: 'direct',
|
||||
transferOne: '1 transfer',
|
||||
transfers: '{{count}} transfers',
|
||||
options: '{{count}} options',
|
||||
ride: 'ride',
|
||||
arrive: 'arrive',
|
||||
speedAvg: '{{speed}} km/h avg',
|
||||
stepsCount: '{{count}} steps',
|
||||
onABoard: 'on a board',
|
||||
usesDrivingRoads: 'uses driving roads',
|
||||
drivingEstimate: 'driving estimate',
|
||||
updating: 'updating…',
|
||||
pickMode: 'Pick a mode to see your route.',
|
||||
waitingLocation: 'Waiting for your location…',
|
||||
// Turn-by-turn planning list
|
||||
routeSteps: 'Route steps',
|
||||
walkingRoute: 'walking route — quicker on a board',
|
||||
planningView: 'Planning view — tap Start to follow with live turns, voice and arrival.',
|
||||
// Transit controls
|
||||
leaveNow: 'Leave now',
|
||||
departAt: 'Depart at',
|
||||
arriveBy: 'Arrive by',
|
||||
departureTime: 'Departure time',
|
||||
arrivalTime: 'Arrival time',
|
||||
only: 'Only',
|
||||
clear: 'clear',
|
||||
itinerary: 'Itinerary',
|
||||
group: {
|
||||
bus: 'Bus',
|
||||
tram: 'Tram',
|
||||
metro: 'Metro',
|
||||
train: 'Train',
|
||||
ferry: 'Ferry',
|
||||
},
|
||||
// Itinerary legs
|
||||
walk: 'Walk',
|
||||
rideVerb: 'Ride',
|
||||
walkLower: 'walk',
|
||||
rideLower: 'ride',
|
||||
toStop: 'to',
|
||||
track: 'track {{track}}',
|
||||
stopOne: '1 stop',
|
||||
stopsCount: '{{count}} stops',
|
||||
tickets: 'tickets ↗',
|
||||
// Follow bar
|
||||
done: 'Done',
|
||||
stop: 'Stop',
|
||||
start: 'Start',
|
||||
arrived: "You've arrived. 🛹",
|
||||
voiceOnNote: 'Screen stays on · voice on — turns and arrival are spoken.',
|
||||
voiceOffNote: 'Screen stays on · voice off.',
|
||||
followNote: 'Turns, voice and arrival as you roll. Keeps the screen on.',
|
||||
voiceOffAria: 'Turn voice guidance off',
|
||||
voiceOnAria: 'Turn voice guidance on',
|
||||
// Spoken guidance
|
||||
voiceStart: 'Starting navigation',
|
||||
voiceArrived: "You've arrived",
|
||||
voiceIn: 'In {{metres}} metres, {{instruction}}',
|
||||
voiceContinue: 'continue',
|
||||
voiceContinueCaps: 'Continue',
|
||||
// Errors
|
||||
errPermissionDenied: 'Location permission denied.',
|
||||
errWaitingGps: 'Waiting for a GPS fix…',
|
||||
errPermissionDeniedLong: 'Location permission denied. Enable location to route from where you are.',
|
||||
errNoLocation: 'Could not get your location. Try again.',
|
||||
errNoLocationShort: 'Could not get your location.',
|
||||
errTransitNoRouteFiltered: 'No route for that time or transport mode — try another time, or include more modes.',
|
||||
errTransitNoRoute: 'No public-transport route found here — this area may not publish open timetables.',
|
||||
errTransitUnreachable: "Couldn't reach the transit service. Check your connection and try again.",
|
||||
errRoutingBusy: 'The routing service is busy right now — try again in a moment.',
|
||||
errNoRouteMode: 'No route for this mode — the destination may be off the road network. Try another mode.',
|
||||
errRoutingUnreachable: "Couldn't reach the routing service. Check your connection and try again.",
|
||||
// Departure board
|
||||
departures: 'Departures',
|
||||
upcomingFrom: 'Upcoming departures from {{stop}}',
|
||||
loadingDepartures: 'Loading departures…',
|
||||
noDepartures: 'No upcoming departures found at this stop.',
|
||||
live: 'live',
|
||||
cancelled: 'cancelled',
|
||||
refresh: 'Refresh',
|
||||
// Trip itinerary card
|
||||
tripLoginTitle: 'Log in to save',
|
||||
tripLoginDesc: 'Sign in to keep this trip as a Collection.',
|
||||
tripCollectionTitle: '{{destination}} skate trip 🛹',
|
||||
tripCollectionDesc: 'Trip plan by The Sasquatch — {{count}} days in {{destination}}.',
|
||||
tripCollectionDescOne: 'Trip plan by The Sasquatch — {{count}} day in {{destination}}.',
|
||||
tripSavedTitle: 'Saved to Collections 🦧',
|
||||
tripSavedDesc: 'Your {{destination}} trip is bookmarked.',
|
||||
tripSaveFailed: "Couldn't save",
|
||||
tripSaveFailedDesc: 'Try again in a sec.',
|
||||
daysInDestinationOne: '{{count}} day in {{destination}}',
|
||||
daysInDestination: '{{count}} days in {{destination}}',
|
||||
wetForecast: 'Wet forecast — hit a local crew for covered/indoor options.',
|
||||
dayLabel: 'Day {{n}}',
|
||||
freeSkate: 'Free skate — explore & film. 🎥',
|
||||
tapToBook: 'tap to book / zap ⚡',
|
||||
localCrews: 'Local crews',
|
||||
saving: 'Saving…',
|
||||
savedToCollections: 'Saved to Collections',
|
||||
saveSpotOne: 'Save {{count}} spot to a Collection',
|
||||
saveSpots: 'Save {{count}} spots to a Collection',
|
||||
flights: 'Flights',
|
||||
stays: 'Stays',
|
||||
gettingAround: 'Getting around',
|
||||
noMiddleman: 'I plan the skating — flights & stays open on the real sites (no KYC, no middleman).',
|
||||
},
|
||||
es: {
|
||||
// Header / search
|
||||
destination: 'Destino',
|
||||
routeTo: 'Ruta a {{label}}',
|
||||
routeToLabel: 'Ruta a',
|
||||
search: 'Buscar',
|
||||
description: 'Opciones de ruta e indicaciones en la app.',
|
||||
routeMapAria: 'Mapa de ruta',
|
||||
// Loading / refining
|
||||
findingLocation: 'Buscando tu ubicación…',
|
||||
plottingRoute: 'Trazando tu ruta…',
|
||||
refiningLocation: 'Afinando la ubicación…',
|
||||
offlineSavedRoute: 'Sin conexión · ruta guardada {{age}}',
|
||||
// Map hints / controls
|
||||
tapMapStart: 'Toca el mapa para fijar tu punto de inicio',
|
||||
fitRouteAria: 'Encajar toda la ruta en el mapa',
|
||||
wholeRoute: 'Ruta completa',
|
||||
// Follow navigation banner
|
||||
next: 'Siguiente',
|
||||
nextStep: 'Siguiente · paso {{n}}',
|
||||
followTheLine: 'Sigue la línea',
|
||||
toTheTurn: 'hasta el giro',
|
||||
toGo: 'restantes',
|
||||
tryAgain: 'Reintentar',
|
||||
// Travel mode toggle
|
||||
mode: {
|
||||
foot: 'Skate / Caminar',
|
||||
bike: 'Bici',
|
||||
moto: 'Moto',
|
||||
car: 'Coche',
|
||||
transit: 'Transporte público',
|
||||
},
|
||||
// Start point
|
||||
from: 'Desde',
|
||||
myLocation: 'Mi ubicación',
|
||||
tapTheMap: 'Toca el mapa…',
|
||||
chooseOnMap: 'Elegir en el mapa',
|
||||
// Route summary
|
||||
direct: 'directo',
|
||||
transferOne: '1 transbordo',
|
||||
transfers: '{{count}} transbordos',
|
||||
options: '{{count}} opciones',
|
||||
ride: 'recorrido',
|
||||
arrive: 'llegada',
|
||||
speedAvg: '{{speed}} km/h media',
|
||||
stepsCount: '{{count}} pasos',
|
||||
onABoard: 'en tabla',
|
||||
usesDrivingRoads: 'usa vías de coche',
|
||||
drivingEstimate: 'estimación en coche',
|
||||
updating: 'actualizando…',
|
||||
pickMode: 'Elige un modo para ver tu ruta.',
|
||||
waitingLocation: 'Esperando tu ubicación…',
|
||||
// Turn-by-turn planning list
|
||||
routeSteps: 'Pasos de la ruta',
|
||||
walkingRoute: 'ruta a pie — más rápida en tabla',
|
||||
planningView: 'Vista de planificación — toca Empezar para seguir con giros en vivo, voz y llegada.',
|
||||
// Transit controls
|
||||
leaveNow: 'Salir ahora',
|
||||
departAt: 'Salir a las',
|
||||
arriveBy: 'Llegar antes de',
|
||||
departureTime: 'Hora de salida',
|
||||
arrivalTime: 'Hora de llegada',
|
||||
only: 'Solo',
|
||||
clear: 'borrar',
|
||||
itinerary: 'Itinerario',
|
||||
group: {
|
||||
bus: 'Autobús',
|
||||
tram: 'Tranvía',
|
||||
metro: 'Metro',
|
||||
train: 'Tren',
|
||||
ferry: 'Ferry',
|
||||
},
|
||||
// Itinerary legs
|
||||
walk: 'Andar',
|
||||
rideVerb: 'En bici',
|
||||
walkLower: 'andar',
|
||||
rideLower: 'en bici',
|
||||
toStop: 'hasta',
|
||||
track: 'vía {{track}}',
|
||||
stopOne: '1 parada',
|
||||
stopsCount: '{{count}} paradas',
|
||||
tickets: 'billetes ↗',
|
||||
// Follow bar
|
||||
done: 'Hecho',
|
||||
stop: 'Parar',
|
||||
start: 'Empezar',
|
||||
arrived: 'Has llegado. 🛹',
|
||||
voiceOnNote: 'La pantalla no se apaga · voz activada — los giros y la llegada se anuncian.',
|
||||
voiceOffNote: 'La pantalla no se apaga · voz desactivada.',
|
||||
followNote: 'Giros, voz y llegada mientras patinas. Mantiene la pantalla encendida.',
|
||||
voiceOffAria: 'Desactivar la guía por voz',
|
||||
voiceOnAria: 'Activar la guía por voz',
|
||||
// Spoken guidance
|
||||
voiceStart: 'Empezando la navegación',
|
||||
voiceArrived: 'Has llegado',
|
||||
voiceIn: 'En {{metres}} metros, {{instruction}}',
|
||||
voiceContinue: 'continúa',
|
||||
voiceContinueCaps: 'Continúa',
|
||||
// Errors
|
||||
errPermissionDenied: 'Permiso de ubicación denegado.',
|
||||
errWaitingGps: 'Esperando señal GPS…',
|
||||
errPermissionDeniedLong: 'Permiso de ubicación denegado. Activa la ubicación para trazar la ruta desde donde estás.',
|
||||
errNoLocation: 'No se pudo obtener tu ubicación. Inténtalo de nuevo.',
|
||||
errNoLocationShort: 'No se pudo obtener tu ubicación.',
|
||||
errTransitNoRouteFiltered: 'No hay ruta para esa hora o modo de transporte — prueba otra hora o incluye más modos.',
|
||||
errTransitNoRoute: 'No se encontró ruta de transporte público aquí — puede que esta zona no publique horarios abiertos.',
|
||||
errTransitUnreachable: 'No se pudo contactar con el servicio de transporte. Comprueba tu conexión e inténtalo de nuevo.',
|
||||
errRoutingBusy: 'El servicio de rutas está saturado ahora mismo — inténtalo de nuevo en un momento.',
|
||||
errNoRouteMode: 'No hay ruta para este modo — puede que el destino esté fuera de la red de carreteras. Prueba otro modo.',
|
||||
errRoutingUnreachable: 'No se pudo contactar con el servicio de rutas. Comprueba tu conexión e inténtalo de nuevo.',
|
||||
// Departure board
|
||||
departures: 'Salidas',
|
||||
upcomingFrom: 'Próximas salidas desde {{stop}}',
|
||||
loadingDepartures: 'Cargando salidas…',
|
||||
noDepartures: 'No se encontraron próximas salidas en esta parada.',
|
||||
live: 'en vivo',
|
||||
cancelled: 'cancelado',
|
||||
refresh: 'Actualizar',
|
||||
// Trip itinerary card
|
||||
tripLoginTitle: 'Inicia sesión para guardar',
|
||||
tripLoginDesc: 'Inicia sesión para guardar este viaje como Colección.',
|
||||
tripCollectionTitle: 'Viaje de skate a {{destination}} 🛹',
|
||||
tripCollectionDesc: 'Plan de viaje de The Sasquatch — {{count}} días en {{destination}}.',
|
||||
tripCollectionDescOne: 'Plan de viaje de The Sasquatch — {{count}} día en {{destination}}.',
|
||||
tripSavedTitle: 'Guardado en Colecciones 🦧',
|
||||
tripSavedDesc: 'Tu viaje a {{destination}} está guardado.',
|
||||
tripSaveFailed: 'No se pudo guardar',
|
||||
tripSaveFailedDesc: 'Inténtalo de nuevo en un momento.',
|
||||
daysInDestinationOne: '{{count}} día en {{destination}}',
|
||||
daysInDestination: '{{count}} días en {{destination}}',
|
||||
wetForecast: 'Pronóstico de lluvia — contacta con una crew local para opciones cubiertas/interiores.',
|
||||
dayLabel: 'Día {{n}}',
|
||||
freeSkate: 'Skate libre — explora y graba. 🎥',
|
||||
tapToBook: 'toca para reservar / zap ⚡',
|
||||
localCrews: 'Crews locales',
|
||||
saving: 'Guardando…',
|
||||
savedToCollections: 'Guardado en Colecciones',
|
||||
saveSpotOne: 'Guardar {{count}} spot en una Colección',
|
||||
saveSpots: 'Guardar {{count}} spots en una Colección',
|
||||
flights: 'Vuelos',
|
||||
stays: 'Alojamiento',
|
||||
gettingAround: 'Moverse',
|
||||
noMiddleman: 'Yo planifico el skate — los vuelos y alojamientos se abren en los sitios reales (sin KYC, sin intermediarios).',
|
||||
},
|
||||
};
|
||||
|
||||
export default routeUi;
|
||||
217
src/i18n/batch3/sasquatchUi.ts
Normal file
217
src/i18n/batch3/sasquatchUi.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
/**
|
||||
* Batch 3 — Sasquatch AI panel translations (`sasquatchUi.*`).
|
||||
*
|
||||
* Filled by the Sasquatch translation pass; keys must exist in BOTH languages.
|
||||
*
|
||||
* NOT translated here (deliberately): the LLM system prompt / language
|
||||
* directive, the static FAQ answer bodies and the locally-formatted chat
|
||||
* responses — those are AI/conversational content, not UI chrome.
|
||||
*/
|
||||
const sasquatchUi = {
|
||||
en: {
|
||||
// ── Connection status ──
|
||||
live: 'live',
|
||||
offline: 'offline',
|
||||
|
||||
// ── Panel default content ──
|
||||
genericTitle: 'SkateSpots Assistant 🦧',
|
||||
genericGreeting:
|
||||
"Yo! I'm The Sasquatch. Ask me anything about spots, events, coaches, gear, or how the app works. I've got live data from the SkateSpots relay!",
|
||||
q1: 'Plan me 3 days in Lisbon',
|
||||
q2: 'Find spots near me',
|
||||
q3: 'Any upcoming events?',
|
||||
q4: 'What are crews?',
|
||||
q5: 'How do zaps work?',
|
||||
q6: 'What is Nostr?',
|
||||
q7: 'What is My Stash?',
|
||||
q8: 'Is this free?',
|
||||
|
||||
// ── Bubble + header ──
|
||||
bubbleTitle: 'Press and drag to move · Tap to open',
|
||||
openGuideAria: 'Open Sasquatch guide',
|
||||
activeUntil: 'Active until {{date}}',
|
||||
platformOwner: 'Platform owner',
|
||||
upgradeTitle: 'Upgrade to Sasquatch Pro',
|
||||
goPro: 'Go Pro',
|
||||
aiBalanceTitle: 'Sasquatch AI credit balance (owner-only) — top up at {{provider}}',
|
||||
aiChip: '⚡ AI {{balance}}',
|
||||
minimizeToBubbleAria: 'Minimize to bubble',
|
||||
minimize: 'Minimize',
|
||||
closePanelAria: 'Close panel',
|
||||
close: 'Close',
|
||||
|
||||
// ── Chat chrome ──
|
||||
askLab: 'Ask about Coaching & Filming',
|
||||
askMap: 'Ask about Spots, Shops & Gear',
|
||||
askAnything: 'Ask me anything 🛹',
|
||||
quickQuestions: 'Quick questions:',
|
||||
you: '🧑 You',
|
||||
thinking: 'thinking...',
|
||||
inputPlaceholder: 'Ask about spots, events, coaches...',
|
||||
liveDataFrom: 'Live data from',
|
||||
nostrRelays: 'Nostr relays',
|
||||
clear: 'clear',
|
||||
|
||||
// ── Loading / streaming states ──
|
||||
gettingLocation: '📍 Getting your location…',
|
||||
findingClosest: '🔍 Finding the closest spots…',
|
||||
checkingLive: '🔍 Checking live data…',
|
||||
planningTrip: '🗺️ Planning your {{destination}} trip...',
|
||||
searchingRelays: '🔍 Searching the relays for {{tool}}...',
|
||||
|
||||
// ── Sasquatch Pro dialog ──
|
||||
perk1: 'Personal assistant — Sasquatch reads your own Stash, crews, spots & listings',
|
||||
perk2: 'Action drafts: list gear, post a skate status, plan a trip — you confirm before anything publishes',
|
||||
perk3: 'Answers use your personal context, not just public data',
|
||||
perk4: '⚡ Directly support SkateSpots & keep it free and ad-free',
|
||||
welcomeTitle: 'Welcome to Sasquatch Pro! 🦧⚡',
|
||||
welcomeDesc:
|
||||
'Your pass is active — a receipt just landed in your DMs. Thanks for keeping the streets free.',
|
||||
invoiceCopied: 'Invoice copied',
|
||||
dialogDesc: 'Supercharge your AI skate guide. 100% funds a free, no-ads community app.',
|
||||
proSupporter: "You're a Pro supporter ⚡",
|
||||
extendPrompt: 'Want to extend? Pick a tier below to add more time.',
|
||||
scanToPayPre: 'Scan to pay',
|
||||
scanToPayAmount: '{{sats}} sats',
|
||||
scanToPayPost: 'for {{label}} of Pro',
|
||||
invoiceQrAlt: 'Lightning invoice QR',
|
||||
copyInvoice: 'Copy invoice',
|
||||
ivePaid: "I've paid",
|
||||
ivePaidHint:
|
||||
'Tap "I\'ve paid" after your wallet confirms — this records your pass to your Nostr identity.',
|
||||
freeStaysPre:
|
||||
'Free Sasquatch stays free — public spot/event/coach search, trip planning + the full trick glossary. Pro adds the ',
|
||||
freeStaysEmphasis: 'personal + action',
|
||||
freeStaysPost: ' layer.',
|
||||
loginFirst: 'Log in first to upgrade.',
|
||||
paysTo: 'Pays to',
|
||||
overLightning: 'over Lightning. Bound to your npub.',
|
||||
popular: 'Popular',
|
||||
sats: 'sats',
|
||||
extend: 'Extend',
|
||||
|
||||
// ── Draft action card ──
|
||||
done: 'Done!',
|
||||
cancelledNothing: 'Cancelled — nothing was published.',
|
||||
draftReview: 'Draft — review & confirm',
|
||||
statusTextPlaceholder: 'Status text',
|
||||
titlePlaceholder: 'Title',
|
||||
priceSatsPlaceholder: 'Price (sats)',
|
||||
conditionPlaceholder: 'Condition',
|
||||
cancel: 'Cancel',
|
||||
|
||||
// ── Owner AI balance card ──
|
||||
aiTitle: 'Sasquatch AI',
|
||||
owner: 'Owner',
|
||||
notConfigured: 'not configured',
|
||||
unavailable: 'unavailable',
|
||||
satsValue: '{{balance}} sats',
|
||||
theNode: 'the node',
|
||||
lowBalance: 'Running low — top up at {{provider}} or AI will fall back to the keyless provider.',
|
||||
aiDesc: 'Pay-per-request AI, funded in sats via Routstr. Tops up when low.',
|
||||
},
|
||||
es: {
|
||||
// ── Connection status ──
|
||||
live: 'en vivo',
|
||||
offline: 'sin conexión',
|
||||
|
||||
// ── Panel default content ──
|
||||
genericTitle: 'Asistente de SkateSpots 🦧',
|
||||
genericGreeting:
|
||||
'¡Hola! Soy el Sasquatch. Pregúntame lo que quieras sobre spots, eventos, entrenadores, material o cómo funciona la app. ¡Tengo datos en vivo del relé de SkateSpots!',
|
||||
q1: 'Planifícame 3 días en Lisboa',
|
||||
q2: 'Encuentra spots cerca de mí',
|
||||
q3: '¿Próximos eventos?',
|
||||
q4: '¿Qué son los crews?',
|
||||
q5: '¿Cómo funcionan los zaps?',
|
||||
q6: '¿Qué es Nostr?',
|
||||
q7: '¿Qué es Mi Stash?',
|
||||
q8: '¿Es gratis?',
|
||||
|
||||
// ── Bubble + header ──
|
||||
bubbleTitle: 'Pulsa y arrastra para mover · Toca para abrir',
|
||||
openGuideAria: 'Abrir la guía del Sasquatch',
|
||||
activeUntil: 'Activo hasta el {{date}}',
|
||||
platformOwner: 'Propietario de la plataforma',
|
||||
upgradeTitle: 'Mejora a Sasquatch Pro',
|
||||
goPro: 'Hazte Pro',
|
||||
aiBalanceTitle: 'Saldo de crédito de la IA del Sasquatch (solo propietario) — recarga en {{provider}}',
|
||||
aiChip: '⚡ IA {{balance}}',
|
||||
minimizeToBubbleAria: 'Minimizar a la burbuja',
|
||||
minimize: 'Minimizar',
|
||||
closePanelAria: 'Cerrar el panel',
|
||||
close: 'Cerrar',
|
||||
|
||||
// ── Chat chrome ──
|
||||
askLab: 'Pregunta sobre entrenamiento y grabación',
|
||||
askMap: 'Pregunta sobre spots, tiendas y material',
|
||||
askAnything: 'Pregúntame lo que quieras 🛹',
|
||||
quickQuestions: 'Preguntas rápidas:',
|
||||
you: '🧑 Tú',
|
||||
thinking: 'pensando...',
|
||||
inputPlaceholder: 'Pregunta sobre spots, eventos, entrenadores...',
|
||||
liveDataFrom: 'Datos en vivo de',
|
||||
nostrRelays: 'relés de Nostr',
|
||||
clear: 'borrar',
|
||||
|
||||
// ── Loading / streaming states ──
|
||||
gettingLocation: '📍 Obteniendo tu ubicación…',
|
||||
findingClosest: '🔍 Buscando los spots más cercanos…',
|
||||
checkingLive: '🔍 Comprobando datos en vivo…',
|
||||
planningTrip: '🗺️ Planificando tu viaje a {{destination}}...',
|
||||
searchingRelays: '🔍 Buscando en los relés: {{tool}}...',
|
||||
|
||||
// ── Sasquatch Pro dialog ──
|
||||
perk1: 'Asistente personal — el Sasquatch lee tu propio Stash, crews, spots y anuncios',
|
||||
perk2: 'Borradores de acciones: publica material, comparte tu estado de skate, planifica un viaje — tú confirmas antes de que se publique nada',
|
||||
perk3: 'Las respuestas usan tu contexto personal, no solo datos públicos',
|
||||
perk4: '⚡ Apoya directamente a SkateSpots y ayúdanos a mantenerla gratis y sin anuncios',
|
||||
welcomeTitle: '¡Bienvenido a Sasquatch Pro! 🦧⚡',
|
||||
welcomeDesc:
|
||||
'Tu pase está activo — acaba de llegar un recibo a tus MD. Gracias por mantener las calles libres.',
|
||||
invoiceCopied: 'Factura copiada',
|
||||
dialogDesc: 'Potencia tu guía de skate con IA. El 100 % financia una app comunitaria gratis y sin anuncios.',
|
||||
proSupporter: 'Eres un colaborador Pro ⚡',
|
||||
extendPrompt: '¿Quieres ampliar? Elige una tarifa abajo para añadir más tiempo.',
|
||||
scanToPayPre: 'Escanea para pagar',
|
||||
scanToPayAmount: '{{sats}} sats',
|
||||
scanToPayPost: 'por {{label}} de Pro',
|
||||
invoiceQrAlt: 'QR de la factura Lightning',
|
||||
copyInvoice: 'Copiar factura',
|
||||
ivePaid: 'He pagado',
|
||||
ivePaidHint:
|
||||
'Toca "He pagado" cuando tu cartera lo confirme — esto registra tu pase en tu identidad de Nostr.',
|
||||
freeStaysPre:
|
||||
'El Sasquatch gratis sigue siendo gratis — búsqueda pública de spots, eventos y entrenadores, planificación de viajes + el glosario completo de trucos. Pro añade la ',
|
||||
freeStaysEmphasis: 'capa personal y de acciones',
|
||||
freeStaysPost: '.',
|
||||
loginFirst: 'Inicia sesión primero para mejorar.',
|
||||
paysTo: 'Paga a',
|
||||
overLightning: 'por Lightning. Vinculado a tu npub.',
|
||||
popular: 'Popular',
|
||||
sats: 'sats',
|
||||
extend: 'Ampliar',
|
||||
|
||||
// ── Draft action card ──
|
||||
done: '¡Hecho!',
|
||||
cancelledNothing: 'Cancelado — no se publicó nada.',
|
||||
draftReview: 'Borrador — revisa y confirma',
|
||||
statusTextPlaceholder: 'Texto del estado',
|
||||
titlePlaceholder: 'Título',
|
||||
priceSatsPlaceholder: 'Precio (sats)',
|
||||
conditionPlaceholder: 'Estado',
|
||||
cancel: 'Cancelar',
|
||||
|
||||
// ── Owner AI balance card ──
|
||||
aiTitle: 'Sasquatch IA',
|
||||
owner: 'Propietario',
|
||||
notConfigured: 'sin configurar',
|
||||
unavailable: 'no disponible',
|
||||
satsValue: '{{balance}} sats',
|
||||
theNode: 'el nodo',
|
||||
lowBalance: 'Saldo bajo — recarga en {{provider}} o la IA recurrirá al proveedor sin clave.',
|
||||
aiDesc: 'IA de pago por uso, financiada en sats vía Routstr. Se recarga cuando baja.',
|
||||
},
|
||||
};
|
||||
|
||||
export default sasquatchUi;
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
Loading…
Reference in a new issue