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"
|
id="title"
|
||||||
value={newTitle}
|
value={newTitle}
|
||||||
onChange={(e) => setNewTitle(e.target.value)}
|
onChange={(e) => setNewTitle(e.target.value)}
|
||||||
placeholder="Barcelona Trip 2024"
|
placeholder={t('collectionUi.namePlaceholder')}
|
||||||
className="bg-black border-gray-700 text-white h-11"
|
className="bg-black border-gray-700 text-white h-11"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
|
@ -217,7 +217,7 @@ export function AddToCollectionDialog({ open, onOpenChange, spot }: AddToCollect
|
||||||
id="description"
|
id="description"
|
||||||
value={newDescription}
|
value={newDescription}
|
||||||
onChange={(e) => setNewDescription(e.target.value)}
|
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"
|
className="bg-black border-gray-700 text-white resize-none"
|
||||||
rows={2}
|
rows={2}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,25 @@ import {
|
||||||
transitPrimaryEmoji,
|
transitPrimaryEmoji,
|
||||||
} from '@/lib/transitUi';
|
} from '@/lib/transitUi';
|
||||||
import { StopDepartures } from '@/components/StopDepartures';
|
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. */
|
/** A stop name that opens the departure board, when the feed gave a stop id. */
|
||||||
function StopButton({
|
function StopButton({
|
||||||
|
|
@ -88,12 +107,12 @@ interface RouteDialogProps {
|
||||||
* maps app is opened — the route comes from the keyless public OSRM server and
|
* 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.
|
* 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". */
|
/** The average speed the routing engine assumed for this mode, e.g. "21". */
|
||||||
function speedLabel(distanceM: number, durationS: number): string | undefined {
|
function speedValue(distanceM: number, durationS: number): string | undefined {
|
||||||
if (!(distanceM > 0) || !(durationS > 0)) return undefined;
|
if (!(distanceM > 0) || !(durationS > 0)) return undefined;
|
||||||
const kmh = distanceM / 1000 / (durationS / 3600);
|
const kmh = distanceM / 1000 / (durationS / 3600);
|
||||||
if (!Number.isFinite(kmh) || kmh <= 0) return undefined;
|
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. */
|
/** Local clock time you'd arrive if you set off now. */
|
||||||
|
|
@ -108,15 +127,16 @@ export function RouteDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
destination,
|
destination,
|
||||||
destinationLabel = 'Destination',
|
destinationLabel,
|
||||||
onDestinationChange,
|
onDestinationChange,
|
||||||
}: RouteDialogProps) {
|
}: RouteDialogProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
// The destination is owned HERE so the in-dialog search can change it without
|
// 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
|
// 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
|
// OPENS (or the parent's target actually changes) — not on every parent
|
||||||
// render, which would revert a search the user just made.
|
// render, which would revert a search the user just made.
|
||||||
const [dest, setDest] = useState(destination);
|
const [dest, setDest] = useState(destination);
|
||||||
const [destLabel, setDestLabel] = useState(destinationLabel);
|
const [destLabel, setDestLabel] = useState(destinationLabel ?? '');
|
||||||
const [searchOpen, setSearchOpen] = useState(false);
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
const lastPropKeyRef = useRef<string>('');
|
const lastPropKeyRef = useRef<string>('');
|
||||||
// The map's initial centre; read by the init effect without making it a dep
|
// 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;
|
if (key === lastPropKeyRef.current) return;
|
||||||
lastPropKeyRef.current = key;
|
lastPropKeyRef.current = key;
|
||||||
setDest(destination);
|
setDest(destination);
|
||||||
setDestLabel(destinationLabel);
|
setDestLabel(destinationLabel ?? '');
|
||||||
}, [open, destination, destinationLabel]);
|
}, [open, destination, destinationLabel]);
|
||||||
// The map container is inside Radix Dialog content, which mounts AFTER this
|
// 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
|
// component's first effect pass. A plain useRef is therefore still null when
|
||||||
|
|
@ -362,7 +382,7 @@ export function RouteDialog({
|
||||||
setFollowing(true);
|
setFollowing(true);
|
||||||
void acquireWakeLock();
|
void acquireWakeLock();
|
||||||
// Spoken from the Start tap (a user gesture) — this also unlocks TTS.
|
// Spoken from the Start tap (a user gesture) — this also unlocks TTS.
|
||||||
speak('Starting navigation');
|
speak(t('routeUi.voiceStart'));
|
||||||
watchRef.current = watchPosition(
|
watchRef.current = watchPosition(
|
||||||
(coords) => {
|
(coords) => {
|
||||||
setLive(coords);
|
setLive(coords);
|
||||||
|
|
@ -373,14 +393,14 @@ export function RouteDialog({
|
||||||
// loss is normal (tunnels, indoors) and the watcher recovers by itself,
|
// loss is normal (tunnels, indoors) and the watcher recovers by itself,
|
||||||
// so we keep running and just say we're waiting.
|
// so we keep running and just say we're waiting.
|
||||||
if (err.kind === 'denied') {
|
if (err.kind === 'denied') {
|
||||||
setFollowError('Location permission denied.');
|
setFollowError(t('routeUi.errPermissionDenied'));
|
||||||
stopFollowing();
|
stopFollowing();
|
||||||
} else {
|
} 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.
|
// Never leave the GPS running: stop on close and on unmount.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -446,7 +466,7 @@ export function RouteDialog({
|
||||||
if (nav.toDestKm < 0.03) {
|
if (nav.toDestKm < 0.03) {
|
||||||
if (!arrivedRef.current) {
|
if (!arrivedRef.current) {
|
||||||
arrivedRef.current = true;
|
arrivedRef.current = true;
|
||||||
speak("You've arrived");
|
speak(t('routeUi.voiceArrived'));
|
||||||
buzz([120, 80, 120]);
|
buzz([120, 80, 120]);
|
||||||
setArrived(true);
|
setArrived(true);
|
||||||
stopFollowing();
|
stopFollowing();
|
||||||
|
|
@ -466,16 +486,19 @@ export function RouteDialog({
|
||||||
bands.add(150);
|
bands.add(150);
|
||||||
announcedRef.current.set(idx, bands);
|
announcedRef.current.set(idx, bands);
|
||||||
const metres = Math.max(50, Math.round((km * 1000) / 10) * 10);
|
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);
|
buzz(60);
|
||||||
} else if (km <= 0.04 && !bands.has(40)) {
|
} else if (km <= 0.04 && !bands.has(40)) {
|
||||||
// ~40 m out — the turn itself.
|
// ~40 m out — the turn itself.
|
||||||
bands.add(40);
|
bands.add(40);
|
||||||
announcedRef.current.set(idx, bands);
|
announcedRef.current.set(idx, bands);
|
||||||
speak(steps[idx]?.instruction ?? 'Continue');
|
speak(steps[idx]?.instruction ?? t('routeUi.voiceContinueCaps'));
|
||||||
buzz(140);
|
buzz(140);
|
||||||
}
|
}
|
||||||
}, [following, nav, route, speak, buzz, stopFollowing]);
|
}, [following, nav, route, speak, buzz, stopFollowing, t]);
|
||||||
|
|
||||||
// ─── Get the user's location when the dialog opens ─────────────────────────
|
// ─── Get the user's location when the dialog opens ─────────────────────────
|
||||||
const locate = useCallback(async () => {
|
const locate = useCallback(async () => {
|
||||||
|
|
@ -488,14 +511,14 @@ export function RouteDialog({
|
||||||
const msg =
|
const msg =
|
||||||
err instanceof GeolocationError
|
err instanceof GeolocationError
|
||||||
? err.kind === 'denied'
|
? err.kind === 'denied'
|
||||||
? 'Location permission denied. Enable location to route from where you are.'
|
? t('routeUi.errPermissionDeniedLong')
|
||||||
: 'Could not get your location. Try again.'
|
: t('routeUi.errNoLocation')
|
||||||
: 'Could not get your location.';
|
: t('routeUi.errNoLocationShort');
|
||||||
setLocError(msg);
|
setLocError(msg);
|
||||||
} finally {
|
} finally {
|
||||||
setLocating(false);
|
setLocating(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
|
@ -677,16 +700,16 @@ export function RouteDialog({
|
||||||
setError(
|
setError(
|
||||||
msg === 'No transit route found'
|
msg === 'No transit route found'
|
||||||
? selectedTransitModes.length > 0 || transitTimeMode !== 'now'
|
? selectedTransitModes.length > 0 || transitTimeMode !== 'now'
|
||||||
? 'No route for that time or transport mode — try another time, or include more modes.'
|
? t('routeUi.errTransitNoRouteFiltered')
|
||||||
: 'No public-transport route found here — this area may not publish open timetables.'
|
: t('routeUi.errTransitNoRoute')
|
||||||
: "Couldn't reach the transit service. Check your connection and try again.",
|
: t('routeUi.errTransitUnreachable'),
|
||||||
);
|
);
|
||||||
} else if (msg === ROUTE_ERR_BUSY) {
|
} 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) {
|
} 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 {
|
} 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
|
// Offline / server down: fall back to the last route we saved for this
|
||||||
|
|
@ -723,7 +746,7 @@ export function RouteDialog({
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
controller.abort();
|
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
|
// ─── Draw the selected transit itinerary (redraws when an alternative is
|
||||||
// chosen — no re-query). Walking legs dashed grey, vehicles solid. ──────
|
// chosen — no re-query). Walking legs dashed grey, vehicles solid. ──────
|
||||||
|
|
@ -754,6 +777,9 @@ export function RouteDialog({
|
||||||
}
|
}
|
||||||
}, [selectedTransit]);
|
}, [selectedTransit]);
|
||||||
|
|
||||||
|
const destLabelText = destLabel || t('routeUi.destination');
|
||||||
|
const speed = route ? speedValue(route.distanceM, route.durationS) : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
|
@ -763,7 +789,7 @@ export function RouteDialog({
|
||||||
scroll with the Start/Stop bar pinned at the bottom. */}
|
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">
|
<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">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSearchOpen(true)}
|
onClick={() => setSearchOpen(true)}
|
||||||
|
|
@ -772,19 +798,19 @@ export function RouteDialog({
|
||||||
<Navigation className="h-5 w-5 shrink-0 text-[#ccff00]" />
|
<Navigation className="h-5 w-5 shrink-0 text-[#ccff00]" />
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="block text-[10px] font-bold uppercase tracking-widest text-gray-500">
|
<span className="block text-[10px] font-bold uppercase tracking-widest text-gray-500">
|
||||||
Route to
|
{t('routeUi.routeToLabel')}
|
||||||
</span>
|
</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>
|
||||||
<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">
|
<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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{/* Copy kept out of the visible chrome so the route screen stays
|
{/* Copy kept out of the visible chrome so the route screen stays
|
||||||
clean; the same promise now lives on /privacy → "What leaves your
|
clean; the same promise now lives on /privacy → "What leaves your
|
||||||
device" → Directions. sr-only keeps the dialog accessible. */}
|
device" → Directions. sr-only keeps the dialog accessible. */}
|
||||||
<DialogDescription className="sr-only">
|
<DialogDescription className="sr-only">
|
||||||
Route options and in-app directions.
|
{t('routeUi.description')}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|
@ -796,7 +822,7 @@ export function RouteDialog({
|
||||||
<div
|
<div
|
||||||
ref={setMapDiv}
|
ref={setMapDiv}
|
||||||
className="h-full w-full bg-[#0b0b0c]"
|
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
|
{/* 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="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">
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-[#ccff00]" />
|
<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>
|
||||||
</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">
|
<div className="pointer-events-none absolute bottom-2 left-2 flex flex-col items-start gap-1">
|
||||||
{cachedAge !== null && (
|
{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">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{locating && origin && (
|
{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">
|
<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" />
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
Refining location…
|
{t('routeUi.refiningLocation')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -849,7 +875,7 @@ export function RouteDialog({
|
||||||
{/* "Choose on map" hint */}
|
{/* "Choose on map" hint */}
|
||||||
{pickingStart && (
|
{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">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -857,18 +883,18 @@ export function RouteDialog({
|
||||||
{following && nav && (
|
{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">
|
<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]">
|
<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>
|
||||||
<p className="truncate text-base font-black text-white">
|
<p className="truncate text-base font-black text-white">
|
||||||
{nav.stepIndex >= 0
|
{nav.stepIndex >= 0
|
||||||
? route?.steps?.[nav.stepIndex]?.instruction ?? 'Follow the line'
|
? route?.steps?.[nav.stepIndex]?.instruction ?? t('routeUi.followTheLine')
|
||||||
: route?.steps?.[0]?.instruction ?? 'Follow the line'}
|
: route?.steps?.[0]?.instruction ?? t('routeUi.followTheLine')}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-400">
|
<p className="text-xs text-gray-400">
|
||||||
{nav.stepIndex >= 0 && Number.isFinite(nav.stepKm)
|
{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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -886,7 +912,7 @@ export function RouteDialog({
|
||||||
<p className="text-sm text-muted-foreground">{locError}</p>
|
<p className="text-sm text-muted-foreground">{locError}</p>
|
||||||
<Button size="sm" variant="outline" onClick={locate} className="gap-2">
|
<Button size="sm" variant="outline" onClick={locate} className="gap-2">
|
||||||
<LocateFixed className="h-4 w-4" />
|
<LocateFixed className="h-4 w-4" />
|
||||||
Try again
|
{t('routeUi.tryAgain')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -898,10 +924,10 @@ export function RouteDialog({
|
||||||
type="button"
|
type="button"
|
||||||
onClick={fitRoute}
|
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"
|
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" />
|
<LocateFixed className="h-3.5 w-3.5" />
|
||||||
Whole route
|
{t('routeUi.wholeRoute')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -922,7 +948,7 @@ export function RouteDialog({
|
||||||
aria-pressed={mode === m.mode}
|
aria-pressed={mode === m.mode}
|
||||||
>
|
>
|
||||||
<span className="text-lg leading-none">{m.emoji}</span>
|
<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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -930,7 +956,7 @@ export function RouteDialog({
|
||||||
{/* Start point — default is where you are; you can pick another on the map */}
|
{/* 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">
|
<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">
|
<span className="text-[10px] font-black uppercase tracking-widest text-muted-foreground">
|
||||||
From
|
{t('routeUi.from')}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -945,7 +971,7 @@ export function RouteDialog({
|
||||||
: 'border-white/10 text-muted-foreground hover:text-foreground',
|
: 'border-white/10 text-muted-foreground hover:text-foreground',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
My location
|
{t('routeUi.myLocation')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -958,7 +984,7 @@ export function RouteDialog({
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Crosshair className="h-3 w-3" />
|
<Crosshair className="h-3 w-3" />
|
||||||
{pickingStart ? 'Tap the map…' : 'Choose on map'}
|
{pickingStart ? t('routeUi.tapTheMap') : t('routeUi.chooseOnMap')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -983,9 +1009,11 @@ export function RouteDialog({
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{formatDuration(selectedTransit.durationS)} ·{' '}
|
{formatDuration(selectedTransit.durationS)} ·{' '}
|
||||||
{selectedTransit.transfers === 0
|
{selectedTransit.transfers === 0
|
||||||
? 'direct'
|
? t('routeUi.direct')
|
||||||
: `${selectedTransit.transfers} transfer${selectedTransit.transfers > 1 ? 's' : ''}`}
|
: selectedTransit.transfers === 1
|
||||||
{options.length > 1 && ` · ${options.length} options`}
|
? 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" />}
|
{routing && <Loader2 className="ml-1 inline h-3 w-3 animate-spin" />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1021,13 +1049,13 @@ export function RouteDialog({
|
||||||
{formatDuration(route.durationS)}
|
{formatDuration(route.durationS)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
ride
|
{t('routeUi.ride')}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span aria-hidden className="h-3.5 w-px bg-white/15" />
|
<span aria-hidden className="h-3.5 w-px bg-white/15" />
|
||||||
<span className="flex items-baseline gap-1.5">
|
<span className="flex items-baseline gap-1.5">
|
||||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
arrive
|
{t('routeUi.arrive')}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-base font-black tabular-nums text-white">
|
<span className="text-base font-black tabular-nums text-white">
|
||||||
{etaClock(route.durationS)}
|
{etaClock(route.durationS)}
|
||||||
|
|
@ -1040,24 +1068,24 @@ export function RouteDialog({
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
{route.steps && route.steps.length > 1
|
{route.steps && route.steps.length > 1
|
||||||
? `${route.steps.length} steps`
|
? t('routeUi.stepsCount', { count: route.steps.length })
|
||||||
: 'direct'}
|
: t('routeUi.direct')}
|
||||||
</span>
|
</span>
|
||||||
{mode === 'foot'
|
{mode === 'foot'
|
||||||
? <span>· {skateTime(route.distanceM / 1000)} on a board</span>
|
? <span>· {skateTime(route.distanceM / 1000)} {t('routeUi.onABoard')}</span>
|
||||||
: speedLabel(route.distanceM, route.durationS) && (
|
: speed && (
|
||||||
<span>· {speedLabel(route.distanceM, route.durationS)}</span>
|
<span>· {t('routeUi.speedAvg', { speed })}</span>
|
||||||
)}
|
)}
|
||||||
{TRAVEL_MODES.find((m) => m.mode === mode)?.approximate && (
|
{TRAVEL_MODES.find((m) => m.mode === mode)?.approximate && (
|
||||||
<span>· uses driving roads</span>
|
<span>· {t('routeUi.usesDrivingRoads')}</span>
|
||||||
)}
|
)}
|
||||||
{route.approximate && <span>· driving estimate</span>}
|
{route.approximate && <span>· {t('routeUi.drivingEstimate')}</span>}
|
||||||
{routing && <span>· updating…</span>}
|
{routing && <span>· {t('routeUi.updating')}</span>}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm text-muted-foreground">
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1068,13 +1096,13 @@ export function RouteDialog({
|
||||||
<div className="border-t bg-muted/20">
|
<div className="border-t bg-muted/20">
|
||||||
<div className="flex flex-wrap items-baseline justify-between gap-x-2 px-4 pt-3">
|
<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">
|
<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">
|
<span className="rounded-full bg-white/10 px-1.5 py-0.5 text-[9px] tabular-nums">
|
||||||
{route.steps.length}
|
{route.steps.length}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
{mode === 'foot' && (
|
{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>
|
</div>
|
||||||
<ol className="max-h-72 space-y-1.5 overflow-y-auto px-4 pb-2 pt-1.5">
|
<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>
|
</ol>
|
||||||
<p className="px-4 pb-3 text-[10px] text-muted-foreground/60">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1133,7 +1161,7 @@ export function RouteDialog({
|
||||||
: 'border-white/10 text-gray-400 hover:border-white/25',
|
: '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>
|
</button>
|
||||||
))}
|
))}
|
||||||
{transitTimeMode !== 'now' && (
|
{transitTimeMode !== 'now' && (
|
||||||
|
|
@ -1141,7 +1169,7 @@ export function RouteDialog({
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
value={transitTimeValue}
|
value={transitTimeValue}
|
||||||
onChange={(e) => setTransitTimeValue(e.target.value)}
|
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"
|
className="min-h-0 rounded-lg border border-white/15 bg-white/[0.04] px-2 py-1 text-[11px] text-white"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1149,7 +1177,7 @@ export function RouteDialog({
|
||||||
|
|
||||||
{/* Only: filter the modes (nothing selected = all) */}
|
{/* Only: filter the modes (nothing selected = all) */}
|
||||||
<div className="flex flex-wrap items-center gap-1.5 px-4 pt-2">
|
<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) => {
|
{TRANSIT_MODE_GROUPS.map((g) => {
|
||||||
const on = transitModeFilter.includes(g.key);
|
const on = transitModeFilter.includes(g.key);
|
||||||
return (
|
return (
|
||||||
|
|
@ -1166,7 +1194,7 @@ export function RouteDialog({
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span aria-hidden>{g.emoji}</span>
|
<span aria-hidden>{g.emoji}</span>
|
||||||
{g.label}
|
{t(TRANSIT_GROUP_LABEL_KEYS[g.key] ?? g.label)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -1176,7 +1204,7 @@ export function RouteDialog({
|
||||||
onClick={() => setTransitModeFilter([])}
|
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"
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1210,8 +1238,10 @@ export function RouteDialog({
|
||||||
<span aria-hidden>{modes.join('')}</span>
|
<span aria-hidden>{modes.join('')}</span>
|
||||||
{formatDuration(opt.durationS)} ·{' '}
|
{formatDuration(opt.durationS)} ·{' '}
|
||||||
{opt.transfers === 0
|
{opt.transfers === 0
|
||||||
? 'direct'
|
? t('routeUi.direct')
|
||||||
: `${opt.transfers} transfer${opt.transfers > 1 ? 's' : ''}`}
|
: opt.transfers === 1
|
||||||
|
? t('routeUi.transferOne')
|
||||||
|
: t('routeUi.transfers', { count: opt.transfers })}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</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">
|
<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">
|
<span className="rounded-full bg-white/10 px-1.5 py-0.5 text-[9px] tabular-nums">
|
||||||
{selectedTransit.legs.length}
|
{selectedTransit.legs.length}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -1251,7 +1281,7 @@ export function RouteDialog({
|
||||||
)}
|
)}
|
||||||
<span className="text-xs font-bold text-white">
|
<span className="text-xs font-bold text-white">
|
||||||
{isFoot
|
{isFoot
|
||||||
? leg.mode === 'BIKE' ? 'Ride' : 'Walk'
|
? leg.mode === 'BIKE' ? t('routeUi.rideVerb') : t('routeUi.walk')
|
||||||
: leg.headsign
|
: leg.headsign
|
||||||
? `→ ${leg.headsign}`
|
? `→ ${leg.headsign}`
|
||||||
: (leg.line ?? leg.mode)}
|
: (leg.line ?? leg.mode)}
|
||||||
|
|
@ -1261,7 +1291,7 @@ export function RouteDialog({
|
||||||
)}
|
)}
|
||||||
{leg.realTime && (
|
{leg.realTime && (
|
||||||
<span className="rounded bg-[#00ff88]/15 px-1 text-[9px] font-black uppercase text-[#00ff88]">
|
<span className="rounded bg-[#00ff88]/15 px-1 text-[9px] font-black uppercase text-[#00ff88]">
|
||||||
live
|
{t('routeUi.live')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1270,14 +1300,14 @@ export function RouteDialog({
|
||||||
<>
|
<>
|
||||||
{leg.toName ? (
|
{leg.toName ? (
|
||||||
<>
|
<>
|
||||||
to{' '}
|
{t('routeUi.toStop')}{' '}
|
||||||
<StopButton
|
<StopButton
|
||||||
id={leg.toStopId}
|
id={leg.toStopId}
|
||||||
name={leg.toName}
|
name={leg.toName}
|
||||||
onOpen={(id, name) => setStopBoard({ id, name })}
|
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)}` : ''}
|
{leg.distanceM ? ` · ${formatDistance(leg.distanceM / 1000)}` : ''}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -1287,16 +1317,16 @@ export function RouteDialog({
|
||||||
name={leg.fromName}
|
name={leg.fromName}
|
||||||
onOpen={(id, name) => setStopBoard({ id, name })}
|
onOpen={(id, name) => setStopBoard({ id, name })}
|
||||||
/>
|
/>
|
||||||
{leg.fromTrack ? ` · track ${leg.fromTrack}` : ''} {startClock}
|
{leg.fromTrack ? ` · ${t('routeUi.track', { track: leg.fromTrack })}` : ''} {startClock}
|
||||||
{' → '}
|
{' → '}
|
||||||
<StopButton
|
<StopButton
|
||||||
id={leg.toStopId}
|
id={leg.toStopId}
|
||||||
name={leg.toName}
|
name={leg.toName}
|
||||||
onOpen={(id, name) => setStopBoard({ id, name })}
|
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
|
{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.agencyName ? ` · ${leg.agencyName}` : ''}
|
||||||
{leg.agencyFareUrl ? (
|
{leg.agencyFareUrl ? (
|
||||||
|
|
@ -1308,7 +1338,7 @@ export function RouteDialog({
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="underline decoration-dotted underline-offset-2 hover:text-foreground"
|
className="underline decoration-dotted underline-offset-2 hover:text-foreground"
|
||||||
>
|
>
|
||||||
tickets ↗
|
{t('routeUi.tickets')}
|
||||||
</a>
|
</a>
|
||||||
</>
|
</>
|
||||||
) : ''}
|
) : ''}
|
||||||
|
|
@ -1335,7 +1365,7 @@ export function RouteDialog({
|
||||||
className="gap-2 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
|
className="gap-2 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
|
||||||
>
|
>
|
||||||
<Check className="h-3.5 w-3.5" />
|
<Check className="h-3.5 w-3.5" />
|
||||||
Done
|
{t('routeUi.done')}
|
||||||
</Button>
|
</Button>
|
||||||
) : following ? (
|
) : following ? (
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -1345,7 +1375,7 @@ export function RouteDialog({
|
||||||
className="gap-2 border-[#ff0040]/50 text-[#ff0040] hover:bg-[#ff0040]/10"
|
className="gap-2 border-[#ff0040]/50 text-[#ff0040] hover:bg-[#ff0040]/10"
|
||||||
>
|
>
|
||||||
<Square className="h-3.5 w-3.5" />
|
<Square className="h-3.5 w-3.5" />
|
||||||
Stop
|
{t('routeUi.stop')}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -1354,23 +1384,23 @@ export function RouteDialog({
|
||||||
className="gap-2 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
|
className="gap-2 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
|
||||||
>
|
>
|
||||||
<Play className="h-3.5 w-3.5" />
|
<Play className="h-3.5 w-3.5" />
|
||||||
Start
|
{t('routeUi.start')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<p className="min-w-0 flex-1 text-[11px] leading-snug text-muted-foreground">
|
<p className="min-w-0 flex-1 text-[11px] leading-snug text-muted-foreground">
|
||||||
{arrived
|
{arrived
|
||||||
? "You've arrived. 🛹"
|
? t('routeUi.arrived')
|
||||||
: following
|
: following
|
||||||
? voiceOn
|
? voiceOn
|
||||||
? 'Screen stays on · voice on — turns and arrival are spoken.'
|
? t('routeUi.voiceOnNote')
|
||||||
: 'Screen stays on · voice off.'
|
: t('routeUi.voiceOffNote')
|
||||||
: 'Turns, voice and arrival as you roll. Keeps the screen on.'}
|
: t('routeUi.followNote')}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleVoice}
|
onClick={toggleVoice}
|
||||||
aria-pressed={voiceOn}
|
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(
|
className={cn(
|
||||||
'flex h-9 w-9 shrink-0 items-center justify-center rounded-full border transition-colors',
|
'flex h-9 w-9 shrink-0 items-center justify-center rounded-full border transition-colors',
|
||||||
voiceOn
|
voiceOn
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Loader2, Check, X, Wand2 } from 'lucide-react';
|
import { Loader2, Check, X, Wand2 } from 'lucide-react';
|
||||||
|
import { useTranslation } from '@/contexts/language';
|
||||||
import {
|
import {
|
||||||
summarizeDraft,
|
summarizeDraft,
|
||||||
type SasquatchDraft,
|
type SasquatchDraft,
|
||||||
|
|
@ -24,20 +25,21 @@ interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SasquatchActionCard({ draft, onPublish, onCancel, status, resultLabel }: Props) {
|
export function SasquatchActionCard({ draft, onPublish, onCancel, status, resultLabel }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [edited, setEdited] = useState<SasquatchDraft>(draft);
|
const [edited, setEdited] = useState<SasquatchDraft>(draft);
|
||||||
const summary = summarizeDraft(edited);
|
const summary = summarizeDraft(edited);
|
||||||
|
|
||||||
if (status === 'done') {
|
if (status === 'done') {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-[#ccff00]/30 bg-[#ccff00]/10 p-3 text-xs text-[#ccff00] flex items-center gap-2">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (status === 'cancelled') {
|
if (status === 'cancelled') {
|
||||||
return (
|
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">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -47,7 +49,7 @@ export function SasquatchActionCard({ draft, onPublish, onCancel, status, result
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-red-500/30 bg-red-500/[0.06] p-3 space-y-2.5">
|
<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">
|
<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>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm font-bold text-white">{summary.title}</p>
|
<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 })}
|
onChange={(e) => setEdited({ ...edited, text: e.target.value })}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
className="h-9 bg-black/40 border-white/10 text-white text-sm"
|
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">
|
<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 })}
|
onChange={(e) => setEdited({ ...edited, title: e.target.value })}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
className="col-span-2 h-9 bg-black/40 border-white/10 text-white text-sm"
|
className="col-span-2 h-9 bg-black/40 border-white/10 text-white text-sm"
|
||||||
placeholder="Title"
|
placeholder={t('sasquatchUi.titlePlaceholder')}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
value={edited.price ?? ''}
|
value={edited.price ?? ''}
|
||||||
|
|
@ -77,14 +79,14 @@ export function SasquatchActionCard({ draft, onPublish, onCancel, status, result
|
||||||
type="number"
|
type="number"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
className="h-9 bg-black/40 border-white/10 text-white text-sm"
|
className="h-9 bg-black/40 border-white/10 text-white text-sm"
|
||||||
placeholder="Price (sats)"
|
placeholder={t('sasquatchUi.priceSatsPlaceholder')}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
value={edited.condition ?? ''}
|
value={edited.condition ?? ''}
|
||||||
onChange={(e) => setEdited({ ...edited, condition: e.target.value })}
|
onChange={(e) => setEdited({ ...edited, condition: e.target.value })}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
className="h-9 bg-black/40 border-white/10 text-white text-sm"
|
className="h-9 bg-black/40 border-white/10 text-white text-sm"
|
||||||
placeholder="Condition"
|
placeholder={t('sasquatchUi.conditionPlaceholder')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -104,7 +106,7 @@ export function SasquatchActionCard({ draft, onPublish, onCancel, status, result
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5" /> Cancel
|
<X className="h-3.5 w-3.5" /> {t('sasquatchUi.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions';
|
import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions';
|
||||||
import { useSasquatchAiStatus } from '@/hooks/useSasquatchAiStatus';
|
import { useSasquatchAiStatus } from '@/hooks/useSasquatchAiStatus';
|
||||||
|
import { useTranslation } from '@/contexts/language';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -11,6 +12,7 @@ import { cn } from '@/lib/utils';
|
||||||
* needed, since the operator can see it live in Settings.
|
* needed, since the operator can see it live in Settings.
|
||||||
*/
|
*/
|
||||||
export function SasquatchAiStatusCard() {
|
export function SasquatchAiStatusCard() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
const isOwner = !!user?.pubkey && PLATFORM_OWNER_PUBKEYS.includes(user.pubkey);
|
const isOwner = !!user?.pubkey && PLATFORM_OWNER_PUBKEYS.includes(user.pubkey);
|
||||||
const { data, isLoading, isError } = useSasquatchAiStatus();
|
const { data, isLoading, isError } = useSasquatchAiStatus();
|
||||||
|
|
@ -30,9 +32,9 @@ export function SasquatchAiStatusCard() {
|
||||||
) : (
|
) : (
|
||||||
<Coins className="h-4 w-4 text-red-400" />
|
<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">
|
<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>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<span
|
<span
|
||||||
|
|
@ -44,18 +46,18 @@ export function SasquatchAiStatusCard() {
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
) : isError || data?.configured === false ? (
|
) : isError || data?.configured === false ? (
|
||||||
'not configured'
|
t('sasquatchUi.notConfigured')
|
||||||
) : balance === null || balance === undefined ? (
|
) : balance === null || balance === undefined ? (
|
||||||
'unavailable'
|
t('sasquatchUi.unavailable')
|
||||||
) : (
|
) : (
|
||||||
`${balance.toLocaleString()} sats`
|
t('sasquatchUi.satsValue', { balance: balance.toLocaleString() })
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
{low
|
{low
|
||||||
? `Running low — top up at ${data?.provider ?? 'the node'} or AI will fall back to the keyless provider.`
|
? t('sasquatchUi.lowBalance', { provider: data?.provider ?? t('sasquatchUi.theNode') })
|
||||||
: 'Pay-per-request AI, funded in sats via Routstr. Tops up when low.'}
|
: t('sasquatchUi.aiDesc')}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -576,6 +576,7 @@ function getStaticAnswer(question: string): string | null {
|
||||||
// ─── CVM connection status indicator ─────────────────────────────────────────
|
// ─── CVM connection status indicator ─────────────────────────────────────────
|
||||||
|
|
||||||
function CVMStatus({ connected }: { connected: boolean | null }) {
|
function CVMStatus({ connected }: { connected: boolean | null }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
if (connected === null) return null;
|
if (connected === null) return null;
|
||||||
return (
|
return (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
|
|
@ -585,9 +586,9 @@ function CVMStatus({ connected }: { connected: boolean | null }) {
|
||||||
: 'bg-white/5 text-gray-500'
|
: 'bg-white/5 text-gray-500'
|
||||||
)}>
|
)}>
|
||||||
{connected ? (
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -645,7 +646,7 @@ function ChatText({ text }: { text: string }) {
|
||||||
|
|
||||||
export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
|
export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
const { language } = useTranslation();
|
const { t, language } = useTranslation();
|
||||||
const cvm = useCVMClient();
|
const cvm = useCVMClient();
|
||||||
const personal = usePersonalContext();
|
const personal = usePersonalContext();
|
||||||
const { isPro, expiresAt, proof } = useSasquatchPro();
|
const { isPro, expiresAt, proof } = useSasquatchPro();
|
||||||
|
|
@ -849,7 +850,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
|
||||||
const answerNearestSpots = useCallback(async (): Promise<boolean> => {
|
const answerNearestSpots = useCallback(async (): Promise<boolean> => {
|
||||||
let loc = getLastLocation();
|
let loc = getLastLocation();
|
||||||
try {
|
try {
|
||||||
setStreamingText('📍 Getting your location…');
|
setStreamingText(t('sasquatchUi.gettingLocation'));
|
||||||
const fix = await getCurrentPosition();
|
const fix = await getCurrentPosition();
|
||||||
loc = { lat: fix.lat, lng: fix.lng, at: Date.now() };
|
loc = { lat: fix.lat, lng: fix.lng, at: Date.now() };
|
||||||
saveLastLocation(fix.lat, fix.lng);
|
saveLastLocation(fix.lat, fix.lng);
|
||||||
|
|
@ -868,7 +869,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setStreamingText('🔍 Finding the closest spots…');
|
setStreamingText(t('sasquatchUi.findingClosest'));
|
||||||
const res = await cvm.findSpots({ limit: 500 });
|
const res = await cvm.findSpots({ limit: 500 });
|
||||||
const nearest = (res.spots ?? [])
|
const nearest = (res.spots ?? [])
|
||||||
.map((s) => {
|
.map((s) => {
|
||||||
|
|
@ -910,7 +911,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
|
||||||
setStreamingText('');
|
setStreamingText('');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, [cvm, appendMessage]);
|
}, [cvm, appendMessage, t]);
|
||||||
|
|
||||||
// ── Core: handle a user question ───────────────────────────────────────────
|
// ── Core: handle a user question ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -990,7 +991,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
|
||||||
const fastIntent = isPersonalQuery ? null : parseLocalIntent(question);
|
const fastIntent = isPersonalQuery ? null : parseLocalIntent(question);
|
||||||
if (fastIntent && fastIntent.tool !== 'plan_trip') {
|
if (fastIntent && fastIntent.tool !== 'plan_trip') {
|
||||||
try {
|
try {
|
||||||
setStreamingText('🔍 Checking live data…');
|
setStreamingText(t('sasquatchUi.checkingLive'));
|
||||||
let fastResult: unknown;
|
let fastResult: unknown;
|
||||||
switch (fastIntent.tool) {
|
switch (fastIntent.tool) {
|
||||||
case 'find_spots':
|
case 'find_spots':
|
||||||
|
|
@ -1208,7 +1209,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setStreamingText(`🗺️ Planning your ${destination} trip...`);
|
setStreamingText(t('sasquatchUi.planningTrip', { destination }));
|
||||||
try {
|
try {
|
||||||
const itinerary = await runTripPlanner(destination, typeof a.days === 'number' ? a.days : 3);
|
const itinerary = await runTripPlanner(destination, typeof a.days === 'number' ? a.days : 3);
|
||||||
setStreamingText('');
|
setStreamingText('');
|
||||||
|
|
@ -1244,7 +1245,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
|
||||||
|
|
||||||
if (toolCall) {
|
if (toolCall) {
|
||||||
const { tool: toolName, args: toolArgs } = toolCall;
|
const { tool: toolName, args: toolArgs } = toolCall;
|
||||||
setStreamingText(`🔍 Searching the relays for ${toolName.replace(/_/g, ' ')}...`);
|
setStreamingText(t('sasquatchUi.searchingRelays', { tool: toolName.replace(/_/g, ' ') }));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Query the Nostr relays directly (useCVMClient) — aggregates across
|
// 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).
|
// Trip planning gets its own rich-card path (not a text blob).
|
||||||
if (intent && intent.tool === 'plan_trip' && intent.destination) {
|
if (intent && intent.tool === 'plan_trip' && intent.destination) {
|
||||||
try {
|
try {
|
||||||
setStreamingText(`🗺️ Planning your ${intent.destination} trip...`);
|
setStreamingText(t('sasquatchUi.planningTrip', { destination: intent.destination }));
|
||||||
const itinerary = await runTripPlanner(intent.destination, intent.days);
|
const itinerary = await runTripPlanner(intent.destination, intent.days);
|
||||||
setStreamingText('');
|
setStreamingText('');
|
||||||
if (itinerary.empty) {
|
if (itinerary.empty) {
|
||||||
|
|
@ -1421,7 +1422,7 @@ Instructions:
|
||||||
|
|
||||||
if (intent && intent.tool !== 'plan_trip') {
|
if (intent && intent.tool !== 'plan_trip') {
|
||||||
try {
|
try {
|
||||||
setStreamingText('🔍 Checking live data...');
|
setStreamingText(t('sasquatchUi.checkingLive'));
|
||||||
let localResult: unknown;
|
let localResult: unknown;
|
||||||
switch (intent.tool) {
|
switch (intent.tool) {
|
||||||
case 'find_spots':
|
case 'find_spots':
|
||||||
|
|
@ -1488,7 +1489,7 @@ Instructions:
|
||||||
setIsThinking(false);
|
setIsThinking(false);
|
||||||
setStreamingText('');
|
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
|
// Listen for external events from FAQ/About pages
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -1543,18 +1544,18 @@ Instructions:
|
||||||
// Build a fallback generic message for pages with no specific context
|
// Build a fallback generic message for pages with no specific context
|
||||||
const activeMessage = message ?? {
|
const activeMessage = message ?? {
|
||||||
id: 'generic',
|
id: 'generic',
|
||||||
title: 'SkateSpots Assistant 🦧',
|
title: t('sasquatchUi.genericTitle'),
|
||||||
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!",
|
message: t('sasquatchUi.genericGreeting'),
|
||||||
context: undefined,
|
context: undefined,
|
||||||
suggestedQuestions: [
|
suggestedQuestions: [
|
||||||
'Plan me 3 days in Lisbon',
|
t('sasquatchUi.q1'),
|
||||||
'Find spots near me',
|
t('sasquatchUi.q2'),
|
||||||
'Any upcoming events?',
|
t('sasquatchUi.q3'),
|
||||||
'What are crews?',
|
t('sasquatchUi.q4'),
|
||||||
'How do zaps work?',
|
t('sasquatchUi.q5'),
|
||||||
'What is Nostr?',
|
t('sasquatchUi.q6'),
|
||||||
'What is My Stash?',
|
t('sasquatchUi.q7'),
|
||||||
'Is this free?',
|
t('sasquatchUi.q8'),
|
||||||
],
|
],
|
||||||
enableChat: true,
|
enableChat: true,
|
||||||
};
|
};
|
||||||
|
|
@ -1577,7 +1578,7 @@ Instructions:
|
||||||
onPointerMove={onPointerMove}
|
onPointerMove={onPointerMove}
|
||||||
onPointerUp={onPointerUp}
|
onPointerUp={onPointerUp}
|
||||||
onClick={onBubbleClick}
|
onClick={onBubbleClick}
|
||||||
title="Press and drag to move · Tap to open"
|
title={t('sasquatchUi.bubbleTitle')}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -1588,7 +1589,7 @@ Instructions:
|
||||||
background: 'radial-gradient(circle at 40% 35%, #2d4a1e 0%, #1a2e0f 50%, #0d1a07 100%)',
|
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)',
|
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
|
<img
|
||||||
src="/images/sasquatch-guide.webp"
|
src="/images/sasquatch-guide.webp"
|
||||||
|
|
@ -1655,7 +1656,7 @@ Instructions:
|
||||||
{isPro ? (
|
{isPro ? (
|
||||||
<span
|
<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]"
|
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` : ''}
|
⚡ Pro{proDaysLeft !== null ? ` · ${proDaysLeft}d` : ''}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -1663,10 +1664,10 @@ Instructions:
|
||||||
<button
|
<button
|
||||||
onClick={() => setProDialogOpen(true)}
|
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"
|
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>
|
<span aria-hidden>⚡</span>
|
||||||
Go Pro
|
{t('sasquatchUi.goPro')}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1681,9 +1682,9 @@ Instructions:
|
||||||
'ml-auto flex-shrink-0 rounded px-1 py-0.5 text-[8px] font-black uppercase tracking-wider',
|
'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',
|
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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1693,8 +1694,8 @@ Instructions:
|
||||||
<button
|
<button
|
||||||
onClick={() => { if (recentlyOpened()) return; setIsMinimized(true); }}
|
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"
|
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"
|
aria-label={t('sasquatchUi.minimizeToBubbleAria')}
|
||||||
title="Minimize"
|
title={t('sasquatchUi.minimize')}
|
||||||
>
|
>
|
||||||
<Minimize2 className="h-3 w-3" />
|
<Minimize2 className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -1702,8 +1703,8 @@ Instructions:
|
||||||
<button
|
<button
|
||||||
onClick={() => { if (recentlyOpened()) return; setIsVisible(false); if (onDismiss) onDismiss(); }}
|
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"
|
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"
|
aria-label={t('sasquatchUi.closePanelAria')}
|
||||||
title="Close"
|
title={t('sasquatchUi.close')}
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</button>
|
</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"
|
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" />
|
<HelpCircle className="h-3 w-3" />
|
||||||
{activeMessage.context === 'lab' && 'Ask about Coaching & Filming'}
|
{activeMessage.context === 'lab' && t('sasquatchUi.askLab')}
|
||||||
{activeMessage.context === 'map' && 'Ask about Spots, Shops & Gear'}
|
{activeMessage.context === 'map' && t('sasquatchUi.askMap')}
|
||||||
{(activeMessage.context === 'home' || activeMessage.context === 'onboarding') && 'Ask me anything 🛹'}
|
{(activeMessage.context === 'home' || activeMessage.context === 'onboarding') && t('sasquatchUi.askAnything')}
|
||||||
{(!activeMessage.context || (activeMessage.context !== 'lab' && activeMessage.context !== 'map' && activeMessage.context !== 'home' && activeMessage.context !== 'onboarding')) && 'Ask me anything 🛹'}
|
{(!activeMessage.context || (activeMessage.context !== 'lab' && activeMessage.context !== 'map' && activeMessage.context !== 'home' && activeMessage.context !== 'onboarding')) && t('sasquatchUi.askAnything')}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-2.5">
|
||||||
|
|
@ -1752,7 +1753,7 @@ Instructions:
|
||||||
{chatMessages.length === 0 && activeMessage.suggestedQuestions && (
|
{chatMessages.length === 0 && activeMessage.suggestedQuestions && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="text-[9px] text-[#ccff00]/50 font-bold uppercase tracking-wider mb-1.5">
|
<div className="text-[9px] text-[#ccff00]/50 font-bold uppercase tracking-wider mb-1.5">
|
||||||
Quick questions:
|
{t('sasquatchUi.quickQuestions')}
|
||||||
</div>
|
</div>
|
||||||
{activeMessage.suggestedQuestions.map((q) => (
|
{activeMessage.suggestedQuestions.map((q) => (
|
||||||
<button
|
<button
|
||||||
|
|
@ -1782,10 +1783,10 @@ Instructions:
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="font-bold text-[9px] mb-1 flex items-center gap-1 opacity-60">
|
<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 && (
|
{msg.isLive && (
|
||||||
<span className="text-[#ccff00] bg-[#ccff00]/10 px-1 rounded text-[8px]">
|
<span className="text-[#ccff00] bg-[#ccff00]/10 px-1 rounded text-[8px]">
|
||||||
live
|
{t('sasquatchUi.live')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1814,7 +1815,7 @@ Instructions:
|
||||||
) : (
|
) : (
|
||||||
<span className="flex items-center gap-1.5 text-[#ccff00]/60">
|
<span className="flex items-center gap-1.5 text-[#ccff00]/60">
|
||||||
<Loader2 className="h-2.5 w-2.5 animate-spin" />
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1829,7 +1830,7 @@ Instructions:
|
||||||
value={userInput}
|
value={userInput}
|
||||||
onChange={(e) => setUserInput(e.target.value)}
|
onChange={(e) => setUserInput(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && !inputDisabled && handleSendMessage()}
|
onKeyDown={(e) => e.key === 'Enter' && !inputDisabled && handleSendMessage()}
|
||||||
placeholder="Ask about spots, events, coaches..."
|
placeholder={t('sasquatchUi.inputPlaceholder')}
|
||||||
disabled={inputDisabled}
|
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"
|
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">
|
<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">
|
<p className="text-[9px] text-gray-600 flex items-center gap-1.5 flex-wrap min-w-0">
|
||||||
<CVMStatus connected={cvmConnected} />
|
<CVMStatus connected={cvmConnected} />
|
||||||
Live data from{' '}
|
{t('sasquatchUi.liveDataFrom')}{' '}
|
||||||
<a
|
<a
|
||||||
href="https://goskatespots.nostr1.com"
|
href="https://goskatespots.nostr1.com"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-[#ccff00]/60 hover:text-[#ccff00] underline underline-offset-1 transition-colors"
|
className="text-[#ccff00]/60 hover:text-[#ccff00] underline underline-offset-1 transition-colors"
|
||||||
>
|
>
|
||||||
Nostr relays
|
{t('sasquatchUi.nostrRelays')}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
{chatMessages.length > 0 && (
|
{chatMessages.length > 0 && (
|
||||||
|
|
@ -1865,7 +1866,7 @@ Instructions:
|
||||||
onClick={() => { setChatMessages([]); setShowChat(false); setCvmConnected(null); }}
|
onClick={() => { setChatMessages([]); setShowChat(false); setCvmConnected(null); }}
|
||||||
className="text-[9px] text-gray-600 hover:text-[#ccff00] transition-colors flex-shrink-0"
|
className="text-[9px] text-gray-600 hover:text-[#ccff00] transition-colors flex-shrink-0"
|
||||||
>
|
>
|
||||||
clear
|
{t('sasquatchUi.clear')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import { usePurchasePro } from '@/hooks/usePurchasePro';
|
||||||
import { useExchangeRate } from '@/hooks/useExchangeRate';
|
import { useExchangeRate } from '@/hooks/useExchangeRate';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
import { PRO_TIERS, PRO_LIGHTNING_ADDRESS, type ProTier } from '@/lib/sasquatchPro';
|
import { PRO_TIERS, PRO_LIGHTNING_ADDRESS, type ProTier } from '@/lib/sasquatchPro';
|
||||||
|
import { useTranslation } from '@/contexts/language';
|
||||||
import {
|
import {
|
||||||
Zap,
|
Zap,
|
||||||
Check,
|
Check,
|
||||||
|
|
@ -34,11 +35,11 @@ import {
|
||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const PERKS: Array<{ icon: React.ComponentType<{ className?: string }>; text: string }> = [
|
const PERKS: Array<{ icon: React.ComponentType<{ className?: string }>; textKey: string }> = [
|
||||||
{ icon: Brain, text: 'Personal assistant — Sasquatch reads your own Stash, crews, spots & listings' },
|
{ icon: Brain, textKey: 'sasquatchUi.perk1' },
|
||||||
{ icon: Wand2, text: 'Action drafts: list gear, post a skate status, plan a trip — you confirm before anything publishes' },
|
{ icon: Wand2, textKey: 'sasquatchUi.perk2' },
|
||||||
{ icon: Rocket, text: 'Answers use your personal context, not just public data' },
|
{ icon: Rocket, textKey: 'sasquatchUi.perk3' },
|
||||||
{ icon: BadgeCheck, text: '⚡ Directly support SkateSpots & keep it free and ad-free' },
|
{ icon: BadgeCheck, textKey: 'sasquatchUi.perk4' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function SasquatchProDialog({
|
export function SasquatchProDialog({
|
||||||
|
|
@ -49,6 +50,7 @@ export function SasquatchProDialog({
|
||||||
onOpenChange: (v: boolean) => void;
|
onOpenChange: (v: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
|
const { t } = useTranslation();
|
||||||
const { isPro, expiresAt } = useSasquatchPro();
|
const { isPro, expiresAt } = useSasquatchPro();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const purchase = usePurchasePro();
|
const purchase = usePurchasePro();
|
||||||
|
|
@ -74,16 +76,16 @@ export function SasquatchProDialog({
|
||||||
// Toast + close on success.
|
// Toast + close on success.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (purchase.state === 'paid') {
|
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.' });
|
toast({ title: t('sasquatchUi.welcomeTitle'), description: t('sasquatchUi.welcomeDesc') });
|
||||||
const t = setTimeout(() => { onOpenChange(false); purchase.reset(); }, 1400);
|
const timer = setTimeout(() => { onOpenChange(false); purchase.reset(); }, 1400);
|
||||||
return () => clearTimeout(t);
|
return () => clearTimeout(timer);
|
||||||
}
|
}
|
||||||
}, [purchase.state, toast, onOpenChange, purchase]);
|
}, [purchase.state, toast, onOpenChange, purchase, t]);
|
||||||
|
|
||||||
const copyInvoice = () => {
|
const copyInvoice = () => {
|
||||||
if (purchase.invoice) {
|
if (purchase.invoice) {
|
||||||
navigator.clipboard.writeText(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>
|
<span className="text-2xl">🦧</span> Sasquatch <span className="text-red-500">Pro</span>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-gray-400">
|
<DialogDescription className="text-gray-400">
|
||||||
Supercharge your AI skate guide. 100% funds a free, no-ads community app.
|
{t('sasquatchUi.dialogDesc')}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|
@ -107,23 +109,23 @@ export function SasquatchProDialog({
|
||||||
<div className="space-y-4 py-2">
|
<div className="space-y-4 py-2">
|
||||||
<div className="rounded-2xl border border-[#ccff00]/30 bg-[#ccff00]/10 p-4 text-center">
|
<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]" />
|
<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 && (
|
{expiresAt && (
|
||||||
<p className="mt-1 text-xs text-gray-300">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{PERKS.map((p) => (
|
{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]" />
|
<Check className="mt-0.5 h-4 w-4 shrink-0 text-[#ccff00]" />
|
||||||
{p.text}
|
{t(p.textKey)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-center text-xs text-gray-500">
|
<p className="text-center text-xs text-gray-500">
|
||||||
Want to extend? Pick a tier below to add more time.
|
{t('sasquatchUi.extendPrompt')}
|
||||||
</p>
|
</p>
|
||||||
<TierPicker selected={selected} onSelect={setSelected} />
|
<TierPicker selected={selected} onSelect={setSelected} />
|
||||||
<PayButton tier={selected} busy={busy} onPay={() => purchase.purchase(selected)} extend />
|
<PayButton tier={selected} busy={busy} onPay={() => purchase.purchase(selected)} extend />
|
||||||
|
|
@ -132,26 +134,28 @@ export function SasquatchProDialog({
|
||||||
// ── Manual QR payment ───────────────────────────────────────────
|
// ── Manual QR payment ───────────────────────────────────────────
|
||||||
<div className="space-y-4 py-2 text-center">
|
<div className="space-y-4 py-2 text-center">
|
||||||
<p className="text-sm text-gray-300">
|
<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>
|
</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">
|
<div className="flex gap-2">
|
||||||
<Button variant="outline" className="flex-1 gap-2 border-white/15" onClick={copyInvoice}>
|
<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>
|
||||||
<Button
|
<Button
|
||||||
className="flex-1 gap-2 bg-[#ccff00] text-black hover:bg-[#d4ff1a] font-bold"
|
className="flex-1 gap-2 bg-[#ccff00] text-black hover:bg-[#d4ff1a] font-bold"
|
||||||
onClick={() => purchase.confirmManual(selected)}
|
onClick={() => purchase.confirmManual(selected)}
|
||||||
>
|
>
|
||||||
<Check className="h-4 w-4" /> I've paid
|
<Check className="h-4 w-4" /> {t('sasquatchUi.ivePaid')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-gray-400">
|
<p className="text-[11px] text-gray-400">
|
||||||
Paying to{' '}
|
{t('sasquatchUi.paysTo')}{' '}
|
||||||
<span className="font-mono text-gray-200">⚡ {PRO_LIGHTNING_ADDRESS}</span>
|
<span className="font-mono text-gray-200">⚡ {PRO_LIGHTNING_ADDRESS}</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[11px] text-gray-500">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -159,22 +163,21 @@ export function SasquatchProDialog({
|
||||||
<div className="space-y-4 py-2">
|
<div className="space-y-4 py-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{PERKS.map((p) => (
|
{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.icon className="mt-0.5 h-4 w-4 shrink-0 text-red-400" />
|
||||||
{p.text}
|
{t(p.textKey)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-xl border border-white/10 bg-white/[0.03] p-3 text-xs text-gray-400">
|
<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
|
{t('sasquatchUi.freeStaysPre')}<span className="text-white font-semibold">{t('sasquatchUi.freeStaysEmphasis')}</span>{t('sasquatchUi.freeStaysPost')}
|
||||||
+ the full trick glossary. Pro adds the <span className="text-white font-semibold">personal + action</span> layer.
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TierPicker selected={selected} onSelect={setSelected} />
|
<TierPicker selected={selected} onSelect={setSelected} />
|
||||||
|
|
||||||
{!user && (
|
{!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 && (
|
{purchase.error && (
|
||||||
<p className="text-center text-xs text-red-400">{purchase.error}</p>
|
<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} />
|
<PayButton tier={selected} busy={busy} onPay={() => purchase.purchase(selected)} disabled={!user} />
|
||||||
|
|
||||||
<p className="text-center text-[11px] text-gray-500">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -195,15 +198,16 @@ export function SasquatchProDialog({
|
||||||
function TierPicker({ selected, onSelect }: { selected: ProTier; onSelect: (t: ProTier) => void }) {
|
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).
|
// Live BTC/USD so the fiat hint never goes stale (it used to be hardcoded).
|
||||||
const { data: btcUsd } = useExchangeRate();
|
const { data: btcUsd } = useExchangeRate();
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{PRO_TIERS.map((t) => {
|
{PRO_TIERS.map((tier) => {
|
||||||
const active = t.id === selected.id;
|
const active = tier.id === selected.id;
|
||||||
const usd = btcUsd ? `~$${((t.sats / 1e8) * btcUsd).toFixed(2)}` : '';
|
const usd = btcUsd ? `~$${((tier.sats / 1e8) * btcUsd).toFixed(2)}` : '';
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={t.id}
|
key={tier.id}
|
||||||
onClick={() => onSelect(t)}
|
onClick={() => onSelect(tier)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative rounded-xl border p-3 text-left transition-all',
|
'relative rounded-xl border p-3 text-left transition-all',
|
||||||
active
|
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',
|
: '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">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
<p className="text-sm font-black text-white">{t.label}</p>
|
<p className="text-sm font-black text-white">{tier.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-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, t.badge].filter(Boolean).join(' · ')}</p>
|
<p className="text-[10px] text-gray-500">{[usd, tier.badge].filter(Boolean).join(' · ')}</p>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -239,6 +243,7 @@ function PayButton({
|
||||||
extend?: boolean;
|
extend?: boolean;
|
||||||
onPay: () => void;
|
onPay: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
className="w-full gap-2 bg-red-500 hover:bg-red-600 font-black uppercase tracking-wide h-12"
|
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}
|
disabled={busy || disabled}
|
||||||
>
|
>
|
||||||
{busy ? <Loader2 className="h-5 w-5 animate-spin" /> : <Zap className="h-5 w-5" />}
|
{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>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { useTranslation } from '@/contexts/language';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Branded cold-start splash — a choreographed, premium launch moment.
|
* 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 FADE_MS = 480;
|
||||||
const READY_HOLD_MS = 340; // savour the 100% "ready" beat before revealing
|
const READY_HOLD_MS = 340; // savour the 100% "ready" beat before revealing
|
||||||
|
|
||||||
const STATUS_LINES = [
|
const STATUS_KEYS = [
|
||||||
'Waking the network',
|
'bootUi.statusWaking',
|
||||||
'Tuning the relays',
|
'bootUi.statusTuning',
|
||||||
'Pulling in spots',
|
'bootUi.statusPulling',
|
||||||
'Dropping in',
|
'bootUi.statusDropping',
|
||||||
];
|
];
|
||||||
|
|
||||||
export function SplashScreen() {
|
export function SplashScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [visible, setVisible] = useState(() => {
|
const [visible, setVisible] = useState(() => {
|
||||||
try {
|
try {
|
||||||
return sessionStorage.getItem(SESSION_KEY) !== 'true';
|
return sessionStorage.getItem(SESSION_KEY) !== 'true';
|
||||||
|
|
@ -58,7 +61,7 @@ export function SplashScreen() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible || ready) return;
|
if (!visible || ready) return;
|
||||||
const id = setInterval(() => {
|
const id = setInterval(() => {
|
||||||
setStatusIndex((i) => (i + 1) % STATUS_LINES.length);
|
setStatusIndex((i) => (i + 1) % STATUS_KEYS.length);
|
||||||
}, 640);
|
}, 640);
|
||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, [visible, ready]);
|
}, [visible, ready]);
|
||||||
|
|
@ -149,7 +152,7 @@ export function SplashScreen() {
|
||||||
}}
|
}}
|
||||||
role="status"
|
role="status"
|
||||||
aria-live="polite"
|
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 */}
|
{/* Fine grid texture — subtle depth, like a skate map underlay */}
|
||||||
<div
|
<div
|
||||||
|
|
@ -268,7 +271,7 @@ export function SplashScreen() {
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Ready
|
{t('bootUi.ready')}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span
|
<span
|
||||||
|
|
@ -276,7 +279,7 @@ export function SplashScreen() {
|
||||||
className="font-mono text-[11px] uppercase tracking-[0.28em] text-gray-500"
|
className="font-mono text-[11px] uppercase tracking-[0.28em] text-gray-500"
|
||||||
style={anim('splashFade 360ms ease-out')}
|
style={anim('splashFade 360ms ease-out')}
|
||||||
>
|
>
|
||||||
{STATUS_LINES[statusIndex]}
|
{t(STATUS_KEYS[statusIndex])}
|
||||||
{!prefersReducedMotion && <span className="ml-0.5 animate-pulse">…</span>}
|
{!prefersReducedMotion && <span className="ml-0.5 animate-pulse">…</span>}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
} from '@/lib/routing';
|
} from '@/lib/routing';
|
||||||
import { transitLegColor, transitModeEmoji } from '@/lib/transitUi';
|
import { transitLegColor, transitModeEmoji } from '@/lib/transitUi';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useTranslation } from '@/contexts/language';
|
||||||
|
|
||||||
interface StopDeparturesProps {
|
interface StopDeparturesProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -29,6 +30,7 @@ interface StopDeparturesProps {
|
||||||
* schedule data, never invented, and says so when nothing comes back.
|
* schedule data, never invented, and says so when nothing comes back.
|
||||||
*/
|
*/
|
||||||
export function StopDepartures({ open, onOpenChange, stopId, stopName }: StopDeparturesProps) {
|
export function StopDepartures({ open, onOpenChange, stopId, stopName }: StopDeparturesProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [rows, setRows] = useState<StopDeparture[] | null>(null);
|
const [rows, setRows] = useState<StopDeparture[] | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [reloadKey, setReloadKey] = useState(0);
|
const [reloadKey, setReloadKey] = useState(0);
|
||||||
|
|
@ -47,7 +49,7 @@ export function StopDepartures({ open, onOpenChange, stopId, stopName }: StopDep
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (err instanceof DOMException && err.name === 'AbortError') 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;
|
cancelled = true;
|
||||||
controller.abort();
|
controller.abort();
|
||||||
};
|
};
|
||||||
}, [open, stopId, reloadKey]);
|
}, [open, stopId, reloadKey, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<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">
|
<DialogHeader className="border-b px-4 py-3">
|
||||||
<DialogTitle className="text-base">
|
<DialogTitle className="text-base">
|
||||||
<span className="block text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
<span className="block text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
Departures
|
{t('routeUi.departures')}
|
||||||
</span>
|
</span>
|
||||||
<span className="block truncate">{stopName}</span>
|
<span className="block truncate">{stopName}</span>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="sr-only">
|
<DialogDescription className="sr-only">
|
||||||
Upcoming departures from {stopName}
|
{t('routeUi.upcomingFrom', { stop: stopName })}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|
@ -77,11 +79,11 @@ export function StopDepartures({ open, onOpenChange, stopId, stopName }: StopDep
|
||||||
<div className="p-4 text-sm text-amber-500">{error}</div>
|
<div className="p-4 text-sm text-amber-500">{error}</div>
|
||||||
) : !rows ? (
|
) : !rows ? (
|
||||||
<div className="flex items-center justify-center gap-2 p-6 text-sm text-muted-foreground">
|
<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>
|
</div>
|
||||||
) : rows.length === 0 ? (
|
) : rows.length === 0 ? (
|
||||||
<div className="p-6 text-center text-sm text-muted-foreground">
|
<div className="p-6 text-center text-sm text-muted-foreground">
|
||||||
No upcoming departures found at this stop.
|
{t('routeUi.noDepartures')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-y">
|
<ul className="divide-y">
|
||||||
|
|
@ -112,12 +114,12 @@ export function StopDepartures({ open, onOpenChange, stopId, stopName }: StopDep
|
||||||
</span>
|
</span>
|
||||||
{d.realTime && !d.cancelled && (
|
{d.realTime && !d.cancelled && (
|
||||||
<span className="shrink-0 rounded bg-[#00ff88]/15 px-1 text-[9px] font-black uppercase text-[#00ff88]">
|
<span className="shrink-0 rounded bg-[#00ff88]/15 px-1 text-[9px] font-black uppercase text-[#00ff88]">
|
||||||
live
|
{t('routeUi.live')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{d.cancelled && (
|
{d.cancelled && (
|
||||||
<span className="shrink-0 rounded bg-red-500/15 px-1 text-[9px] font-black uppercase text-red-400">
|
<span className="shrink-0 rounded bg-red-500/15 px-1 text-[9px] font-black uppercase text-red-400">
|
||||||
cancelled
|
{t('routeUi.cancelled')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
|
|
@ -133,7 +135,7 @@ export function StopDepartures({ open, onOpenChange, stopId, stopName }: StopDep
|
||||||
onClick={() => setReloadKey((k) => k + 1)}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import { travelLinks } from '@/lib/tripPlanner';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useCreateSpotCollection } from '@/hooks/useSpotCollections';
|
import { useCreateSpotCollection } from '@/hooks/useSpotCollections';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
import { useTranslation } from '@/contexts/language';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const KIND_ICON = {
|
const KIND_ICON = {
|
||||||
|
|
@ -23,10 +24,18 @@ const KIND_ICON = {
|
||||||
coach: GraduationCap,
|
coach: GraduationCap,
|
||||||
} as const;
|
} 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 }) {
|
export function TripItineraryCard({ itinerary }: { itinerary: Itinerary }) {
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
const { createCollection, isPending } = useCreateSpotCollection();
|
const { createCollection, isPending } = useCreateSpotCollection();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { t } = useTranslation();
|
||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
|
|
||||||
const w = itinerary.weather;
|
const w = itinerary.weather;
|
||||||
|
|
@ -34,21 +43,27 @@ export function TripItineraryCard({ itinerary }: { itinerary: Itinerary }) {
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!user) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
const dayCount = itinerary.days.length;
|
||||||
await createCollection({
|
await createCollection({
|
||||||
title: `${itinerary.destination} skate trip 🛹`,
|
title: t('routeUi.tripCollectionTitle', { destination: itinerary.destination }),
|
||||||
description: `Trip plan by The Sasquatch — ${itinerary.days.length} days in ${itinerary.destination}.`,
|
description: dayCount === 1
|
||||||
|
? t('routeUi.tripCollectionDescOne', { count: dayCount, destination: itinerary.destination })
|
||||||
|
: t('routeUi.tripCollectionDesc', { count: dayCount, destination: itinerary.destination }),
|
||||||
spotRefs: itinerary.allSpotRefs,
|
spotRefs: itinerary.allSpotRefs,
|
||||||
});
|
});
|
||||||
setSaved(true);
|
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) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: "Couldn't save",
|
title: t('routeUi.tripSaveFailed'),
|
||||||
description: err instanceof Error ? err.message : 'Try again in a sec.',
|
description: err instanceof Error ? err.message : t('routeUi.tripSaveFailedDesc'),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -64,7 +79,9 @@ export function TripItineraryCard({ itinerary }: { itinerary: Itinerary }) {
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-[13px]">🛹</span>
|
<span className="text-[13px]">🛹</span>
|
||||||
<span className="font-black text-[11px] text-[#ccff00] uppercase tracking-wider truncate">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{w && (
|
{w && (
|
||||||
|
|
@ -82,7 +99,7 @@ export function TripItineraryCard({ itinerary }: { itinerary: Itinerary }) {
|
||||||
{rainy && (
|
{rainy && (
|
||||||
<div className="mt-1 flex items-start gap-1 text-[9px] text-orange-300/90">
|
<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" />
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -92,10 +109,10 @@ export function TripItineraryCard({ itinerary }: { itinerary: Itinerary }) {
|
||||||
{itinerary.days.map((day) => (
|
{itinerary.days.map((day) => (
|
||||||
<div key={day.dayNumber} className="px-2.5 py-2">
|
<div key={day.dayNumber} className="px-2.5 py-2">
|
||||||
<div className="text-[9px] font-black text-[#ccff00]/70 uppercase tracking-widest mb-1">
|
<div className="text-[9px] font-black text-[#ccff00]/70 uppercase tracking-widest mb-1">
|
||||||
📅 {day.label}
|
📅 {t('routeUi.dayLabel', { n: day.dayNumber })}
|
||||||
</div>
|
</div>
|
||||||
{day.stops.length === 0 ? (
|
{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">
|
<div className="space-y-1">
|
||||||
{day.stops.map((stop, i) => {
|
{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>
|
<div className="text-[8px] text-gray-500 truncate">{stop.subtitle}</div>
|
||||||
)}
|
)}
|
||||||
{stop.kind === 'coach' && (
|
{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>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -138,7 +155,7 @@ export function TripItineraryCard({ itinerary }: { itinerary: Itinerary }) {
|
||||||
{itinerary.crews.length > 0 && (
|
{itinerary.crews.length > 0 && (
|
||||||
<div className="px-2.5 py-2 border-t border-white/5">
|
<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">
|
<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>
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{itinerary.crews.map((c, i) => (
|
{itinerary.crews.map((c, i) => (
|
||||||
|
|
@ -170,11 +187,13 @@ export function TripItineraryCard({ itinerary }: { itinerary: Itinerary }) {
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<><Loader2 className="h-3 w-3 animate-spin" /> Saving…</>
|
<><Loader2 className="h-3 w-3 animate-spin" /> {t('routeUi.saving')}</>
|
||||||
) : saved ? (
|
) : 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>
|
</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"
|
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>{link.emoji}</span>
|
||||||
<span className="truncate">{link.label}</span>
|
<span className="truncate">{t(TRAVEL_LINK_KEYS[link.label] ?? link.label)}</span>
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[8px] text-gray-600 flex items-center gap-1 justify-center">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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
|
* English translations
|
||||||
* Note: Skateboarding trick names and terminology stay in English
|
* Note: Skateboarding trick names and terminology stay in English
|
||||||
*/
|
*/
|
||||||
|
import { batch3En } from './batch3';
|
||||||
|
|
||||||
const en = {
|
const en = {
|
||||||
// Common
|
// Common
|
||||||
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
|
* Note: Skateboarding trick names and terminology stay in English
|
||||||
* (kickflip, ollie, grind, etc. are universal in skate culture)
|
* (kickflip, ollie, grind, etc. are universal in skate culture)
|
||||||
*/
|
*/
|
||||||
|
import { batch3Es } from './batch3';
|
||||||
|
|
||||||
const es = {
|
const es = {
|
||||||
// Common
|
// Common
|
||||||
common: {
|
common: {
|
||||||
|
|
@ -6527,4 +6529,4 @@ walletUi: {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default es;
|
export default { ...es, ...batch3Es };
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue