i18n(es): batch 3 part 3 — shared label maps + leftover routing/Sasquatch strings

- labelsUi.* (221+ keys): spot categories/features/types, access, shelter, event
  types, gear categories/conditions, price/listings, tours, crews,
  member-visibility, tag categories, spot status. New src/lib/labelKeys.ts holds
  the shared value->key maps (one source; the five duplicated EVENT_TYPE_KEYS
  copies now import it). spotStatus.statusTimeAgo() uses the locale date shim.
- formatPrice() now takes an optional t so FREE/Trade/Make Offer translate.
- Route helpers: skateTime/distanceReadout/maneuver descriptors/formatAge
  translated (routeUi.*), consumers updated.
- Sasquatch: PRO_TIERS labels/badges + summarizeDraft titles/lines/ctas
  translated (sasquatchUi.*).
- Lab *_LABELS/_DESCRIPTIONS maps remain English (documented out of scope).
This commit is contained in:
SkateSpots 2026-10-02 15:31:27 +07:00
parent 9357386bbd
commit e357833557
49 changed files with 1308 additions and 279 deletions

View file

@ -16,6 +16,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { genUserName } from '@/lib/genUserName';
import { useCollectiveMembers, type CollectiveMember } from '@/hooks/useCollectiveMembers';
import { CREW_ROLE_META, type CrewRole } from '@/lib/types';
import { CREW_ROLE_KEYS } from '@/lib/labelKeys';
import { cn } from '@/lib/utils';
interface CollectiveMembersProps {
@ -33,10 +34,11 @@ interface CollectiveMembersProps {
/** Skate-role flex chip (🎥 Filmer, 📷 Photographer, …). */
function RoleChip({ role }: { role: CrewRole }) {
const { t } = useTranslation();
const meta = CREW_ROLE_META[role];
return (
<span className="inline-flex items-center gap-1 rounded-full bg-white/10 px-2 py-0.5 text-xs font-bold text-gray-200">
<span className="leading-none">{meta.emoji}</span> {meta.label}
<span className="leading-none">{meta.emoji}</span> {CREW_ROLE_KEYS[role] ? t(CREW_ROLE_KEYS[role]) : meta.label}
</span>
);
}

View file

@ -12,6 +12,7 @@ import {
MEMBER_VISIBILITY_DESCRIPTIONS,
type MemberVisibility,
} from '@/lib/types';
import { MEMBER_VISIBILITY_DESC_KEYS } from '@/lib/labelKeys';
interface ConfirmMembershipCardProps {
kind: number;
@ -61,7 +62,9 @@ export function VisibilityPicker({
})}
</div>
<p className="text-xs leading-relaxed text-gray-500">
{MEMBER_VISIBILITY_DESCRIPTIONS[value]}
{MEMBER_VISIBILITY_DESC_KEYS[value]
? t(MEMBER_VISIBILITY_DESC_KEYS[value])
: MEMBER_VISIBILITY_DESCRIPTIONS[value]}
</p>
</div>
);

View file

@ -22,6 +22,7 @@ import { isCrewVideoUrl, parseCrewVideo } from '@/lib/videoEmbed';
import { CrewSponsorPicker } from '@/components/crew/CrewSponsorPicker';
import { NofferField } from '@/components/NofferField';
import { SKATE_CREW_KIND, SKATE_SPOT_KIND, SKATE_SHOP_KIND, parseAddrCoord, ZAP_GOAL_KIND, CREW_VIBE_LABELS, CREW_FOCUS_LABELS, CREW_FOCUS_DESCRIPTIONS, CREW_FOCUS_TAG, CREW_LOOKING_FOR_OPTIONS, CREW_ROLE_META, CREW_ROLE_ORDER, SHOW_CREW_TREASURY, isValidNoffer, type SkateCrew, type CrewFocus, type CrewRole } from '@/lib/types';
import { CREW_VIBE_KEYS, CREW_FOCUS_KEYS, CREW_FOCUS_DESC_KEYS, CREW_LOOKING_FOR_KEYS, CREW_ROLE_KEYS } from '@/lib/labelKeys';
import { useCrewGoal } from '@/hooks/useCrewGoal';
import { useCoordinator } from '@/hooks/useCoordinator';
import { coordinatorAddUrl, crewCoordinatorInputValue, parseCrewCoordinatorInput, shortNpub } from '@/lib/coordinator';
@ -40,7 +41,7 @@ const FOCUS_ICONS: Record<CrewFocus, typeof Users> = {
wellness: Dumbbell,
};
const VIBE_ENTRIES = Object.entries(CREW_VIBE_LABELS);
const VIBE_ENTRIES = Object.keys(CREW_VIBE_LABELS);
const TEAL = 'bg-teal-500 hover:bg-teal-600 text-black';
/** Number of steps in the create wizard (0-indexed last step). Keep in sync with `steps`. */
const WIZARD_LAST_STEP = 3;
@ -121,7 +122,7 @@ function MemberRow({
)}
aria-pressed={active}
>
<span className="leading-none">{meta.emoji}</span> {meta.label}
<span className="leading-none">{meta.emoji}</span> {CREW_ROLE_KEYS[role] ? t(CREW_ROLE_KEYS[role]) : meta.label}
</button>
);
})}
@ -666,7 +667,7 @@ export function CrewForm({ existing }: CrewFormProps) {
<div className="space-y-3">
<Label>{t('socialUi.form.vibeLabel')}</Label>
<div className="flex flex-wrap gap-2">
{VIBE_ENTRIES.map(([key, label]) => (
{VIBE_ENTRIES.map((key) => (
<button
key={key}
type="button"
@ -678,7 +679,7 @@ export function CrewForm({ existing }: CrewFormProps) {
: 'bg-gray-800 hover:bg-gray-700 border-gray-700 text-gray-300'
)}
>
{label}
{CREW_VIBE_KEYS[key] ? t(CREW_VIBE_KEYS[key]) : key}
</button>
))}
</div>
@ -760,10 +761,10 @@ export function CrewForm({ existing }: CrewFormProps) {
<Icon className={cn('mt-0.5 h-4 w-4 flex-shrink-0', selected ? 'text-teal-300' : 'text-gray-400')} />
<span className="min-w-0">
<span className={cn('block text-sm font-bold leading-tight', selected ? 'text-teal-200' : 'text-gray-200')}>
{CREW_FOCUS_LABELS[key]}
{CREW_FOCUS_KEYS[key] ? t(CREW_FOCUS_KEYS[key]) : CREW_FOCUS_LABELS[key]}
</span>
<span className="mt-0.5 block text-[11px] leading-snug text-gray-500">
{CREW_FOCUS_DESCRIPTIONS[key]}
{CREW_FOCUS_DESC_KEYS[key] ? t(CREW_FOCUS_DESC_KEYS[key]) : CREW_FOCUS_DESCRIPTIONS[key]}
</span>
</span>
</button>
@ -846,7 +847,7 @@ export function CrewForm({ existing }: CrewFormProps) {
)}
>
<span>{opt.emoji}</span>
{opt.label}
{CREW_LOOKING_FOR_KEYS[opt.slug] ? t(CREW_LOOKING_FOR_KEYS[opt.slug]) : opt.label}
</button>
);
})}
@ -964,7 +965,7 @@ export function CrewForm({ existing }: CrewFormProps) {
)}
aria-pressed={active}
>
<span className="leading-none">{meta.emoji}</span> {meta.label}
<span className="leading-none">{meta.emoji}</span> {CREW_ROLE_KEYS[role] ? t(CREW_ROLE_KEYS[role]) : meta.label}
</button>
);
})}

View file

@ -1,5 +1,6 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { CREW_LOOKING_FOR_LABELS } from '@/lib/types';
import { CREW_LOOKING_FOR_KEYS } from '@/lib/labelKeys';
import { useTranslation } from '@/contexts/language';
import { Megaphone } from 'lucide-react';
@ -30,7 +31,7 @@ export function CrewLookingForCard({ lookingFor }: { lookingFor: string[] }) {
className="inline-flex items-center gap-1.5 rounded-xl border border-fuchsia-500/40 bg-fuchsia-500/10 px-3 py-1.5 text-sm font-bold text-fuchsia-200"
>
<span>{meta?.emoji ?? '🛹'}</span>
{meta?.label ?? slug}
{CREW_LOOKING_FOR_KEYS[slug] ? t(CREW_LOOKING_FOR_KEYS[slug]) : (meta?.label ?? slug)}
</span>
);
})}

View file

@ -5,7 +5,7 @@ import { Input } from '@/components/ui/input';
import { useSkateSpots } from '@/hooks/useSkateSpots';
import { useShops } from '@/hooks/useShops';
import { useBtcMapPlaces } from '@/hooks/useBtcMapPlaces';
import { SPOT_TYPE_LABELS } from '@/lib/types';
import { spotTypeLabel } from '@/lib/spotTypeLabel';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
@ -70,7 +70,7 @@ export function DestinationSearch({
key: `spot:${spot.pubkey}:${spot.identifier}`,
kind: 'spot',
label: spot.title,
sub: [spot.location, spot.types.map((t) => SPOT_TYPE_LABELS[t] ?? t).join(', ')]
sub: [spot.location, spot.types.map((type) => spotTypeLabel(t, type)).join(', ')]
.filter(Boolean)
.join(' · '),
lat: latitude,
@ -103,7 +103,7 @@ export function DestinationSearch({
}
return out;
}, [spots, shops, merchants]);
}, [spots, shops, merchants, t]);
const results = useMemo(() => {
const q = query.trim().toLowerCase();

View file

@ -26,17 +26,7 @@ import { ReportDialog } from '@/components/ReportDialog';
import { useTranslation } from '@/contexts/language';
import type { SkateEvent } from '@/lib/types';
import { EVENT_TYPE_LABELS } from '@/lib/types';
/** Map event type keys to translation keys (types live in lib/types; labels translate at render). */
const EVENT_TYPE_KEYS: Record<string, string> = {
contest: 'placesUi.eventTypes.contest',
session: 'placesUi.eventTypes.session',
meetup: 'placesUi.eventTypes.meetup',
premiere: 'placesUi.eventTypes.premiere',
workshop: 'placesUi.eventTypes.workshop',
demo: 'placesUi.eventTypes.demo',
jam: 'placesUi.eventTypes.jam',
};
import { EVENT_TYPE_KEYS } from '@/lib/labelKeys';
function eventTypeLabel(t: (key: string) => string, type: string): string {
return EVENT_TYPE_KEYS[type] ? t(EVENT_TYPE_KEYS[type]) : (EVENT_TYPE_LABELS[type] || type);

View file

@ -17,6 +17,7 @@ import { getLastLocation } from '@/lib/lastLocation';
import { itemDistanceKm } from '@/lib/sortOptions';
import { getSkateboardRating, getTrustLevel, SELLER_TRUST_BADGES } from '@/lib/skateboardRating';
import { GEAR_CONDITIONS, GEAR_CATEGORIES, formatPrice, type MarketplaceItem } from '@/lib/types';
import { GEAR_CATEGORY_KEYS, GEAR_CONDITION_KEYS } from '@/lib/labelKeys';
import { MessageCircle, MapPin, Search, CheckCircle, ShieldCheck, AlertTriangle, MoreVertical, Flag, Trash2, RotateCcw, Loader2 } from 'lucide-react';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from '@/components/ui/dropdown-menu';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog';
@ -80,7 +81,7 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
};
// Format price display
const priceDisplay = formatPrice(item.price, item.priceType);
const priceDisplay = formatPrice(item.price, item.priceType, t);
const timeAgo = formatDistanceToNow(new Date(item.createdAt * 1000), { addSuffix: true });
@ -91,8 +92,11 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
const conditionInfo = item.condition ? GEAR_CONDITIONS[item.condition] : null;
// Get category label
const categoryLabel = item.category?.find((c) => GEAR_CATEGORIES[c as keyof typeof GEAR_CATEGORIES])
? GEAR_CATEGORIES[item.category.find((c) => GEAR_CATEGORIES[c as keyof typeof GEAR_CATEGORIES]) as keyof typeof GEAR_CATEGORIES]
const categoryValue = item.category?.find((c) => GEAR_CATEGORIES[c as keyof typeof GEAR_CATEGORIES]);
const categoryLabel = categoryValue
? GEAR_CATEGORY_KEYS[categoryValue]
? t(GEAR_CATEGORY_KEYS[categoryValue])
: GEAR_CATEGORIES[categoryValue as keyof typeof GEAR_CATEGORIES]
: null;
// Get price type label
@ -198,7 +202,7 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
variant="outline"
className="absolute bottom-2 left-2 bg-black/80 border-gray-600 text-xs backdrop-blur-sm"
>
{conditionInfo.emoji} {conditionInfo.label}
{conditionInfo.emoji} {item.condition && GEAR_CONDITION_KEYS[item.condition] ? t(GEAR_CONDITION_KEYS[item.condition]) : conditionInfo.label}
</Badge>
)}

View file

@ -3,6 +3,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Users, ChevronRight, MapPin } from 'lucide-react';
import { useCrewsByFocus } from '@/hooks/useCrews';
import { CREW_FOCUS_LABELS, crewFocusForServiceType, type SkateCrew, type CrewFocus } from '@/lib/types';
import { CREW_FOCUS_KEYS } from '@/lib/labelKeys';
import { useTranslation } from '@/contexts/language';
import { Skeleton } from '@/components/ui/skeleton';
@ -92,7 +93,7 @@ export function LabCrewsStrip({ serviceType }: { serviceType: ServiceType }) {
<Users className="h-4 w-4" />
{t('labUi.crewsStrip.title')}
<span className="rounded-full bg-teal-500/15 px-2 py-0.5 text-[10px] font-bold text-teal-300">
{CREW_FOCUS_LABELS[focus]}
{CREW_FOCUS_KEYS[focus] ? t(CREW_FOCUS_KEYS[focus]) : CREW_FOCUS_LABELS[focus]}
</span>
</h2>
<Link

View file

@ -31,6 +31,7 @@ import { Clock, Search, Tag, Flag, MoreVertical, ShieldCheck, Calendar, Package,
import { formatDistanceToNow } from '@/lib/dateFnsI18n';
import type { MarketplaceItem } from '@/lib/types';
import { GEAR_CONDITIONS, GEAR_CATEGORIES, PRICE_TYPES, LISTING_REPORT_REASONS, formatPrice } from '@/lib/types';
import { GEAR_CATEGORY_KEYS, GEAR_CONDITION_KEYS, PRICE_TYPE_KEYS } from '@/lib/labelKeys';
import { useTranslation } from '@/contexts/language';
interface MarketplaceItemCardProps {
@ -78,8 +79,8 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
// Format price display
const priceDisplay = item.price
? `${formatPrice(item.price, item.priceType)}${item.price.frequency ? `/${item.price.frequency}` : ''}`
: formatPrice(undefined, item.priceType);
? `${formatPrice(item.price, item.priceType, t)}${item.price.frequency ? `/${item.price.frequency}` : ''}`
: formatPrice(undefined, item.priceType, t);
const timeAgo = formatDistanceToNow(new Date(item.createdAt * 1000), { addSuffix: true });
@ -93,12 +94,19 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
const conditionInfo = item.condition ? GEAR_CONDITIONS[item.condition] : null;
// Get category label
const categoryLabel = item.category?.find((c) => GEAR_CATEGORIES[c as keyof typeof GEAR_CATEGORIES])
? GEAR_CATEGORIES[item.category.find((c) => GEAR_CATEGORIES[c as keyof typeof GEAR_CATEGORIES]) as keyof typeof GEAR_CATEGORIES]
const categoryValue = item.category?.find((c) => GEAR_CATEGORIES[c as keyof typeof GEAR_CATEGORIES]);
const categoryLabel = categoryValue
? GEAR_CATEGORY_KEYS[categoryValue]
? t(GEAR_CATEGORY_KEYS[categoryValue])
: GEAR_CATEGORIES[categoryValue as keyof typeof GEAR_CATEGORIES]
: null;
// Get price type label
const priceTypeLabel = item.priceType ? PRICE_TYPES[item.priceType]?.label : null;
const priceTypeLabel = item.priceType
? PRICE_TYPE_KEYS[item.priceType]
? t(PRICE_TYPE_KEYS[item.priceType])
: PRICE_TYPES[item.priceType]?.label
: null;
// Sats estimate from the LIVE BTC/USD rate — never a hardcoded constant
// (that quoted buyers wildly wrong amounts). Non-USD fiat is left unestimated
@ -254,7 +262,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
>
{priceDisplay}
</span>
{priceTypeLabel && priceTypeLabel !== 'Fixed Price' && (
{priceTypeLabel && item.priceType !== 'fixed' && (
<div className="text-xs text-gray-400">{priceTypeLabel}</div>
)}
{priceInSats > 0 && (
@ -332,7 +340,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
<div className="flex flex-wrap gap-1 mb-2">
{conditionInfo && (
<Badge variant="outline" className="text-xs border-gray-600 bg-gray-800/50">
{conditionInfo.emoji} {conditionInfo.label}
{conditionInfo.emoji} {item.condition && GEAR_CONDITION_KEYS[item.condition] ? t(GEAR_CONDITION_KEYS[item.condition]) : conditionInfo.label}
</Badge>
)}
{item.size && (

View file

@ -43,7 +43,7 @@ function ReportRow({ report }: { report: SpotStatusReport }) {
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-xs">
<span className="font-semibold">{meta.label}</span>
<span className="font-semibold">{t(meta.labelKey)}</span>
<span className="text-muted-foreground truncate">{t('spotUi.byAuthor', { name })}</span>
<span className="text-muted-foreground ml-auto flex-shrink-0">
{statusTimeAgo(report.createdAt)}
@ -154,8 +154,8 @@ export function ReportSpotStatusDialog({
style={{ backgroundColor: meta.dot, width: 12, height: 12 }}
/>
<div className="min-w-0 flex-1">
<p className="font-semibold text-sm">{meta.label}</p>
<p className="text-xs text-muted-foreground">{meta.description}</p>
<p className="font-semibold text-sm">{t(meta.labelKey)}</p>
<p className="text-xs text-muted-foreground">{t(meta.descriptionKey)}</p>
</div>
{isSel && <Check className="h-4 w-4 text-[#ff0040] flex-shrink-0" />}
</button>

View file

@ -36,6 +36,7 @@ import {
skateTime,
type TravelMode,
type RouteResult,
type RouteStep,
type TransitPlanOptions,
} from '@/lib/routing';
import {
@ -66,6 +67,43 @@ const TRANSIT_GROUP_LABEL_KEYS: Record<string, string> = {
ferry: 'routeUi.group.ferry',
};
/** Minimal translation-function shape. */
type Translate = (key: string, params?: Record<string, string | number>) => string;
/** Maneuver tokens live in `lib/routing`; map them to literal translation keys. */
const MANEUVER_LABEL_KEYS: Record<string, string> = {
headOut: 'routeUi.maneuver.headOut',
arrive: 'routeUi.maneuver.arrive',
continueStraight: 'routeUi.maneuver.continueStraight',
bearLeft: 'routeUi.maneuver.bearLeft',
bearRight: 'routeUi.maneuver.bearRight',
turnLeft: 'routeUi.maneuver.turnLeft',
turnRight: 'routeUi.maneuver.turnRight',
sharpLeft: 'routeUi.maneuver.sharpLeft',
sharpRight: 'routeUi.maneuver.sharpRight',
turn: 'routeUi.maneuver.turn',
keepLeft: 'routeUi.maneuver.keepLeft',
keepRight: 'routeUi.maneuver.keepRight',
takeFork: 'routeUi.maneuver.takeFork',
merge: 'routeUi.maneuver.merge',
roundabout: 'routeUi.maneuver.roundabout',
takeRamp: 'routeUi.maneuver.takeRamp',
takeExit: 'routeUi.maneuver.takeExit',
};
/**
* Translate a route step. New steps carry a maneuver token + optional road
* name (feed data, appended but never translated); legacy cached steps only
* have the English `instruction`.
*/
function stepInstruction(step: RouteStep, t: Translate): string {
if (!step.label) return step.instruction;
const key = MANEUVER_LABEL_KEYS[step.label];
if (!key) return step.instruction;
const base = t(key);
return step.road ? t('routeUi.maneuverOnto', { instruction: base, road: step.road }) : base;
}
/** A stop name that opens the departure board, when the feed gave a stop id. */
function StopButton({
id,
@ -489,14 +527,14 @@ export function RouteDialog({
const metres = Math.max(50, Math.round((km * 1000) / 10) * 10);
speak(t('routeUi.voiceIn', {
metres,
instruction: steps[idx]?.instruction ?? t('routeUi.voiceContinue'),
instruction: steps[idx] ? stepInstruction(steps[idx], t) : t('routeUi.voiceContinue'),
}));
buzz(60);
} else if (km <= 0.04 && !bands.has(40)) {
// ~40 m out — the turn itself.
bands.add(40);
announcedRef.current.set(idx, bands);
speak(steps[idx]?.instruction ?? t('routeUi.voiceContinueCaps'));
speak(steps[idx] ? stepInstruction(steps[idx], t) : t('routeUi.voiceContinueCaps'));
buzz(140);
}
}, [following, nav, route, speak, buzz, stopFollowing, t]);
@ -861,7 +899,7 @@ export function RouteDialog({
<div className="pointer-events-none absolute bottom-2 left-2 flex flex-col items-start gap-1">
{cachedAge !== null && (
<div className="rounded-full border border-amber-500/40 bg-amber-500/15 px-2 py-1 text-[10px] font-bold uppercase tracking-wide text-amber-300 backdrop-blur-sm">
{t('routeUi.offlineSavedRoute', { age: formatAge(cachedAge) })}
{t('routeUi.offlineSavedRoute', { age: formatAge(cachedAge, t) })}
</div>
)}
{locating && origin && (
@ -888,8 +926,8 @@ export function RouteDialog({
</p>
<p className="truncate text-base font-black text-white">
{nav.stepIndex >= 0
? route?.steps?.[nav.stepIndex]?.instruction ?? t('routeUi.followTheLine')
: route?.steps?.[0]?.instruction ?? t('routeUi.followTheLine')}
? route?.steps?.[nav.stepIndex] ? stepInstruction(route.steps[nav.stepIndex], t) : t('routeUi.followTheLine')
: route?.steps?.[0] ? stepInstruction(route.steps[0], t) : t('routeUi.followTheLine')}
</p>
<p className="text-xs text-gray-400">
{nav.stepIndex >= 0 && Number.isFinite(nav.stepKm)
@ -1073,7 +1111,7 @@ export function RouteDialog({
: t('routeUi.direct')}
</span>
{mode === 'foot'
? <span>· {skateTime(route.distanceM / 1000)} {t('routeUi.onABoard')}</span>
? <span>· {skateTime(route.distanceM / 1000, t)}</span>
: speed && (
<span>· {t('routeUi.speedAvg', { speed })}</span>
)}
@ -1127,7 +1165,7 @@ export function RouteDialog({
<span className="mt-px w-4 shrink-0 text-right font-mono text-[10px] text-muted-foreground/60">
{i + 1}
</span>
<span className="min-w-0 flex-1 leading-snug text-gray-300">{s.instruction}</span>
<span className="min-w-0 flex-1 leading-snug text-gray-300">{stepInstruction(s, t)}</span>
{s.distanceM >= 10 && (
<span className="shrink-0 tabular-nums text-muted-foreground/70">
{formatDistance(s.distanceM / 1000)}

View file

@ -27,7 +27,7 @@ interface Props {
export function SasquatchActionCard({ draft, onPublish, onCancel, status, resultLabel }: Props) {
const { t } = useTranslation();
const [edited, setEdited] = useState<SasquatchDraft>(draft);
const summary = summarizeDraft(edited);
const summary = summarizeDraft(edited, t);
if (status === 'done') {
return (

View file

@ -137,7 +137,7 @@ export function SasquatchProDialog({
<p className="text-sm text-gray-300">
{t('sasquatchUi.scanToPayPre')}{' '}
<span className="font-black text-white">{t('sasquatchUi.scanToPayAmount', { sats: selected.sats.toLocaleString() })}</span>{' '}
{t('sasquatchUi.scanToPayPost', { label: selected.label })}
{t('sasquatchUi.scanToPayPost', { label: t(selected.labelKey) })}
</p>
{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">
@ -221,9 +221,9 @@ function TierPicker({ selected, onSelect }: { selected: ProTier; onSelect: (t: P
{t('sasquatchUi.popular')}
</span>
)}
<p className="text-sm font-black text-white">{tier.label}</p>
<p className="text-sm font-black text-white">{t(tier.labelKey)}</p>
<p className="text-lg font-black text-red-400">{tier.sats.toLocaleString()}<span className="ml-0.5 text-xs text-gray-500">{t('sasquatchUi.sats')}</span></p>
<p className="text-[10px] text-gray-500">{[usd, tier.badge].filter(Boolean).join(' · ')}</p>
<p className="text-[10px] text-gray-500">{[usd, tier.badgeKey ? t(tier.badgeKey) : tier.badge].filter(Boolean).join(' · ')}</p>
</button>
);
})}

View file

@ -23,7 +23,9 @@ import { AddToCollectionDialog } from '@/components/AddToCollectionDialog';
import { ReportDialog } from '@/components/ReportDialog';
import { SpotStatusBadge } from '@/components/SpotStatusBadge';
import type { SkateSpot } from '@/lib/types';
import { SPOT_TYPE_LABELS, ACCESS_TYPE_INFO, SHELTER_INFO, SKATE_SHOP_KIND } from '@/lib/types';
import { ACCESS_TYPE_INFO, SHELTER_INFO, SKATE_SHOP_KIND } from '@/lib/types';
import { spotTypeLabel } from '@/lib/spotTypeLabel';
import { ACCESS_TYPE_KEYS, SHELTER_SHORT_KEYS } from '@/lib/labelKeys';
import type { AggregatedSpotStatus } from '@/hooks/useSpotStatus';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils';
@ -115,7 +117,7 @@ export function SpotCard({ spot, status }: SpotCardProps) {
color: ACCESS_TYPE_INFO[spot.accessType].color
}}
>
{ACCESS_TYPE_INFO[spot.accessType].emoji} {ACCESS_TYPE_INFO[spot.accessType].label.split(' - ')[0]}
{ACCESS_TYPE_INFO[spot.accessType].emoji} {(ACCESS_TYPE_KEYS[spot.accessType] ? t(ACCESS_TYPE_KEYS[spot.accessType]) : ACCESS_TYPE_INFO[spot.accessType].label).split(' - ')[0]}
</Badge>
)}
{spot.shelter && spot.shelter !== 'outdoor' && SHELTER_INFO[spot.shelter] && (
@ -126,7 +128,7 @@ export function SpotCard({ spot, status }: SpotCardProps) {
color: SHELTER_INFO[spot.shelter].color,
}}
>
{SHELTER_INFO[spot.shelter].emoji} {SHELTER_INFO[spot.shelter].short}
{SHELTER_INFO[spot.shelter].emoji} {SHELTER_SHORT_KEYS[spot.shelter] ? t(SHELTER_SHORT_KEYS[spot.shelter]) : SHELTER_INFO[spot.shelter].short}
</Badge>
)}
{spot.types.length > 0 && (
@ -136,7 +138,7 @@ export function SpotCard({ spot, status }: SpotCardProps) {
key={type}
className="bg-black/80 text-[#ccff00] backdrop-blur-sm border border-[#ccff00]/50 font-bold uppercase text-[10px]"
>
{SPOT_TYPE_LABELS[type] || type}
{spotTypeLabel(t, type)}
</Badge>
))}
</div>

View file

@ -743,7 +743,7 @@ function createEnhancedPopupContent(
const itemDiv = document.createElement('div');
itemDiv.className = 'text-xs text-gray-300';
const price = formatPrice(item.price, item.priceType);
const price = formatPrice(item.price, item.priceType, t);
itemDiv.textContent = `• ${item.title} - ${price}`;
itemsList.appendChild(itemDiv);
@ -1198,7 +1198,7 @@ export function SpotMap({
group.marketplaceItems.slice(0, 3).forEach((item) => {
const itemDiv = document.createElement('div');
itemDiv.className = 'text-xs text-gray-300';
const price = formatPrice(item.price, item.priceType);
const price = formatPrice(item.price, item.priceType, t);
itemDiv.textContent = `• ${item.title} - ${price}`;
itemsList.appendChild(itemDiv);
});

View file

@ -67,7 +67,7 @@ export function SpotStatusBadge({
height: size === 'sm' ? 6 : 8,
}}
/>
{meta.label}
{t(meta.labelKey)}
</span>
);
@ -75,8 +75,8 @@ export function SpotStatusBadge({
<Tooltip>
<TooltipTrigger asChild>{badge}</TooltipTrigger>
<TooltipContent className="max-w-56">
<p className="font-medium">{meta.label}</p>
<p className="text-xs text-muted-foreground">{meta.description}</p>
<p className="font-medium">{t(meta.labelKey)}</p>
<p className="text-xs text-muted-foreground">{t(meta.descriptionKey)}</p>
<p className="mt-1 text-xs text-muted-foreground">
{reporterCount} {reporterCount === 1 ? t('spotUi.reportOne') : t('spotUi.reports')}
{lastAt ? ` · ${t('spotUi.lastReported', { time: statusTimeAgo(lastAt) })}` : ''}

View file

@ -18,6 +18,7 @@ import { ReportDialog } from '@/components/ReportDialog';
import type { SkateTour, TourCategory } from '@/lib/types';
import { TOUR_CATEGORIES, TOUR_SKILL_LEVELS } from '@/lib/types';
import { TOUR_CATEGORY_KEYS } from '@/lib/labelKeys';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
@ -229,7 +230,7 @@ export function TourCard({ tour, compact = false }: TourCardProps) {
className="text-[10px] px-1.5 py-0 font-bold"
style={{ borderColor: categoryInfo.color, color: categoryInfo.color }}
>
{categoryInfo.label}
{TOUR_CATEGORY_KEYS[tour.category] ? t(TOUR_CATEGORY_KEYS[tour.category]) : categoryInfo.label}
</Badge>
{tour.duration && (
<span className="flex items-center gap-1">
@ -299,7 +300,7 @@ export function TourCard({ tour, compact = false }: TourCardProps) {
color: (categoryInfo.color as string) === '#ccff00' ? 'black' : 'white'
}}
>
{categoryInfo.emoji} {categoryInfo.label}
{categoryInfo.emoji} {TOUR_CATEGORY_KEYS[tour.category] ? t(TOUR_CATEGORY_KEYS[tour.category]) : categoryInfo.label}
</Badge>
</div>

View file

@ -21,7 +21,8 @@ import { useAuthor } from '@/hooks/useAuthor';
import { useTranslation } from '@/contexts/language';
import { genUserName } from '@/lib/genUserName';
import { crewAccent, crewBadge, type CrewActivity } from '@/lib/crewHelpers';
import { CREW_FOCUS_LABELS, CREW_VIBE_LABELS, type CrewFocus, type SkateCrew } from '@/lib/types';
import type { CrewFocus, SkateCrew } from '@/lib/types';
import { CREW_FOCUS_KEYS, CREW_VIBE_KEYS } from '@/lib/labelKeys';
import { cn } from '@/lib/utils';
const FOCUS_ICON: Record<CrewFocus, typeof Users> = {
@ -161,7 +162,7 @@ export function CrewHero({
style={{ color: accent.hex, borderColor: `${accent.hex}55`, background: `${accent.hex}14` }}
>
<FocusIcon className="h-3.5 w-3.5" />
{CREW_FOCUS_LABELS[crew.focus]}
{CREW_FOCUS_KEYS[crew.focus] ? t(CREW_FOCUS_KEYS[crew.focus]) : crew.focus}
</span>
</div>
</div>
@ -217,7 +218,7 @@ export function CrewHero({
style={{ color: accent.hex, borderColor: `${accent.hex}55`, background: `${accent.hex}14` }}
>
<FocusIcon className="h-3 w-3" />
{CREW_FOCUS_LABELS[crew.focus]}
{CREW_FOCUS_KEYS[crew.focus] ? t(CREW_FOCUS_KEYS[crew.focus]) : crew.focus}
</span>
</div>
@ -229,7 +230,7 @@ export function CrewHero({
key={v}
className="rounded-full border border-white/10 bg-white/[0.06] px-2.5 py-0.5 text-xs font-bold text-gray-300"
>
{CREW_VIBE_LABELS[v] || v}
{CREW_VIBE_KEYS[v] ? t(CREW_VIBE_KEYS[v]) : v}
</span>
))}
</div>

View file

@ -9,6 +9,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { genUserName } from '@/lib/genUserName';
import { crewAccent } from '@/lib/crewHelpers';
import { CREW_ROLE_META, type CrewRole, type SkateCrew } from '@/lib/types';
import { CREW_ROLE_KEYS } from '@/lib/labelKeys';
import { cn } from '@/lib/utils';
/**
@ -98,7 +99,7 @@ function LeaderCard({
key={r}
className="inline-flex items-center gap-0.5 rounded-full bg-white/10 px-1.5 py-0.5 text-[10px] font-bold text-gray-200"
>
<span className="leading-none">{CREW_ROLE_META[r].emoji}</span> {CREW_ROLE_META[r].label}
<span className="leading-none">{CREW_ROLE_META[r].emoji}</span> {CREW_ROLE_KEYS[r] ? t(CREW_ROLE_KEYS[r]) : CREW_ROLE_META[r].label}
</span>
))}
</div>

View file

@ -22,6 +22,7 @@ import { useNWC } from '@/hooks/useNWCContext';
import { useWallet } from '@/hooks/useWallet';
import { useRelays } from '@/lib/relays';
import { useSendDirectMessage } from '@/hooks/useDirectMessages';
import { useTranslation } from '@/contexts/language';
import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions';
import {
getZapProviderPubkey,
@ -49,6 +50,7 @@ export function usePurchasePro() {
const { webln } = useWallet();
const { read, write } = useRelays();
const { mutateAsync: sendDM } = useSendDirectMessage();
const { t } = useTranslation();
const [result, setResult] = useState<PurchaseResult>({ invoice: null, state: 'idle', error: null });
@ -66,7 +68,7 @@ export function usePurchasePro() {
const content = [
`🦧⚡ Sasquatch Pro — you're in!`,
``,
`Pass: ${tier.label} (${tier.sats.toLocaleString()} sats)`,
`Pass: ${t(tier.labelKey)} (${tier.sats.toLocaleString()} sats)`,
`Active until: ${until}`,
``,
`What you unlocked:`,
@ -83,7 +85,7 @@ export function usePurchasePro() {
/* best-effort — don't fail the purchase over a receipt DM */
}
},
[user, sendDM],
[user, sendDM, t],
);
/** Publish the self-signed fallback receipt for a paid tier. */
@ -93,20 +95,20 @@ export function usePurchasePro() {
const now = Math.floor(Date.now() / 1000);
const event = await user.signer.signEvent({
kind: SASQUATCH_PRO_KIND,
content: `Sasquatch Pro — ${tier.label} supporter pass 🦧⚡`,
content: `Sasquatch Pro — ${t(tier.labelKey)} supporter pass 🦧⚡`,
tags: [
['d', 'sasquatch-pro'],
['tier', tier.id],
['expiry', String(expiry)],
['amount', String(tier.sats)],
['p', PLATFORM_PUBKEY],
['alt', `Sasquatch Pro supporter pass (${tier.label}), valid until ${new Date(expiry * 1000).toISOString()}`],
['alt', `Sasquatch Pro supporter pass (${t(tier.labelKey)}), valid until ${new Date(expiry * 1000).toISOString()}`],
],
created_at: now,
});
await nostr.event(event, { signal: AbortSignal.timeout(10000) });
},
[user, nostr],
[user, nostr, t],
);
const purchase = useCallback(
@ -123,7 +125,7 @@ export function usePurchasePro() {
const expiry = now + tier.days * DAY_SECONDS;
const amountMsats = tier.sats * 1000;
const relays = [...new Set([...read, ...write])];
const comment = `Sasquatch Pro — ${tier.label} 🦧`;
const comment = `Sasquatch Pro — ${t(tier.labelKey)} 🦧`;
// Try a NIP-57 zap first (unforgeable, npub-bound). Fall back to a plain
// invoice if the provider doesn't publish zap receipts.
@ -199,7 +201,7 @@ export function usePurchasePro() {
});
}
},
[user, read, write, getActiveConnection, sendPayment, webln, publishReceipt, sendConfirmationDM],
[user, read, write, getActiveConnection, sendPayment, webln, publishReceipt, sendConfirmationDM, t],
);
/** Called after a manual (QR) payment to record the pass locally. */

View file

@ -13,9 +13,13 @@
import { useCallback } from 'react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useTranslation } from '@/contexts/language';
import { MARKETPLACE_ITEM_KIND } from '@/lib/types';
import { USER_STATUS_KIND } from '@/hooks/useUserStatus';
/** Minimal translation-function shape (this module is imported by components). */
type Translate = (key: string, params?: Record<string, string | number>) => string;
// ─── Draft shapes ─────────────────────────────────────────────────────────────
export interface PostStatusDraft {
@ -57,31 +61,31 @@ export type SasquatchDraft = PostStatusDraft | ListGearDraft | WtbGearDraft;
// ─── Human-readable summary for the confirmation card ────────────────────────
export function summarizeDraft(d: SasquatchDraft): { title: string; lines: string[]; cta: string } {
export function summarizeDraft(d: SasquatchDraft, t: Translate): { title: string; lines: string[]; cta: string } {
if (d.type === 'post_status') {
const mins = d.expiresInMinutes ?? 120;
return {
title: 'Post a skating status',
lines: [`"${d.text}"`, `Auto-expires in ${mins} min`],
cta: 'Post status',
title: t('sasquatchUi.draftPostStatusTitle'),
lines: [`"${d.text}"`, t('sasquatchUi.draftAutoExpires', { count: mins })],
cta: t('sasquatchUi.draftPostStatusCta'),
};
}
if (d.type === 'wtb_gear') {
const lines: string[] = [];
if (d.category) lines.push(`Category: ${d.category}`);
if (d.size) lines.push(`Size: ${d.size}`);
if (d.condition) lines.push(`Condition: ${d.condition}`);
lines.push(d.price ? `Max price: ${d.price.toLocaleString()} ${d.currency ?? 'sats'}` : 'Price: open');
if (d.category) lines.push(t('sasquatchUi.draftCategory', { value: d.category }));
if (d.size) lines.push(t('sasquatchUi.draftSize', { value: d.size }));
if (d.condition) lines.push(t('sasquatchUi.draftCondition', { value: d.condition }));
lines.push(d.price ? t('sasquatchUi.draftMaxPrice', { price: d.price.toLocaleString(), currency: d.currency ?? 'sats' }) : t('sasquatchUi.draftPriceOpen'));
if (d.description) lines.push(d.description);
return { title: `Looking for "${d.title}"`, lines, cta: 'Post wanted ad' };
return { title: t('sasquatchUi.draftLookingForTitle', { title: d.title }), lines, cta: t('sasquatchUi.draftWantedCta') };
}
const lines: string[] = [];
if (d.category) lines.push(`Category: ${d.category}`);
if (d.condition) lines.push(`Condition: ${d.condition}`);
if (d.size) lines.push(`Size: ${d.size}`);
lines.push(d.price ? `Price: ${d.price.toLocaleString()} ${d.currency ?? 'sats'}` : 'Price: make offer');
if (d.category) lines.push(t('sasquatchUi.draftCategory', { value: d.category }));
if (d.condition) lines.push(t('sasquatchUi.draftCondition', { value: d.condition }));
if (d.size) lines.push(t('sasquatchUi.draftSize', { value: d.size }));
lines.push(d.price ? t('sasquatchUi.draftPrice', { price: d.price.toLocaleString(), currency: d.currency ?? 'sats' }) : t('sasquatchUi.draftPriceOffer'));
if (d.description) lines.push(d.description);
return { title: `List "${d.title}" for sale`, lines, cta: 'Publish listing' };
return { title: t('sasquatchUi.draftListTitle', { title: d.title }), lines, cta: t('sasquatchUi.draftPublishCta') };
}
// ─── The hook ─────────────────────────────────────────────────────────────────
@ -89,17 +93,18 @@ export function summarizeDraft(d: SasquatchDraft): { title: string; lines: strin
export function useSasquatchActions() {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
const { t } = useTranslation();
const publishDraft = useCallback(
async (draft: SasquatchDraft): Promise<{ ok: true; label: string }> => {
if (!user) throw new Error('You must be logged in.');
if (!user) throw new Error(t('sasquatchUi.mustBeLoggedIn'));
if (draft.type === 'post_status') {
const tags: string[][] = [['d', 'general']];
const mins = draft.expiresInMinutes ?? 120;
tags.push(['expiration', String(Math.floor(Date.now() / 1000) + mins * 60)]);
await publishEvent({ kind: USER_STATUS_KIND, content: draft.text.slice(0, 140), tags });
return { ok: true, label: 'Status posted 🛹' };
return { ok: true, label: t('sasquatchUi.statusPosted') };
}
if (draft.type === 'wtb_gear') {
@ -129,7 +134,7 @@ export function useSasquatchActions() {
content: (draft.description ?? '').slice(0, 2000),
tags,
});
return { ok: true, label: 'Wanted ad published 🔎' };
return { ok: true, label: t('sasquatchUi.wantedPublished') };
}
// list_gear
@ -160,9 +165,9 @@ export function useSasquatchActions() {
content: (draft.description ?? '').slice(0, 2000),
tags,
});
return { ok: true, label: 'Listing published 💸' };
return { ok: true, label: t('sasquatchUi.listingPublished') };
},
[user, publishEvent],
[user, publishEvent, t],
);
return { publishDraft };

View file

@ -2,6 +2,7 @@ import routeUi from './routeUi';
import sasquatchUi from './sasquatchUi';
import collectionUi from './collectionUi';
import bootUi from './bootUi';
import labelsUi from './labelsUi';
/** Namespaces added by the batch-3 translation pass, spread into `en`. */
export const batch3En = {
@ -9,6 +10,7 @@ export const batch3En = {
sasquatchUi: sasquatchUi.en,
collectionUi: collectionUi.en,
bootUi: bootUi.en,
labelsUi: labelsUi.en,
};
/** Namespaces added by the batch-3 translation pass, spread into `es`. */
@ -17,4 +19,5 @@ export const batch3Es = {
sasquatchUi: sasquatchUi.es,
collectionUi: collectionUi.es,
bootUi: bootUi.es,
labelsUi: labelsUi.es,
};

488
src/i18n/batch3/labelsUi.ts Normal file
View file

@ -0,0 +1,488 @@
/**
* Batch 3 — shared data-module labels (`labelsUi.*`).
*
* Translates the English label maps that live outside components
* (spot/event/gear/crew/Lab constants in `src/lib/types.ts`, `skateTags.ts`,
* `spotStatus.ts`). Keys must exist in BOTH languages.
*
* Values are resolved at the point of display via the `*_KEYS` maps in
* `src/lib/labelKeys.ts` (e.g. `t(SPOT_TYPE_KEYS[type])`).
*/
const labelsUi = {
en: {
spotCategory: {
street: { label: 'Street', description: 'Random found spots, urban terrain' },
transition: { label: 'Transition', description: 'Curved/ramped surfaces' },
park: { label: 'Park', description: 'Built skateparks' },
plaza: { label: 'Plaza', description: 'Urban plazas (like MACBA, Love Park)' },
},
spotFeature: {
wallrides: 'Wallrides',
stairs: 'Stairs & Handrails',
rails: 'Rails',
ledges: 'Ledges & Curbs',
gaps: 'Gaps',
flatground: 'Flatground',
'manual-pads': 'Manual Pads',
'half-pipes': 'Half Pipes',
'quarter-pipes': 'Quarter Pipes',
'mini-ramps': 'Mini Ramps',
bowls: 'Bowls',
vert: 'Vert Ramps',
'mega-ramps': 'Mega Ramps',
hubbas: 'Hubbas',
'euro-gaps': 'Euro Gaps',
banks: 'Banks',
'spine-ramps': 'Spine Ramps',
'roll-ins': 'Roll-ins',
hips: 'Hips',
volcano: 'Volcano',
pyramids: 'Pyramids / Funboxes',
'snake-runs': 'Snake Runs',
'pump-track': 'Pump Track Rollers',
'flat-bars': 'Flat Bars',
},
spotType: {
street: 'Street',
transition: 'Transition',
park: 'Park',
plaza: 'Plaza',
wallrides: 'Wallrides',
stairs: 'Stairs & Handrails',
rails: 'Rails',
ledges: 'Ledges & Curbs',
gaps: 'Gaps',
flatground: 'Flatground',
'manual-pads': 'Manual Pads',
'half-pipes': 'Half Pipes',
'quarter-pipes': 'Quarter Pipes',
'mini-ramps': 'Mini Ramps',
bowls: 'Bowls',
vert: 'Vert Ramps',
'mega-ramps': 'Mega Ramps',
hubbas: 'Hubbas',
'euro-gaps': 'Euro Gaps',
banks: 'Banks',
'spine-ramps': 'Spine Ramps',
'roll-ins': 'Roll-ins',
hips: 'Hips',
volcano: 'Volcano',
pyramids: 'Pyramids / Funboxes',
'snake-runs': 'Snake Runs',
'pump-track': 'Pump Track Rollers',
'flat-bars': 'Flat Bars',
diy: 'DIY',
rail: 'Rail',
ledge: 'Ledge',
gap: 'Gap',
bowl: 'Bowl',
stall: 'Stall',
slappy: 'Slappy',
},
access: {
public: { label: 'Public - Open to Everyone' },
private: { label: 'Private Property - Permission Needed' },
locals: { label: 'Ask Locals First' },
},
shelter: {
outdoor: { label: 'Outdoor', short: 'Outdoor', description: 'Fully exposed — rain stops play' },
partial: { label: 'Partly covered', short: 'Partly covered', description: 'Some obstacles stay dry' },
covered: { label: 'Covered / roofed', short: 'Covered', description: 'Outdoor but under a roof' },
indoor: { label: 'Indoor', short: 'Indoor', description: 'Fully enclosed — skate rain or shine' },
},
eventType: {
contest: 'Contest',
demo: 'Demo',
session: 'Session',
workshop: 'Workshop',
premiere: 'Premiere',
tradeshow: 'Trade Show',
gameskate: 'Game of Skate',
concert: '🎸 Concert',
jam: '🎵 Jam',
festival: '🎪 Festival',
popup: '🎯 Pop-Up',
},
gearCategory: {
deck: 'Deck',
trucks: 'Trucks',
wheels: 'Wheels',
bearings: 'Bearings',
shoes: 'Skate Shoes',
clothing: 'Clothing',
protective: 'Protective Gear',
stickers: 'Stickers',
wax: 'Wax',
tools: 'Tools',
griptape: 'Griptape',
rubbrick: 'Rub Brick',
},
gearCondition: {
new: { label: 'New', description: 'Never used, still in packaging' },
like_new: { label: 'Like New', description: 'Barely used, excellent condition' },
good: { label: 'Good', description: 'Normal wear, functions perfectly' },
well_used: { label: 'Well Used', description: 'Shows wear but still works' },
},
priceType: {
fixed: { label: 'Fixed Price', description: 'Set price in USD or sats' },
free: { label: 'Free', description: 'Giving away' },
trade: { label: 'Trade', description: 'Looking to swap' },
offer: { label: 'Make Offer', description: 'Price negotiable' },
},
listingType: {
selling: { label: 'Selling', description: 'Listing gear for sale' },
wtb: { label: 'Want to Buy', description: 'Looking for specific gear' },
},
tour: {
spotType: {
street: 'Street',
park: 'Park',
diy: 'DIY',
transition: 'Transition',
hidden: 'Hidden Gems',
},
category: {
local: { label: 'Local Tour', description: 'Show hidden spots in your city' },
national: { label: 'National Tour', description: 'Multi-city road trips across the country' },
international: { label: 'International Tour', description: 'Cross-border adventures across multiple countries' },
destruction: { label: 'World Destruction', description: 'KOTR-style global team competition' },
},
},
crew: {
vibe: {
street: 'Street',
transition: 'Transition / Vert',
park: 'Park',
diy: 'DIY',
cruising: 'Cruising / Filming',
oldschool: 'Old School',
allterrain: 'All Terrain',
girls: 'Girl / Woman',
youth: 'Youth / Groms',
adaptive: 'Adaptive',
punk: 'Punk',
hiphop: 'Rap / Hip-Hop',
},
focus: {
mixed: 'Mixed / General',
builder: 'DIY & Builders',
photo: 'Photo & Film',
coaching: 'Coaching',
art: 'Art & Design',
music: 'Band & Music',
wellness: 'Training & Physio',
},
focusDescription: {
mixed: 'A regular skate crew — shows on the Crews page only.',
builder: 'A DIY / park-building crew — also shows in The Lab under Builders.',
photo: 'A filming, photography or editing crew — also shows in The Lab under Photo & Film.',
coaching: 'A coaching / lessons crew — also shows in The Lab under Coaching.',
art: 'An art, design, graffiti or tattoo collective — also shows in The Lab under Art & Design.',
music: 'A band or music collective — also shows in The Lab under Music.',
wellness: 'A training, fitness or physio crew — also shows in The Lab under Training.',
},
lookingFor: {
filmer: 'Filmer',
photographer: 'Photographer',
editor: 'Video Editor',
coach: 'Coach',
builder: 'Ramp Builder',
artist: 'Artist / Designer',
musician: 'Musician / Band',
members: 'New Members',
},
role: {
filmer: 'Filmer',
photographer: 'Photographer',
editor: 'Editor',
coach: 'Coach',
builder: 'Builder',
artist: 'Artist',
graffiti: 'Graffiti',
tattoo: 'Tattoo',
trainer: 'Trainer',
musician: 'Musician',
judge: 'Judge',
manager: 'Team Manager',
og: 'OG',
},
},
memberVisibility: {
public: {
label: 'Public',
description: 'Anyone can see you in this crew — on the public crew page and in crew-mate discovery.',
},
crew: {
label: 'Crew only',
description: 'Only confirmed crew-mates and the founder see you here. To everyone else you appear as an anonymous “hidden member”. Note: this is app-level privacy — your membership tag still exists publicly on relays, it\'s just never shown to outsiders.',
},
},
tagCategory: {
features: { name: 'Features', description: 'Obstacles and terrain' },
surfaces: { name: 'Surfaces', description: 'Materials and textures' },
sizes: { name: 'Sizes', description: 'Dimensions and scale' },
stairs: { name: 'Stair Counts', description: 'Number of stairs' },
vibes: { name: 'Vibes', description: 'Atmosphere and condition' },
location: { name: 'Location', description: 'Location context' },
tricks: { name: 'Tricks', description: 'Good for specific tricks' },
},
spotTagCategory: {
street: { label: 'Street', description: 'Street spots - stairs, rails, ledges' },
park: { label: 'Park', description: 'Skateparks - public or private' },
diy: { label: 'DIY', description: 'DIY/homemade spots' },
plaza: { label: 'Plaza', description: 'Public plazas with skate-friendly architecture' },
},
status: {
active: { label: 'Active', description: 'Good to go — skate it.' },
bust: { label: 'Bust', description: 'Heavy kick-out risk — security or cops.' },
skate_stopped: { label: 'Skate-stopped', description: 'Knobbed / capped, but partially skateable.' },
closed: { label: 'Closed', description: 'Temporarily closed — construction or event.' },
gone: { label: 'Gone', description: 'Demolished, paved over, or fenced off for good.' },
},
price: {
free: 'FREE',
trade: 'Trade',
makeOffer: 'Make Offer',
offer: 'Offer',
},
},
es: {
spotCategory: {
street: { label: 'Calle', description: 'Spots encontrados al azar, terreno urbano' },
transition: { label: 'Transición', description: 'Superficies curvas o con rampas' },
park: { label: 'Parque', description: 'Skateparks construidos' },
plaza: { label: 'Plaza', description: 'Plazas urbanas (como MACBA, Love Park)' },
},
spotFeature: {
wallrides: 'Wallrides',
stairs: 'Escaleras y barandillas',
rails: 'Barandillas',
ledges: 'Bordes y aceras',
gaps: 'Gaps',
flatground: 'Suelo plano',
'manual-pads': 'Plataformas de manual',
'half-pipes': 'Half pipes',
'quarter-pipes': 'Quarter pipes',
'mini-ramps': 'Mini rampas',
bowls: 'Bowls',
vert: 'Rampas vert',
'mega-ramps': 'Megarrampas',
hubbas: 'Hubbas',
'euro-gaps': 'Euro gaps',
banks: 'Bancos',
'spine-ramps': 'Rampas spine',
'roll-ins': 'Roll-ins',
hips: 'Hips',
volcano: 'Volcán',
pyramids: 'Pirámides / Funboxes',
'snake-runs': 'Snake runs',
'pump-track': 'Pump track',
'flat-bars': 'Barras planas',
},
spotType: {
street: 'Calle',
transition: 'Transición',
park: 'Parque',
plaza: 'Plaza',
wallrides: 'Wallrides',
stairs: 'Escaleras y barandillas',
rails: 'Barandillas',
ledges: 'Bordes y aceras',
gaps: 'Gaps',
flatground: 'Suelo plano',
'manual-pads': 'Plataformas de manual',
'half-pipes': 'Half pipes',
'quarter-pipes': 'Quarter pipes',
'mini-ramps': 'Mini rampas',
bowls: 'Bowls',
vert: 'Rampas vert',
'mega-ramps': 'Megarrampas',
hubbas: 'Hubbas',
'euro-gaps': 'Euro gaps',
banks: 'Bancos',
'spine-ramps': 'Rampas spine',
'roll-ins': 'Roll-ins',
hips: 'Hips',
volcano: 'Volcán',
pyramids: 'Pirámides / Funboxes',
'snake-runs': 'Snake runs',
'pump-track': 'Pump track',
'flat-bars': 'Barras planas',
diy: 'DIY',
rail: 'Barandilla',
ledge: 'Borde',
gap: 'Gap',
bowl: 'Bowl',
stall: 'Stall',
slappy: 'Slappy',
},
access: {
public: { label: 'Público - Abierto a todos' },
private: { label: 'Propiedad privada - Se necesita permiso' },
locals: { label: 'Pregunta a los locales primero' },
},
shelter: {
outdoor: { label: 'Exterior', short: 'Exterior', description: 'Totalmente expuesto: la lluvia lo para' },
partial: { label: 'Parcialmente cubierto', short: 'Parcial', description: 'Algunos obstáculos se mantienen secos' },
covered: { label: 'Cubierto / con techo', short: 'Cubierto', description: 'Al aire libre pero bajo techo' },
indoor: { label: 'Interior', short: 'Interior', description: 'Totalmente cerrado: patina llueva o haga sol' },
},
eventType: {
contest: 'Concurso',
demo: 'Demo',
session: 'Sesión',
workshop: 'Taller',
premiere: 'Estreno',
tradeshow: 'Feria',
gameskate: 'Game of Skate',
concert: '🎸 Concierto',
jam: '🎵 Jam',
festival: '🎪 Festival',
popup: '🎯 Pop-up',
},
gearCategory: {
deck: 'Tabla',
trucks: 'Trucks',
wheels: 'Ruedas',
bearings: 'Rodamientos',
shoes: 'Zapatillas de skate',
clothing: 'Ropa',
protective: 'Protecciones',
stickers: 'Pegatinas',
wax: 'Cera',
tools: 'Herramientas',
griptape: 'Lija',
rubbrick: 'Ladrillo de frotar',
},
gearCondition: {
new: { label: 'Nuevo', description: 'Nunca usado, todavía en su embalaje' },
like_new: { label: 'Como nuevo', description: 'Apenas usado, excelente estado' },
good: { label: 'Bueno', description: 'Desgaste normal, funciona perfectamente' },
well_used: { label: 'Muy usado', description: 'Muestra desgaste pero sigue funcionando' },
},
priceType: {
fixed: { label: 'Precio fijo', description: 'Precio fijado en USD o sats' },
free: { label: 'Gratis', description: 'Se regala' },
trade: { label: 'Intercambio', description: 'Busca cambiar' },
offer: { label: 'Acepta ofertas', description: 'Precio negociable' },
},
listingType: {
selling: { label: 'En venta', description: 'Publica material en venta' },
wtb: { label: 'Busco comprar', description: 'Busca material concreto' },
},
tour: {
spotType: {
street: 'Calle',
park: 'Parque',
diy: 'DIY',
transition: 'Transición',
hidden: 'Joyas ocultas',
},
category: {
local: { label: 'Ruta local', description: 'Muestra spots ocultos de tu ciudad' },
national: { label: 'Ruta nacional', description: 'Viajes por carretera por varias ciudades del país' },
international: { label: 'Ruta internacional', description: 'Aventuras transfronterizas por varios países' },
destruction: { label: 'World Destruction', description: 'Competición global por equipos al estilo KOTR' },
},
},
crew: {
vibe: {
street: 'Calle',
transition: 'Transición / Vert',
park: 'Parque',
diy: 'DIY',
cruising: 'Cruising / Grabación',
oldschool: 'Old school',
allterrain: 'Todo terreno',
girls: 'Chicas / Mujeres',
youth: 'Jóvenes / Groms',
adaptive: 'Adaptado',
punk: 'Punk',
hiphop: 'Rap / Hip-Hop',
},
focus: {
mixed: 'Mixto / General',
builder: 'DIY y constructores',
photo: 'Foto y vídeo',
coaching: 'Entrenamiento',
art: 'Arte y diseño',
music: 'Banda y música',
wellness: 'Entrenamiento y fisio',
},
focusDescription: {
mixed: 'Un crew de skate normal: solo aparece en la página de Crews.',
builder: 'Un crew de DIY / construcción de parques: también aparece en The Lab, en Constructores.',
photo: 'Un crew de grabación, fotografía o edición: también aparece en The Lab, en Foto y vídeo.',
coaching: 'Un crew de entrenamiento o clases: también aparece en The Lab, en Entrenamiento.',
art: 'Un colectivo de arte, diseño, grafiti o tatuaje: también aparece en The Lab, en Arte y diseño.',
music: 'Una banda o colectivo musical: también aparece en The Lab, en Música.',
wellness: 'Un crew de entrenamiento, fitness o fisio: también aparece en The Lab, en Entrenamiento.',
},
lookingFor: {
filmer: 'Filmer',
photographer: 'Fotógrafo',
editor: 'Editor de vídeo',
coach: 'Entrenador',
builder: 'Constructor de rampas',
artist: 'Artista / Diseñador',
musician: 'Músico / Banda',
members: 'Nuevos miembros',
},
role: {
filmer: 'Filmer',
photographer: 'Fotógrafo',
editor: 'Editor',
coach: 'Entrenador',
builder: 'Constructor',
artist: 'Artista',
graffiti: 'Grafiti',
tattoo: 'Tatuaje',
trainer: 'Preparador',
musician: 'Músico',
judge: 'Juez',
manager: 'Mánager de equipo',
og: 'OG',
},
},
memberVisibility: {
public: {
label: 'Público',
description: 'Cualquiera puede verte en este crew: en la página pública del crew y en el descubrimiento de compañeros.',
},
crew: {
label: 'Solo el crew',
description: 'Solo los compañeros confirmados y el fundador te ven aquí. Para el resto apareces como un «miembro oculto» anónimo. Nota: esto es privacidad a nivel de app — tu etiqueta de miembro sigue existiendo públicamente en los relays, solo que nunca se muestra a los de fuera.',
},
},
tagCategory: {
features: { name: 'Características', description: 'Obstáculos y terreno' },
surfaces: { name: 'Superficies', description: 'Materiales y texturas' },
sizes: { name: 'Tamaños', description: 'Dimensiones y escala' },
stairs: { name: 'Número de escalones', description: 'Cantidad de escalones' },
vibes: { name: 'Ambiente', description: 'Ambiente y estado' },
location: { name: 'Ubicación', description: 'Contexto de ubicación' },
tricks: { name: 'Trucos', description: 'Bueno para trucos concretos' },
},
spotTagCategory: {
street: { label: 'Calle', description: 'Spots de calle: escaleras, barandillas, bordes' },
park: { label: 'Parque', description: 'Skateparks públicos o privados' },
diy: { label: 'DIY', description: 'Spots DIY o caseros' },
plaza: { label: 'Plaza', description: 'Plazas públicas con arquitectura apta para patinar' },
},
status: {
active: { label: 'Activo', description: 'Todo listo: a patinar.' },
bust: { label: 'Te echan', description: 'Mucho riesgo de que te echen: seguridad o policía.' },
skate_stopped: { label: 'Bloqueado', description: 'Con topes o bloqueado, pero patinable en parte.' },
closed: { label: 'Cerrado', description: 'Cerrado temporalmente: obras o evento.' },
gone: { label: 'Desaparecido', description: 'Derribado, asfaltado o vallado para siempre.' },
},
price: {
free: 'GRATIS',
trade: 'Intercambio',
makeOffer: 'Haz una oferta',
offer: 'Oferta',
},
},
};
export default labelsUi;

View file

@ -147,6 +147,36 @@ const routeUi = {
stays: 'Stays',
gettingAround: 'Getting around',
noMiddleman: 'I plan the skating — flights & stays open on the real sites (no KYC, no middleman).',
// Cached-route age
ageJustNow: 'just now',
ageMinAgo: '{{count}} min ago',
ageHourAgo: '{{count}} h ago',
ageDayAgo: '{{count}} d ago',
// Skate-paced cruise estimate
skateRightHere: 'right here',
skateMin: '~{{count}} min cruise',
skateHours: '~{{hours}} h cruise',
// Turn-by-turn maneuvers
maneuver: {
headOut: 'Head out',
arrive: 'Arrive at the spot',
continueStraight: 'Continue',
bearLeft: 'Bear left',
bearRight: 'Bear right',
turnLeft: 'Turn left',
turnRight: 'Turn right',
sharpLeft: 'Sharp left',
sharpRight: 'Sharp right',
turn: 'Turn',
keepLeft: 'Keep left',
keepRight: 'Keep right',
takeFork: 'Take the fork',
merge: 'Merge',
roundabout: 'Take the roundabout',
takeRamp: 'Take the ramp',
takeExit: 'Take the exit',
},
maneuverOnto: '{{instruction}} onto {{road}}',
},
es: {
// Header / search
@ -291,6 +321,36 @@ const routeUi = {
stays: 'Alojamiento',
gettingAround: 'Moverse',
noMiddleman: 'Yo planifico el skate — los vuelos y alojamientos se abren en los sitios reales (sin KYC, sin intermediarios).',
// Cached-route age
ageJustNow: 'ahora mismo',
ageMinAgo: 'hace {{count}} min',
ageHourAgo: 'hace {{count}} h',
ageDayAgo: 'hace {{count}} d',
// Skate-paced cruise estimate
skateRightHere: 'aquí mismo',
skateMin: '~{{count}} min en tabla',
skateHours: '~{{hours}} h en tabla',
// Turn-by-turn maneuvers
maneuver: {
headOut: 'Sal',
arrive: 'Llega al spot',
continueStraight: 'Continúa',
bearLeft: 'Desvíate a la izquierda',
bearRight: 'Desvíate a la derecha',
turnLeft: 'Gira a la izquierda',
turnRight: 'Gira a la derecha',
sharpLeft: 'Giro cerrado a la izquierda',
sharpRight: 'Giro cerrado a la derecha',
turn: 'Gira',
keepLeft: 'Mantente a la izquierda',
keepRight: 'Mantente a la derecha',
takeFork: 'Toma la bifurcación',
merge: 'Incorpórate',
roundabout: 'Toma la rotonda',
takeRamp: 'Toma la incorporación',
takeExit: 'Toma la salida',
},
maneuverOnto: '{{instruction}} hacia {{road}}',
},
};

View file

@ -109,6 +109,35 @@ const sasquatchUi = {
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.',
// ── Pro tier labels & badges (PRO_TIERS) ──
tier7d: '7 days',
tier1m: '1 month',
tier3m: '3 months',
tier1y: '1 year',
badgeTry: 'Try it',
badgeOff20: '20% off',
badgeBest: 'Best value · 40% off',
// ── Draft action summary ──
draftPostStatusTitle: 'Post a skating status',
draftAutoExpires: 'Auto-expires in {{count}} min',
draftPostStatusCta: 'Post status',
draftCategory: 'Category: {{value}}',
draftSize: 'Size: {{value}}',
draftCondition: 'Condition: {{value}}',
draftMaxPrice: 'Max price: {{price}} {{currency}}',
draftPriceOpen: 'Price: open',
draftLookingForTitle: 'Looking for "{{title}}"',
draftWantedCta: 'Post wanted ad',
draftPrice: 'Price: {{price}} {{currency}}',
draftPriceOffer: 'Price: make offer',
draftListTitle: 'List "{{title}}" for sale',
draftPublishCta: 'Publish listing',
statusPosted: 'Status posted 🛹',
wantedPublished: 'Wanted ad published 🔎',
listingPublished: 'Listing published 💸',
mustBeLoggedIn: 'You must be logged in.',
},
es: {
// ── Connection status ──
@ -211,6 +240,35 @@ const sasquatchUi = {
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.',
// ── Pro tier labels & badges (PRO_TIERS) ──
tier7d: '7 días',
tier1m: '1 mes',
tier3m: '3 meses',
tier1y: '1 año',
badgeTry: 'Pruébalo',
badgeOff20: '20 % dto.',
badgeBest: 'Mejor precio · 40 % dto.',
// ── Draft action summary ──
draftPostStatusTitle: 'Publicar un estado de skate',
draftAutoExpires: 'Se borra automáticamente en {{count}} min',
draftPostStatusCta: 'Publicar estado',
draftCategory: 'Categoría: {{value}}',
draftSize: 'Talla: {{value}}',
draftCondition: 'Estado: {{value}}',
draftMaxPrice: 'Precio máx.: {{price}} {{currency}}',
draftPriceOpen: 'Precio: abierto',
draftLookingForTitle: 'Buscando "{{title}}"',
draftWantedCta: 'Publicar anuncio',
draftPrice: 'Precio: {{price}} {{currency}}',
draftPriceOffer: 'Precio: aceptar ofertas',
draftListTitle: 'Vender "{{title}}"',
draftPublishCta: 'Publicar anuncio',
statusPosted: 'Estado publicado 🛹',
wantedPublished: 'Anuncio de búsqueda publicado 🔎',
listingPublished: 'Anuncio publicado 💸',
mustBeLoggedIn: 'Tienes que iniciar sesión.',
},
};

330
src/lib/labelKeys.ts Normal file
View file

@ -0,0 +1,330 @@
/**
* Shared label key maps (`labelsUi.*`).
*
* The English label constants in `types.ts` / `skateTags.ts` / `spotStatus.ts`
* live outside React, so `t()` can't reach them directly. These maps translate a
* stable value/id into the `labelsUi.*` translation key; consumers resolve at the
* point of display with a safe fallback to the original English string:
*
* SPOT_TYPE_KEYS[type] ? t(SPOT_TYPE_KEYS[type]) : (SPOT_TYPE_LABELS[type] || type)
*
* Keep every map's keys in sync with the source constant it mirrors.
*/
export const SPOT_CATEGORY_KEYS: Record<string, string> = {
street: 'labelsUi.spotCategory.street.label',
transition: 'labelsUi.spotCategory.transition.label',
park: 'labelsUi.spotCategory.park.label',
plaza: 'labelsUi.spotCategory.plaza.label',
};
export const SPOT_CATEGORY_DESC_KEYS: Record<string, string> = {
street: 'labelsUi.spotCategory.street.description',
transition: 'labelsUi.spotCategory.transition.description',
park: 'labelsUi.spotCategory.park.description',
plaza: 'labelsUi.spotCategory.plaza.description',
};
export const SPOT_FEATURE_KEYS: Record<string, string> = {
wallrides: 'labelsUi.spotFeature.wallrides',
stairs: 'labelsUi.spotFeature.stairs',
rails: 'labelsUi.spotFeature.rails',
ledges: 'labelsUi.spotFeature.ledges',
gaps: 'labelsUi.spotFeature.gaps',
flatground: 'labelsUi.spotFeature.flatground',
'manual-pads': 'labelsUi.spotFeature.manual-pads',
'half-pipes': 'labelsUi.spotFeature.half-pipes',
'quarter-pipes': 'labelsUi.spotFeature.quarter-pipes',
'mini-ramps': 'labelsUi.spotFeature.mini-ramps',
bowls: 'labelsUi.spotFeature.bowls',
vert: 'labelsUi.spotFeature.vert',
'mega-ramps': 'labelsUi.spotFeature.mega-ramps',
hubbas: 'labelsUi.spotFeature.hubbas',
'euro-gaps': 'labelsUi.spotFeature.euro-gaps',
banks: 'labelsUi.spotFeature.banks',
'spine-ramps': 'labelsUi.spotFeature.spine-ramps',
'roll-ins': 'labelsUi.spotFeature.roll-ins',
hips: 'labelsUi.spotFeature.hips',
volcano: 'labelsUi.spotFeature.volcano',
pyramids: 'labelsUi.spotFeature.pyramids',
'snake-runs': 'labelsUi.spotFeature.snake-runs',
'pump-track': 'labelsUi.spotFeature.pump-track',
'flat-bars': 'labelsUi.spotFeature.flat-bars',
};
export const SPOT_TYPE_KEYS: Record<string, string> = {
street: 'labelsUi.spotType.street',
transition: 'labelsUi.spotType.transition',
park: 'labelsUi.spotType.park',
plaza: 'labelsUi.spotType.plaza',
wallrides: 'labelsUi.spotType.wallrides',
stairs: 'labelsUi.spotType.stairs',
rails: 'labelsUi.spotType.rails',
ledges: 'labelsUi.spotType.ledges',
gaps: 'labelsUi.spotType.gaps',
flatground: 'labelsUi.spotType.flatground',
'manual-pads': 'labelsUi.spotType.manual-pads',
'half-pipes': 'labelsUi.spotType.half-pipes',
'quarter-pipes': 'labelsUi.spotType.quarter-pipes',
'mini-ramps': 'labelsUi.spotType.mini-ramps',
bowls: 'labelsUi.spotType.bowls',
vert: 'labelsUi.spotType.vert',
'mega-ramps': 'labelsUi.spotType.mega-ramps',
hubbas: 'labelsUi.spotType.hubbas',
'euro-gaps': 'labelsUi.spotType.euro-gaps',
banks: 'labelsUi.spotType.banks',
'spine-ramps': 'labelsUi.spotType.spine-ramps',
'roll-ins': 'labelsUi.spotType.roll-ins',
hips: 'labelsUi.spotType.hips',
volcano: 'labelsUi.spotType.volcano',
pyramids: 'labelsUi.spotType.pyramids',
'snake-runs': 'labelsUi.spotType.snake-runs',
'pump-track': 'labelsUi.spotType.pump-track',
'flat-bars': 'labelsUi.spotType.flat-bars',
diy: 'labelsUi.spotType.diy',
rail: 'labelsUi.spotType.rail',
ledge: 'labelsUi.spotType.ledge',
gap: 'labelsUi.spotType.gap',
bowl: 'labelsUi.spotType.bowl',
stall: 'labelsUi.spotType.stall',
slappy: 'labelsUi.spotType.slappy',
};
export const ACCESS_TYPE_KEYS: Record<string, string> = {
public: 'labelsUi.access.public.label',
private: 'labelsUi.access.private.label',
locals: 'labelsUi.access.locals.label',
};
export const SHELTER_KEYS: Record<string, string> = {
outdoor: 'labelsUi.shelter.outdoor.label',
partial: 'labelsUi.shelter.partial.label',
covered: 'labelsUi.shelter.covered.label',
indoor: 'labelsUi.shelter.indoor.label',
};
export const SHELTER_SHORT_KEYS: Record<string, string> = {
outdoor: 'labelsUi.shelter.outdoor.short',
partial: 'labelsUi.shelter.partial.short',
covered: 'labelsUi.shelter.covered.short',
indoor: 'labelsUi.shelter.indoor.short',
};
export const SHELTER_DESC_KEYS: Record<string, string> = {
outdoor: 'labelsUi.shelter.outdoor.description',
partial: 'labelsUi.shelter.partial.description',
covered: 'labelsUi.shelter.covered.description',
indoor: 'labelsUi.shelter.indoor.description',
};
export const EVENT_TYPE_KEYS: Record<string, string> = {
contest: 'labelsUi.eventType.contest',
demo: 'labelsUi.eventType.demo',
session: 'labelsUi.eventType.session',
workshop: 'labelsUi.eventType.workshop',
premiere: 'labelsUi.eventType.premiere',
tradeshow: 'labelsUi.eventType.tradeshow',
gameskate: 'labelsUi.eventType.gameskate',
concert: 'labelsUi.eventType.concert',
jam: 'labelsUi.eventType.jam',
festival: 'labelsUi.eventType.festival',
popup: 'labelsUi.eventType.popup',
};
export const GEAR_CATEGORY_KEYS: Record<string, string> = {
deck: 'labelsUi.gearCategory.deck',
trucks: 'labelsUi.gearCategory.trucks',
wheels: 'labelsUi.gearCategory.wheels',
bearings: 'labelsUi.gearCategory.bearings',
shoes: 'labelsUi.gearCategory.shoes',
clothing: 'labelsUi.gearCategory.clothing',
protective: 'labelsUi.gearCategory.protective',
stickers: 'labelsUi.gearCategory.stickers',
wax: 'labelsUi.gearCategory.wax',
tools: 'labelsUi.gearCategory.tools',
griptape: 'labelsUi.gearCategory.griptape',
rubbrick: 'labelsUi.gearCategory.rubbrick',
};
export const GEAR_CONDITION_KEYS: Record<string, string> = {
new: 'labelsUi.gearCondition.new.label',
like_new: 'labelsUi.gearCondition.like_new.label',
good: 'labelsUi.gearCondition.good.label',
well_used: 'labelsUi.gearCondition.well_used.label',
};
export const GEAR_CONDITION_DESC_KEYS: Record<string, string> = {
new: 'labelsUi.gearCondition.new.description',
like_new: 'labelsUi.gearCondition.like_new.description',
good: 'labelsUi.gearCondition.good.description',
well_used: 'labelsUi.gearCondition.well_used.description',
};
export const PRICE_TYPE_KEYS: Record<string, string> = {
fixed: 'labelsUi.priceType.fixed.label',
free: 'labelsUi.priceType.free.label',
trade: 'labelsUi.priceType.trade.label',
offer: 'labelsUi.priceType.offer.label',
};
export const PRICE_TYPE_DESC_KEYS: Record<string, string> = {
fixed: 'labelsUi.priceType.fixed.description',
free: 'labelsUi.priceType.free.description',
trade: 'labelsUi.priceType.trade.description',
offer: 'labelsUi.priceType.offer.description',
};
export const LISTING_TYPE_KEYS: Record<string, string> = {
selling: 'labelsUi.listingType.selling.label',
wtb: 'labelsUi.listingType.wtb.label',
};
export const LISTING_TYPE_DESC_KEYS: Record<string, string> = {
selling: 'labelsUi.listingType.selling.description',
wtb: 'labelsUi.listingType.wtb.description',
};
export const TOUR_SPOT_TYPE_KEYS: Record<string, string> = {
street: 'labelsUi.tour.spotType.street',
park: 'labelsUi.tour.spotType.park',
diy: 'labelsUi.tour.spotType.diy',
transition: 'labelsUi.tour.spotType.transition',
hidden: 'labelsUi.tour.spotType.hidden',
};
export const TOUR_CATEGORY_KEYS: Record<string, string> = {
local: 'labelsUi.tour.category.local.label',
national: 'labelsUi.tour.category.national.label',
international: 'labelsUi.tour.category.international.label',
destruction: 'labelsUi.tour.category.destruction.label',
};
export const TOUR_CATEGORY_DESC_KEYS: Record<string, string> = {
local: 'labelsUi.tour.category.local.description',
national: 'labelsUi.tour.category.national.description',
international: 'labelsUi.tour.category.international.description',
destruction: 'labelsUi.tour.category.destruction.description',
};
export const CREW_VIBE_KEYS: Record<string, string> = {
street: 'labelsUi.crew.vibe.street',
transition: 'labelsUi.crew.vibe.transition',
park: 'labelsUi.crew.vibe.park',
diy: 'labelsUi.crew.vibe.diy',
cruising: 'labelsUi.crew.vibe.cruising',
oldschool: 'labelsUi.crew.vibe.oldschool',
allterrain: 'labelsUi.crew.vibe.allterrain',
girls: 'labelsUi.crew.vibe.girls',
youth: 'labelsUi.crew.vibe.youth',
adaptive: 'labelsUi.crew.vibe.adaptive',
punk: 'labelsUi.crew.vibe.punk',
hiphop: 'labelsUi.crew.vibe.hiphop',
};
export const CREW_FOCUS_KEYS: Record<string, string> = {
mixed: 'labelsUi.crew.focus.mixed',
builder: 'labelsUi.crew.focus.builder',
photo: 'labelsUi.crew.focus.photo',
coaching: 'labelsUi.crew.focus.coaching',
art: 'labelsUi.crew.focus.art',
music: 'labelsUi.crew.focus.music',
wellness: 'labelsUi.crew.focus.wellness',
};
export const CREW_FOCUS_DESC_KEYS: Record<string, string> = {
mixed: 'labelsUi.crew.focusDescription.mixed',
builder: 'labelsUi.crew.focusDescription.builder',
photo: 'labelsUi.crew.focusDescription.photo',
coaching: 'labelsUi.crew.focusDescription.coaching',
art: 'labelsUi.crew.focusDescription.art',
music: 'labelsUi.crew.focusDescription.music',
wellness: 'labelsUi.crew.focusDescription.wellness',
};
export const CREW_LOOKING_FOR_KEYS: Record<string, string> = {
filmer: 'labelsUi.crew.lookingFor.filmer',
photographer: 'labelsUi.crew.lookingFor.photographer',
editor: 'labelsUi.crew.lookingFor.editor',
coach: 'labelsUi.crew.lookingFor.coach',
builder: 'labelsUi.crew.lookingFor.builder',
artist: 'labelsUi.crew.lookingFor.artist',
musician: 'labelsUi.crew.lookingFor.musician',
members: 'labelsUi.crew.lookingFor.members',
};
export const CREW_ROLE_KEYS: Record<string, string> = {
filmer: 'labelsUi.crew.role.filmer',
photographer: 'labelsUi.crew.role.photographer',
editor: 'labelsUi.crew.role.editor',
coach: 'labelsUi.crew.role.coach',
builder: 'labelsUi.crew.role.builder',
artist: 'labelsUi.crew.role.artist',
graffiti: 'labelsUi.crew.role.graffiti',
tattoo: 'labelsUi.crew.role.tattoo',
trainer: 'labelsUi.crew.role.trainer',
musician: 'labelsUi.crew.role.musician',
judge: 'labelsUi.crew.role.judge',
manager: 'labelsUi.crew.role.manager',
og: 'labelsUi.crew.role.og',
};
export const MEMBER_VISIBILITY_KEYS: Record<string, string> = {
public: 'labelsUi.memberVisibility.public.label',
crew: 'labelsUi.memberVisibility.crew.label',
};
export const MEMBER_VISIBILITY_DESC_KEYS: Record<string, string> = {
public: 'labelsUi.memberVisibility.public.description',
crew: 'labelsUi.memberVisibility.crew.description',
};
/** Keyed by the TagCategory's English `name` (the only stable identifier). */
export const TAG_CATEGORY_KEYS: Record<string, string> = {
Features: 'labelsUi.tagCategory.features.name',
Surfaces: 'labelsUi.tagCategory.surfaces.name',
Sizes: 'labelsUi.tagCategory.sizes.name',
'Stair Counts': 'labelsUi.tagCategory.stairs.name',
Vibes: 'labelsUi.tagCategory.vibes.name',
Location: 'labelsUi.tagCategory.location.name',
Tricks: 'labelsUi.tagCategory.tricks.name',
};
export const TAG_CATEGORY_DESC_KEYS: Record<string, string> = {
Features: 'labelsUi.tagCategory.features.description',
Surfaces: 'labelsUi.tagCategory.surfaces.description',
Sizes: 'labelsUi.tagCategory.sizes.description',
'Stair Counts': 'labelsUi.tagCategory.stairs.description',
Vibes: 'labelsUi.tagCategory.vibes.description',
Location: 'labelsUi.tagCategory.location.description',
Tricks: 'labelsUi.tagCategory.tricks.description',
};
export const SPOT_TAG_CATEGORY_KEYS: Record<string, string> = {
street: 'labelsUi.spotTagCategory.street.label',
park: 'labelsUi.spotTagCategory.park.label',
diy: 'labelsUi.spotTagCategory.diy.label',
plaza: 'labelsUi.spotTagCategory.plaza.label',
};
export const SPOT_TAG_CATEGORY_DESC_KEYS: Record<string, string> = {
street: 'labelsUi.spotTagCategory.street.description',
park: 'labelsUi.spotTagCategory.park.description',
diy: 'labelsUi.spotTagCategory.diy.description',
plaza: 'labelsUi.spotTagCategory.plaza.description',
};
export const STATUS_KEYS: Record<string, string> = {
active: 'labelsUi.status.active.label',
bust: 'labelsUi.status.bust.label',
skate_stopped: 'labelsUi.status.skate_stopped.label',
closed: 'labelsUi.status.closed.label',
gone: 'labelsUi.status.gone.label',
};
export const STATUS_DESC_KEYS: Record<string, string> = {
active: 'labelsUi.status.active.description',
bust: 'labelsUi.status.bust.description',
skate_stopped: 'labelsUi.status.skate_stopped.description',
closed: 'labelsUi.status.closed.description',
gone: 'labelsUi.status.gone.description',
};

View file

@ -1,4 +1,4 @@
import type { RouteResult } from '@/lib/routing';
import type { RouteResult, TranslateFn } from '@/lib/routing';
/**
* Tiny localStorage cache of the last few routes.
@ -101,11 +101,11 @@ export function loadRoute(
}
/** "just now" | "12 min ago" | "2 h ago" — for the cached-route badge. */
export function formatAge(ageMs: number): string {
export function formatAge(ageMs: number, t: TranslateFn): string {
const mins = Math.round(ageMs / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins} min ago`;
if (mins < 1) return t('routeUi.ageJustNow');
if (mins < 60) return t('routeUi.ageMinAgo', { count: mins });
const hours = Math.round(mins / 60);
if (hours < 24) return `${hours} h ago`;
return `${Math.round(hours / 24)} d ago`;
if (hours < 24) return t('routeUi.ageHourAgo', { count: hours });
return t('routeUi.ageDayAgo', { count: Math.round(hours / 24) });
}

View file

@ -27,6 +27,12 @@ import { dateLocaleCode } from '@/lib/dateFnsI18n';
/** Travel modes offered in the UI. */
export type TravelMode = 'foot' | 'bike' | 'moto' | 'car' | 'transit';
/**
* Minimal shape of the translation function, declared locally so pure lib
* helpers can accept `t` without importing the React context.
*/
export type TranslateFn = (key: string, params?: Record<string, string | number>) => string;
export interface TravelModeMeta {
mode: TravelMode;
/** OSRM routing profile actually used (undefined for transit). */
@ -184,8 +190,15 @@ interface OsrmResponse {
/** One turn-by-turn instruction (used for orientation, not live navigation). */
export interface RouteStep {
/** Human instruction, e.g. "Turn left onto Sukhumvit Rd". */
/** Human instruction, e.g. "Turn left onto Sukhumvit Rd". English fallback
* used for routes cached before `label` existed. */
instruction: string;
/** Maneuver token (see `maneuverDescriptor`). The UI maps it to a literal
* translation key; absent on legacy cached steps. */
label?: string;
/** Road/way name to append ("… onto Sukhumvit Rd") — feed data, never
* translated. Only set for maneuvers that take a road. */
road?: string;
/** Length of this step in metres. */
distanceM: number;
/** Where the manoeuvre happens. */
@ -193,30 +206,46 @@ export interface RouteStep {
lng: number;
}
/** Turn OSRM's terse maneuver codes into something readable. */
function maneuverLabel(type: string, modifier: string): string {
/** The app-owned maneuver token + English fallback, from OSRM's terse codes. */
function maneuverDescriptor(type: string, modifier: string): { token: string | null; en: string } {
const t = type.toLowerCase();
const m = modifier.toLowerCase();
if (t === 'depart') return 'Head out';
if (t === 'arrive') return 'Arrive at the spot';
if (t === 'new name') return 'Continue';
if (t === 'continue') return m.includes('left') ? 'Bear left' : m.includes('right') ? 'Bear right' : 'Continue';
if (t === 'turn') {
if (m.includes('sharp left')) return 'Sharp left';
if (m.includes('sharp right')) return 'Sharp right';
if (m.includes('slight left')) return 'Bear left';
if (m.includes('slight right')) return 'Bear right';
if (m.includes('left')) return 'Turn left';
if (m.includes('right')) return 'Turn right';
return 'Turn';
const pick = (token: string, en: string) => ({ token, en });
if (t === 'depart') return pick('headOut', 'Head out');
if (t === 'arrive') return pick('arrive', 'Arrive at the spot');
if (t === 'new name') return pick('continueStraight', 'Continue');
if (t === 'continue') {
return m.includes('left')
? pick('bearLeft', 'Bear left')
: m.includes('right')
? pick('bearRight', 'Bear right')
: pick('continueStraight', 'Continue');
}
if (t === 'fork') return m.includes('left') ? 'Keep left' : m.includes('right') ? 'Keep right' : 'Take the fork';
if (t === 'merge') return 'Merge';
if (t === 'roundabout' || t === 'rotary') return 'Take the roundabout';
if (t === 'end of road') return m.includes('left') ? 'Turn left' : 'Turn right';
if (t === 'on ramp') return 'Take the ramp';
if (t === 'off ramp') return 'Take the exit';
return t.replace(/\b\w/g, (c) => c.toUpperCase());
if (t === 'turn') {
if (m.includes('sharp left')) return pick('sharpLeft', 'Sharp left');
if (m.includes('sharp right')) return pick('sharpRight', 'Sharp right');
if (m.includes('slight left')) return pick('bearLeft', 'Bear left');
if (m.includes('slight right')) return pick('bearRight', 'Bear right');
if (m.includes('left')) return pick('turnLeft', 'Turn left');
if (m.includes('right')) return pick('turnRight', 'Turn right');
return pick('turn', 'Turn');
}
if (t === 'fork') {
return m.includes('left')
? pick('keepLeft', 'Keep left')
: m.includes('right')
? pick('keepRight', 'Keep right')
: pick('takeFork', 'Take the fork');
}
if (t === 'merge') return pick('merge', 'Merge');
if (t === 'roundabout' || t === 'rotary') return pick('roundabout', 'Take the roundabout');
if (t === 'end of road') {
return m.includes('left') ? pick('turnLeft', 'Turn left') : pick('turnRight', 'Turn right');
}
if (t === 'on ramp') return pick('takeRamp', 'Take the ramp');
if (t === 'off ramp') return pick('takeExit', 'Take the exit');
// Unknown OSRM code — keep it raw (feed-ish data, not app copy).
return { token: null, en: t.replace(/\b\w/g, (c) => c.toUpperCase()) };
}
function parseSteps(data: OsrmResponse): RouteStep[] {
@ -225,11 +254,14 @@ function parseSteps(data: OsrmResponse): RouteStep[] {
const steps: RouteStep[] = [];
for (const leg of route.legs) {
for (const s of leg.steps ?? []) {
const label = maneuverLabel(s.maneuver?.type ?? 'continue', s.maneuver?.modifier ?? '');
const { token, en } = maneuverDescriptor(s.maneuver?.type ?? 'continue', s.maneuver?.modifier ?? '');
const road = (s.name ?? '').trim();
const loc = s.maneuver?.location;
const takesRoad = token !== null && token !== 'arrive' && !!road;
steps.push({
instruction: road && !label.startsWith('Arrive') ? `${label} onto ${road}` : label,
instruction: takesRoad ? `${en} onto ${road}` : en,
label: token ?? undefined,
road: takesRoad ? road : undefined,
distanceM: s.distance ?? 0,
lat: loc?.[1] ?? 0,
lng: loc?.[0] ?? 0,
@ -546,23 +578,24 @@ export function formatDuration(seconds: number): string {
* Skaters think in time, not distance. Estimate a cruising time from a
* straight-line distance (km) at a relaxed ~10 km/h push, and phrase it in a
* skate-native way. No routing call needed — pure formula, works offline.
* `t` is passed in by the caller (this lib has no React context).
*/
export function skateTime(km: number): string {
export function skateTime(km: number, t: TranslateFn): string {
if (!isFinite(km) || km < 0) return '';
const CRUISE_KMH = 10; // relaxed cruising pace
const mins = Math.round((km / CRUISE_KMH) * 60);
if (mins < 1) return 'right here';
if (mins < 60) return `~${mins} min cruise`;
if (mins < 1) return t('routeUi.skateRightHere');
if (mins < 60) return t('routeUi.skateMin', { count: mins });
const h = (mins / 60).toFixed(1);
return `~${h} h cruise`;
return t('routeUi.skateHours', { hours: h });
}
/**
* Compact one-line readout for cards/popups given a distance in km.
* e.g. "1.2 km · ~7 min cruise"
*/
export function distanceReadout(km: number): string {
export function distanceReadout(km: number, t: TranslateFn): string {
const d = formatDistance(km);
const t = skateTime(km);
return t ? `${d} · ${t}` : d;
const time = skateTime(km, t);
return time ? `${d} · ${time}` : d;
}

View file

@ -25,20 +25,25 @@ export interface ProProof {
export interface ProTier {
id: string;
/** English label — kept as a non-UI fallback. Prefer `labelKey` + `t`. */
label: string;
/** `sasquatchUi.tier*` literal key for UI rendering. */
labelKey: string;
days: number;
sats: number;
/** Marketing flag. */
/** English marketing flag — kept as a non-UI fallback. Prefer `badgeKey`. */
badge?: string;
/** `sasquatchUi.badge*` literal key, when a badge exists. */
badgeKey?: string;
popular?: boolean;
}
/** The four supporter tiers. Kept deliberately small to avoid choice paralysis. */
export const PRO_TIERS: ProTier[] = [
{ id: '7d', label: '7 days', days: 7, sats: 2000, badge: 'Try it' },
{ id: '1m', label: '1 month', days: 30, sats: 5000 },
{ id: '3m', label: '3 months', days: 90, sats: 12000, badge: '20% off', popular: true },
{ id: '1y', label: '1 year', days: 365, sats: 36000, badge: 'Best value · 40% off' },
{ id: '7d', label: '7 days', labelKey: 'sasquatchUi.tier7d', days: 7, sats: 2000, badge: 'Try it', badgeKey: 'sasquatchUi.badgeTry' },
{ id: '1m', label: '1 month', labelKey: 'sasquatchUi.tier1m', days: 30, sats: 5000 },
{ id: '3m', label: '3 months', labelKey: 'sasquatchUi.tier3m', days: 90, sats: 12000, badge: '20% off', badgeKey: 'sasquatchUi.badgeOff20', popular: true },
{ id: '1y', label: '1 year', labelKey: 'sasquatchUi.tier1y', days: 365, sats: 36000, badge: 'Best value · 40% off', badgeKey: 'sasquatchUi.badgeBest' },
];
export function tierById(id: string): ProTier | undefined {

View file

@ -11,14 +11,20 @@
export interface TagCategory {
name: string;
/** `labelsUi.tagCategory.*` translation key for `name`. */
nameKey: string;
description: string;
/** `labelsUi.tagCategory.*` translation key for `description`. */
descriptionKey: string;
tags: string[];
}
// Feature tags - obstacles and terrain types
export const featureTags: TagCategory = {
name: 'Features',
nameKey: 'labelsUi.tagCategory.features.name',
description: 'Obstacles and terrain',
descriptionKey: 'labelsUi.tagCategory.features.description',
tags: [
// Ledges & Manual Pads
'ledge',
@ -110,7 +116,9 @@ export const featureTags: TagCategory = {
// Surface tags - materials and textures
export const surfaceTags: TagCategory = {
name: 'Surfaces',
nameKey: 'labelsUi.tagCategory.surfaces.name',
description: 'Materials and textures',
descriptionKey: 'labelsUi.tagCategory.surfaces.description',
tags: [
'concrete',
'marble',
@ -139,7 +147,9 @@ export const surfaceTags: TagCategory = {
// Size tags - dimensions and scale
export const sizeTags: TagCategory = {
name: 'Sizes',
nameKey: 'labelsUi.tagCategory.sizes.name',
description: 'Dimensions and scale',
descriptionKey: 'labelsUi.tagCategory.sizes.description',
tags: [
'small',
'medium',
@ -163,7 +173,9 @@ export const sizeTags: TagCategory = {
// Stair count tags
export const stairTags: TagCategory = {
name: 'Stair Counts',
nameKey: 'labelsUi.tagCategory.stairs.name',
description: 'Number of stairs',
descriptionKey: 'labelsUi.tagCategory.stairs.description',
tags: [
'2-stair',
'3-stair',
@ -185,7 +197,9 @@ export const stairTags: TagCategory = {
// Vibe & condition tags
export const vibeTags: TagCategory = {
name: 'Vibes',
nameKey: 'labelsUi.tagCategory.vibes.name',
description: 'Atmosphere and condition',
descriptionKey: 'labelsUi.tagCategory.vibes.description',
tags: [
// Fame & History
'famous',
@ -258,7 +272,9 @@ export const vibeTags: TagCategory = {
// Location context tags
export const locationTags: TagCategory = {
name: 'Location',
nameKey: 'labelsUi.tagCategory.location.name',
description: 'Location context',
descriptionKey: 'labelsUi.tagCategory.location.description',
tags: [
'downtown',
'city-center',
@ -294,7 +310,9 @@ export const locationTags: TagCategory = {
// Trick-specific tags
export const trickTags: TagCategory = {
name: 'Tricks',
nameKey: 'labelsUi.tagCategory.tricks.name',
description: 'Good for specific tricks',
descriptionKey: 'labelsUi.tagCategory.tricks.description',
tags: [
'grind-spot',
'slide-spot',

View file

@ -1,13 +1,19 @@
import type { SpotStatusValue } from '@/lib/types';
import { formatDistanceToNow } from '@/lib/dateFnsI18n';
import { STATUS_KEYS, STATUS_DESC_KEYS } from '@/lib/labelKeys';
export interface SpotStatusMeta {
value: SpotStatusValue;
/** Short human label. */
/** Short human label (English fallback — translate via `labelKey`). */
label: string;
/** `labelsUi.status.*` translation key for `label`. */
labelKey: string;
/** Emoji used in compact contexts. */
emoji: string;
/** One-line description for pickers. */
/** One-line description for pickers (English fallback — translate via `descriptionKey`). */
description: string;
/** `labelsUi.status.*` translation key for `description`. */
descriptionKey: string;
/** Tailwind classes for badge text + background + border. */
badgeClass: string;
/** Hex dot color for map/legend use. */
@ -18,40 +24,50 @@ export const SPOT_STATUS_META: Record<SpotStatusValue, SpotStatusMeta> = {
active: {
value: 'active',
label: 'Active',
labelKey: STATUS_KEYS.active,
emoji: '🟢',
description: 'Good to go — skate it.',
descriptionKey: STATUS_DESC_KEYS.active,
badgeClass: 'text-green-400 bg-green-500/10 border-green-500/30',
dot: '#22c55e',
},
bust: {
value: 'bust',
label: 'Bust',
labelKey: STATUS_KEYS.bust,
emoji: '🟡',
description: 'Heavy kick-out risk — security or cops.',
descriptionKey: STATUS_DESC_KEYS.bust,
badgeClass: 'text-yellow-400 bg-yellow-500/10 border-yellow-500/30',
dot: '#eab308',
},
skate_stopped: {
value: 'skate_stopped',
label: 'Skate-stopped',
labelKey: STATUS_KEYS.skate_stopped,
emoji: '🟠',
description: 'Knobbed / capped, but partially skateable.',
descriptionKey: STATUS_DESC_KEYS.skate_stopped,
badgeClass: 'text-orange-400 bg-orange-500/10 border-orange-500/30',
dot: '#f97316',
},
closed: {
value: 'closed',
label: 'Closed',
labelKey: STATUS_KEYS.closed,
emoji: '⚪',
description: 'Temporarily closed — construction or event.',
descriptionKey: STATUS_DESC_KEYS.closed,
badgeClass: 'text-gray-300 bg-gray-500/10 border-gray-500/30',
dot: '#9ca3af',
},
gone: {
value: 'gone',
label: 'Gone',
labelKey: STATUS_KEYS.gone,
emoji: '🔴',
description: 'Demolished, paved over, or fenced off for good.',
descriptionKey: STATUS_DESC_KEYS.gone,
badgeClass: 'text-red-400 bg-red-500/10 border-red-500/40',
dot: '#ef4444',
},
@ -66,22 +82,10 @@ export const SPOT_STATUS_ORDER: SpotStatusValue[] = [
'gone',
];
/** Human "time ago" for a unix-seconds timestamp. */
/**
* Human "time ago" for a unix-seconds timestamp, in the active app language
* (via the locale-aware `dateFnsI18n` shim — "3 days ago" / "hace 3 días").
*/
export function statusTimeAgo(unixSeconds: number): string {
const diff = Math.max(0, Math.floor(Date.now() / 1000) - unixSeconds);
const days = Math.floor(diff / 86400);
if (days >= 365) {
const y = Math.floor(days / 365);
return `${y}y ago`;
}
if (days >= 30) {
const m = Math.floor(days / 30);
return `${m}mo ago`;
}
if (days >= 1) return `${days}d ago`;
const hours = Math.floor(diff / 3600);
if (hours >= 1) return `${hours}h ago`;
const mins = Math.floor(diff / 60);
if (mins >= 1) return `${mins}m ago`;
return 'just now';
return formatDistanceToNow(new Date(unixSeconds * 1000), { addSuffix: true });
}

View file

@ -1,31 +1,16 @@
import { SPOT_TYPE_LABELS } from '@/lib/types';
import { SPOT_TYPE_KEYS } from '@/lib/labelKeys';
type Translate = (key: string, params?: Record<string, string | number>) => string;
/**
* Spot-type keys that have a translated label under `chromeUi.spotType.*`.
* Anything else (feature tags like `wallrides`, legacy values) falls back to
* the English label so map popups never show a raw tag string.
*/
const TRANSLATED_SPOT_TYPES = new Set([
'street',
'transition',
'park',
'plaza',
'diy',
'bowl',
'vert',
'indoor',
'skatepark',
'miniramp',
]);
/**
* Localised label for a spot type/category key. Translates the ten spot-type
* keys; every other key keeps its English `SPOT_TYPE_LABELS` label (or the key
* itself as a last resort).
* Localised label for a spot type/category key. Every key with a
* `labelsUi.spotType.*` translation is translated; anything else (legacy values)
* falls back to the English `SPOT_TYPE_LABELS` label, or the key itself as a
* last resort, so map popups never show a raw tag string.
*/
export function spotTypeLabel(t: Translate, key: string): string {
if (TRANSLATED_SPOT_TYPES.has(key)) return t(`chromeUi.spotType.${key}`);
const labelKey = SPOT_TYPE_KEYS[key];
if (labelKey) return t(labelKey);
return SPOT_TYPE_LABELS[key] ?? key;
}

View file

@ -3,6 +3,7 @@ import ngeohash from 'ngeohash';
import { countLeadingZeroBits } from '@/lib/pow';
import { parseCrewVideo } from '@/lib/videoEmbed';
import { isAdminPubkey } from '@/lib/admins';
import { SPOT_FEATURE_KEYS } from '@/lib/labelKeys';
// Community relay for all skate-specific content (spots, events, coaches, shops, etc.)
// This ensures all users see the same content regardless of their personal relay settings
@ -1232,18 +1233,18 @@ export const SPOT_FEATURES = {
} as const;
// Get features available for a category
export function getFeaturesForCategory(category: string): Array<{ key: string; label: string; emoji: string }> {
export function getFeaturesForCategory(category: string): Array<{ key: string; label: string; labelKey: string; emoji: string }> {
const allStreetFeatures = Object.entries(SPOT_FEATURES)
.filter(([, v]) => v.category === 'street')
.map(([key, v]) => ({ key, label: v.label, emoji: v.emoji }));
.map(([key, v]) => ({ key, label: v.label, labelKey: SPOT_FEATURE_KEYS[key], emoji: v.emoji }));
const allTransitionFeatures = Object.entries(SPOT_FEATURES)
.filter(([, v]) => v.category === 'transition')
.map(([key, v]) => ({ key, label: v.label, emoji: v.emoji }));
.map(([key, v]) => ({ key, label: v.label, labelKey: SPOT_FEATURE_KEYS[key], emoji: v.emoji }));
const allParkFeatures = Object.entries(SPOT_FEATURES)
.filter(([, v]) => v.category === 'park')
.map(([key, v]) => ({ key, label: v.label, emoji: v.emoji }));
.map(([key, v]) => ({ key, label: v.label, labelKey: SPOT_FEATURE_KEYS[key], emoji: v.emoji }));
switch (category) {
case 'street':
@ -1260,10 +1261,11 @@ export function getFeaturesForCategory(category: string): Array<{ key: string; l
}
// Get all features as flat list (for filters)
export function getAllFeatures(): Array<{ key: string; label: string; emoji: string; category: string }> {
export function getAllFeatures(): Array<{ key: string; label: string; labelKey: string; emoji: string; category: string }> {
return Object.entries(SPOT_FEATURES).map(([key, v]) => ({
key,
label: v.label,
labelKey: SPOT_FEATURE_KEYS[key],
emoji: v.emoji,
category: v.category,
}));
@ -3533,12 +3535,16 @@ export function getCurrencySymbol(code: string): string {
}
// Helper to format price with correct currency symbol
export function formatPrice(price: { amount: string; currency: string } | undefined, priceType?: string): string {
export function formatPrice(
price: { amount: string; currency: string } | undefined,
priceType?: string,
t?: (key: string) => string,
): string {
if (!price) {
if (priceType === 'free') return 'FREE';
if (priceType === 'trade') return 'Trade';
if (priceType === 'offer') return 'Make Offer';
return 'Offer';
if (priceType === 'free') return t ? t('labelsUi.price.free') : 'FREE';
if (priceType === 'trade') return t ? t('labelsUi.price.trade') : 'Trade';
if (priceType === 'offer') return t ? t('labelsUi.price.makeOffer') : 'Make Offer';
return t ? t('labelsUi.price.offer') : 'Offer';
}
const symbol = getCurrencySymbol(price.currency);

View file

@ -247,7 +247,7 @@ export default function CommunityStatsPage() {
{t('walletUi.stats.dataSource')}&nbsp;
<span className="text-[#ccff00] font-mono">{SKATE_RELAY.replace('wss://', '')}</span>
{dataUpdatedAt > 0 && (
<><span aria-hidden>·</span><span>{t('walletUi.stats.updated', { age: formatAge(Date.now() - dataUpdatedAt) })}</span></>
<><span aria-hidden>·</span><span>{t('walletUi.stats.updated', { age: formatAge(Date.now() - dataUpdatedAt, t) })}</span></>
)}
<button
type="button"

View file

@ -47,16 +47,7 @@ import { Link } from 'react-router-dom';
const EVENT_TYPES = Object.entries(EVENT_TYPE_LABELS);
/** Map event type keys to translation keys (types live in lib/types). */
const EVENT_TYPE_KEYS: Record<string, string> = {
contest: 'placesUi.eventTypes.contest',
session: 'placesUi.eventTypes.session',
meetup: 'placesUi.eventTypes.meetup',
premiere: 'placesUi.eventTypes.premiere',
workshop: 'placesUi.eventTypes.workshop',
demo: 'placesUi.eventTypes.demo',
jam: 'placesUi.eventTypes.jam',
};
import { EVENT_TYPE_KEYS, SPOT_CATEGORY_KEYS, TOUR_CATEGORY_KEYS } from '@/lib/labelKeys';
function eventTypeLabel(t: (key: string) => string, type: string): string {
return EVENT_TYPE_KEYS[type] ? t(EVENT_TYPE_KEYS[type]) : (EVENT_TYPE_LABELS[type] || type);
@ -702,7 +693,7 @@ export default function CreateEventPage() {
<div className="flex-1 min-w-0">
{selectedSpot.category && SPOT_CATEGORIES[selectedSpot.category as keyof typeof SPOT_CATEGORIES] && (
<Badge variant="outline" className="mb-1 text-[10px] border-[#ff0040]/50 text-[#ff0040]">
{SPOT_CATEGORIES[selectedSpot.category as keyof typeof SPOT_CATEGORIES].emoji} {SPOT_CATEGORIES[selectedSpot.category as keyof typeof SPOT_CATEGORIES].label}
{SPOT_CATEGORIES[selectedSpot.category as keyof typeof SPOT_CATEGORIES].emoji} {SPOT_CATEGORY_KEYS[selectedSpot.category] ? t(SPOT_CATEGORY_KEYS[selectedSpot.category]) : SPOT_CATEGORIES[selectedSpot.category as keyof typeof SPOT_CATEGORIES].label}
</Badge>
)}
<h4 className="font-bold text-white text-sm">{selectedSpot.title}</h4>
@ -757,7 +748,7 @@ export default function CreateEventPage() {
return (
<div key={category}>
<div className="px-2 py-1.5 text-xs font-bold text-gray-400 uppercase flex items-center gap-1 sticky top-0 bg-black/80 backdrop-blur-sm">
{catInfo?.emoji || '🛹'} {catInfo?.label || category} ({categorySpots.length})
{catInfo?.emoji || '🛹'} {SPOT_CATEGORY_KEYS[category] ? t(SPOT_CATEGORY_KEYS[category]) : (catInfo?.label || category)} ({categorySpots.length})
</div>
{categorySpots.map((spot) => (
<button
@ -999,7 +990,7 @@ export default function CreateEventPage() {
color: TOUR_CATEGORIES[selectedTour.category]?.color
}}
>
{TOUR_CATEGORIES[selectedTour.category]?.emoji} {TOUR_CATEGORIES[selectedTour.category]?.label}
{TOUR_CATEGORIES[selectedTour.category]?.emoji} {selectedTour.category && TOUR_CATEGORY_KEYS[selectedTour.category] ? t(TOUR_CATEGORY_KEYS[selectedTour.category]) : TOUR_CATEGORIES[selectedTour.category]?.label}
</Badge>
<h4 className="font-bold text-white text-sm">{selectedTour.title}</h4>
{selectedTour.location && (

View file

@ -20,6 +20,7 @@ import { LoginArea } from '@/components/auth/LoginArea';
import { Nip05Required } from '@/components/Nip05Required';
import { SpamFeePayment } from '@/components/SpamFeePayment';
import { MARKETPLACE_ITEM_KIND, GEAR_CATEGORIES, GEAR_CONDITIONS, PRICE_TYPES, SIZE_OPTIONS, TRUCK_HEIGHTS, WHEEL_HARDNESS, EXPIRATION_OPTIONS, CURRENCY_OPTIONS, CATEGORY_REQUIRED_FIELDS } from '@/lib/types';
import { GEAR_CATEGORY_KEYS, GEAR_CONDITION_KEYS, GEAR_CONDITION_DESC_KEYS, PRICE_TYPE_KEYS, PRICE_TYPE_DESC_KEYS } from '@/lib/labelKeys';
import { ShoppingBag, Upload, X, Loader2, ArrowLeft, MapPin, AlertTriangle, CheckCircle, Search, Clock, Shield, Zap } from 'lucide-react';
import { useTranslation } from '@/contexts/language';
@ -237,7 +238,7 @@ export default function CreateListingPage() {
if (requiresSize && !size) {
toast({
title: t('marketUi.create.sizeRequired'),
description: t('marketUi.create.sizeRequiredBody', { category: GEAR_CATEGORIES[category as keyof typeof GEAR_CATEGORIES] }),
description: t('marketUi.create.sizeRequiredBody', { category: GEAR_CATEGORY_KEYS[category] ? t(GEAR_CATEGORY_KEYS[category]) : GEAR_CATEGORIES[category as keyof typeof GEAR_CATEGORIES] }),
variant: 'destructive'
});
return;
@ -246,7 +247,7 @@ export default function CreateListingPage() {
if (requiresCondition && !condition) {
toast({
title: t('marketUi.create.conditionRequired'),
description: t('marketUi.create.conditionRequiredBody', { category: GEAR_CATEGORIES[category as keyof typeof GEAR_CATEGORIES] }),
description: t('marketUi.create.conditionRequiredBody', { category: GEAR_CATEGORY_KEYS[category] ? t(GEAR_CATEGORY_KEYS[category]) : GEAR_CATEGORIES[category as keyof typeof GEAR_CATEGORIES] }),
variant: 'destructive'
});
return;
@ -596,7 +597,7 @@ export default function CreateListingPage() {
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2">
{Object.entries(GEAR_CATEGORIES).map(([key, label]) => (
{Object.entries(GEAR_CATEGORIES).map(([key]) => (
<Button
key={key}
type="button"
@ -614,7 +615,7 @@ export default function CreateListingPage() {
setWheelHardness('');
}}
>
{label}
{GEAR_CATEGORY_KEYS[key] ? t(GEAR_CATEGORY_KEYS[key]) : key}
</Button>
))}
</div>
@ -687,9 +688,9 @@ export default function CreateListingPage() {
onClick={() => setCondition(key)}
>
<span className="font-bold">
{emoji} {label}
{emoji} {GEAR_CONDITION_KEYS[key] ? t(GEAR_CONDITION_KEYS[key]) : label}
</span>
<span className="text-xs opacity-70">{description}</span>
<span className="text-xs opacity-70">{GEAR_CONDITION_DESC_KEYS[key] ? t(GEAR_CONDITION_DESC_KEYS[key]) : description}</span>
</Button>
))}
</div>
@ -835,8 +836,8 @@ export default function CreateListingPage() {
}`}
onClick={() => setPriceType(key)}
>
<span className="font-bold">{label}</span>
<span className="text-xs opacity-70">{description}</span>
<span className="font-bold">{PRICE_TYPE_KEYS[key] ? t(PRICE_TYPE_KEYS[key]) : label}</span>
<span className="text-xs opacity-70">{PRICE_TYPE_DESC_KEYS[key] ? t(PRICE_TYPE_DESC_KEYS[key]) : description}</span>
</Button>
))}
</div>

View file

@ -22,6 +22,7 @@ import { TagInput } from '@/components/TagInput';
import { SKATE_SPOT_KIND } from '@/lib/types';
import { reverseGeocodeRegions, pushRegionTags } from '@/lib/geocodeRegions';
import { spotCategories, MAX_TAGS, shelterOptions, DEFAULT_SHELTER, SHELTERED_VALUES, type ShelterType } from '@/lib/skateTags';
import { SPOT_TAG_CATEGORY_KEYS, SPOT_TAG_CATEGORY_DESC_KEYS, SHELTER_KEYS, SHELTER_DESC_KEYS } from '@/lib/labelKeys';
import { cn } from '@/lib/utils';
import {
MapPin,
@ -648,8 +649,8 @@ export default function CreateSpotPage() {
onClick={() => handleCategoryChange(category.value)}
>
<CardContent className="p-3 sm:p-4 text-center relative">
<div className="font-bold text-sm sm:text-base">{category.label}</div>
<div className="text-xs text-gray-400 mt-1">{category.description}</div>
<div className="font-bold text-sm sm:text-base">{SPOT_TAG_CATEGORY_KEYS[category.value] ? t(SPOT_TAG_CATEGORY_KEYS[category.value]) : category.label}</div>
<div className="text-xs text-gray-400 mt-1">{SPOT_TAG_CATEGORY_DESC_KEYS[category.value] ? t(SPOT_TAG_CATEGORY_DESC_KEYS[category.value]) : category.description}</div>
{selectedCategory === category.value && (
<div className="absolute top-1 right-1 w-4 h-4 sm:w-5 sm:h-5 rounded-full bg-[#ff0040] flex items-center justify-center">
<Check className="h-2.5 w-2.5 sm:h-3 sm:w-3 text-white" />
@ -683,8 +684,8 @@ export default function CreateSpotPage() {
<div className="flex items-start gap-2.5">
<div className="text-xl leading-none mt-0.5">{option.emoji}</div>
<div className="flex-1 min-w-0">
<div className="font-bold text-sm sm:text-base text-white">{option.label}</div>
<div className="text-xs text-gray-400 mt-0.5">{option.description}</div>
<div className="font-bold text-sm sm:text-base text-white">{SHELTER_KEYS[option.value] ? t(SHELTER_KEYS[option.value]) : option.label}</div>
<div className="text-xs text-gray-400 mt-0.5">{SHELTER_DESC_KEYS[option.value] ? t(SHELTER_DESC_KEYS[option.value]) : option.description}</div>
</div>
</div>
{shelter === option.value && (

View file

@ -21,6 +21,7 @@ import { Nip05Required } from '@/components/Nip05Required';
import { FeaturedUpgrade } from '@/components/FeaturedEventUpgrade';
import { usePlatformFee } from '@/hooks/usePlatformFee';
import { SKATE_TOUR_KIND, TOUR_SKILL_LEVELS, TOUR_SPOT_TYPES, TOUR_CATEGORIES, type TourCategory, type DestructionChallenge, type SkateTour } from '@/lib/types';
import { TOUR_CATEGORY_KEYS, TOUR_CATEGORY_DESC_KEYS, TOUR_SPOT_TYPE_KEYS } from '@/lib/labelKeys';
import { Route, Upload, X, Loader2, ArrowLeft, MapPin, Clock, Users, Zap, Globe, Info, ChevronDown, Store, Percent, Plane, Car, Skull, Trophy, Target, Plus, Trash2, Calendar, EyeOff } from 'lucide-react';
import { Link } from 'react-router-dom';
import { cn } from '@/lib/utils';
@ -947,7 +948,7 @@ export default function CreateTourPage({ existing }: { existing?: SkateTour } =
className="text-[11px] font-black uppercase tracking-[0.25em]"
style={{ color: TOUR_CATEGORIES[tourCategory].color }}
>
{isEditing ? t('common.edit') : t('common.new')} · {TOUR_CATEGORIES[tourCategory].label}
{isEditing ? t('common.edit') : t('common.new')} · {TOUR_CATEGORY_KEYS[tourCategory] ? t(TOUR_CATEGORY_KEYS[tourCategory]) : TOUR_CATEGORIES[tourCategory].label}
</p>
<h1
className="text-3xl sm:text-4xl font-black uppercase leading-none"
@ -960,7 +961,7 @@ export default function CreateTourPage({ existing }: { existing?: SkateTour } =
{isEditing ? t('placesUi.createTour.editTour') : t('placesUi.createTour.dropTour')}
</h1>
<p className="text-gray-400 mt-1 text-sm">
{TOUR_CATEGORIES[tourCategory].description}
{TOUR_CATEGORY_DESC_KEYS[tourCategory] ? t(TOUR_CATEGORY_DESC_KEYS[tourCategory]) : TOUR_CATEGORIES[tourCategory].description}
</p>
</div>
</div>
@ -1425,7 +1426,7 @@ export default function CreateTourPage({ existing }: { existing?: SkateTour } =
style={spotTypes.includes(key) ? { backgroundColor: TOUR_CATEGORIES[tourCategory].color } : undefined}
>
<span>{type.emoji}</span>
<span>{type.label}</span>
<span>{TOUR_SPOT_TYPE_KEYS[key] ? t(TOUR_SPOT_TYPE_KEYS[key]) : type.label}</span>
</Button>
))}
</div>

View file

@ -46,6 +46,7 @@ import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { genUserName } from '@/lib/genUserName';
import { SKATE_CREW_KIND, SHOW_CREW_TREASURY, CREW_LOOKING_FOR_LABELS, type CrewRole } from '@/lib/types';
import { CREW_LOOKING_FOR_KEYS } from '@/lib/labelKeys';
import { sanitizeUrl, urlDomain } from '@/lib/sanitizeUrl';
import NotFound from './NotFound';
import { Users, MapPin, ArrowLeft, Calendar, Globe, Instagram, Edit, MessageSquare, Zap, ShieldCheck, Music } from 'lucide-react';
@ -550,7 +551,7 @@ export default function CrewDetailPage() {
vibes: crew.vibes,
founderName,
memberCount: 1 + crew.members.length,
lookingFor: crew.lookingFor.map((s) => CREW_LOOKING_FOR_LABELS[s]?.label ?? s),
lookingFor: crew.lookingFor.map((s) => CREW_LOOKING_FOR_KEYS[s] ? t(CREW_LOOKING_FOR_KEYS[s]) : (CREW_LOOKING_FOR_LABELS[s]?.label ?? s)),
bandName: crew.bandName,
homeSpotCount: crew.spotRefs.length,
website: crew.website,

View file

@ -14,7 +14,8 @@ import { useCrews } from '@/hooks/useCrews';
import { useCrewsActivity, crewCoordinate } from '@/hooks/useCrewActivity';
import { useGeolocation } from '@/hooks/useGeolocation';
import { crewAccent, crewDistanceKm, formatDistance, type LatLng } from '@/lib/crewHelpers';
import { CREW_VIBE_LABELS, CREW_FOCUS_LABELS, type SkateCrew, type CrewFocus } from '@/lib/types';
import type { SkateCrew, CrewFocus } from '@/lib/types';
import { CREW_VIBE_KEYS, CREW_FOCUS_KEYS } from '@/lib/labelKeys';
import type { CrewActivity } from '@/lib/crewHelpers';
import { cn } from '@/lib/utils';
import { Users, Plus, Search, MapPin, X, HardHat, Camera, GraduationCap, Sparkles, Navigation, Loader2, Music, Palette, Dumbbell, Film, ChevronDown, Check } from 'lucide-react';
@ -99,7 +100,7 @@ function CrewCard({
{FocusIcon && (
<span className="inline-flex max-w-full items-center gap-1 truncate rounded-full border border-white/10 bg-white/[0.06] px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-gray-300">
<FocusIcon className="h-2.5 w-2.5 shrink-0" />
{CREW_FOCUS_LABELS[crew.focus]}
{CREW_FOCUS_KEYS[crew.focus] ? t(CREW_FOCUS_KEYS[crew.focus]) : crew.focus}
</span>
)}
{crew.videoUrl && (
@ -126,7 +127,7 @@ function CrewCard({
className="inline-flex max-w-full items-center truncate rounded-full border px-2 py-0.5 text-[10px] font-bold"
style={{ color: accent.hex, borderColor: `${accent.hex}55`, background: `${accent.hex}18` }}
>
{CREW_VIBE_LABELS[v] || v}
{CREW_VIBE_KEYS[v] ? t(CREW_VIBE_KEYS[v]) : v}
</span>
))}
</div>
@ -152,13 +153,13 @@ export default function CrewsPage() {
// Remembered preference: compact short labels vs. the full filter names.
const FOCUS_FILTERS: { key: CrewFocus | 'all'; label: string; short: string; icon: typeof Users }[] = [
{ key: 'all', label: t('socialUi.crews.focusAll'), short: t('socialUi.crews.focusAllShort'), icon: Users },
{ key: 'builder', label: CREW_FOCUS_LABELS.builder, short: t('socialUi.crews.focusDiy'), icon: HardHat },
{ key: 'photo', label: CREW_FOCUS_LABELS.photo, short: t('socialUi.crews.focusPhoto'), icon: Camera },
{ key: 'coaching', label: CREW_FOCUS_LABELS.coaching, short: t('socialUi.crews.focusCoaching'), icon: GraduationCap },
{ key: 'art', label: CREW_FOCUS_LABELS.art, short: t('socialUi.crews.focusArt'), icon: Palette },
{ key: 'music', label: CREW_FOCUS_LABELS.music, short: t('socialUi.crews.focusMusic'), icon: Music },
{ key: 'wellness', label: CREW_FOCUS_LABELS.wellness, short: t('socialUi.crews.focusPhysio'), icon: Dumbbell },
{ key: 'mixed', label: CREW_FOCUS_LABELS.mixed, short: t('socialUi.crews.focusMixed'), icon: Sparkles },
{ key: 'builder', label: CREW_FOCUS_KEYS.builder ? t(CREW_FOCUS_KEYS.builder) : 'builder', short: t('socialUi.crews.focusDiy'), icon: HardHat },
{ key: 'photo', label: CREW_FOCUS_KEYS.photo ? t(CREW_FOCUS_KEYS.photo) : 'photo', short: t('socialUi.crews.focusPhoto'), icon: Camera },
{ key: 'coaching', label: CREW_FOCUS_KEYS.coaching ? t(CREW_FOCUS_KEYS.coaching) : 'coaching', short: t('socialUi.crews.focusCoaching'), icon: GraduationCap },
{ key: 'art', label: CREW_FOCUS_KEYS.art ? t(CREW_FOCUS_KEYS.art) : 'art', short: t('socialUi.crews.focusArt'), icon: Palette },
{ key: 'music', label: CREW_FOCUS_KEYS.music ? t(CREW_FOCUS_KEYS.music) : 'music', short: t('socialUi.crews.focusMusic'), icon: Music },
{ key: 'wellness', label: CREW_FOCUS_KEYS.wellness ? t(CREW_FOCUS_KEYS.wellness) : 'wellness', short: t('socialUi.crews.focusPhysio'), icon: Dumbbell },
{ key: 'mixed', label: CREW_FOCUS_KEYS.mixed ? t(CREW_FOCUS_KEYS.mixed) : 'mixed', short: t('socialUi.crews.focusMixed'), icon: Sparkles },
];
const currentFocus = FOCUS_FILTERS.find((f) => f.key === focusFilter) ?? FOCUS_FILTERS[0];
const FocusIcon = currentFocus.icon;
@ -428,7 +429,7 @@ function EmptyState({
// 2) A scene filter with no crews in it — there *are* crews, just not here.
// Saying "no crews planted here yet" would be flatly wrong, so offer the way out.
if (focus !== 'all') {
const label = CREW_FOCUS_LABELS[focus];
const label = CREW_FOCUS_KEYS[focus] ? t(CREW_FOCUS_KEYS[focus]) : focus;
return (
<SharedEmptyState
icon={Users}

View file

@ -35,16 +35,7 @@ import NotFound from './NotFound';
const EVENT_TYPES = Object.entries(EVENT_TYPE_LABELS);
/** Map event type keys to translation keys (types live in lib/types). */
const EVENT_TYPE_KEYS: Record<string, string> = {
contest: 'placesUi.eventTypes.contest',
session: 'placesUi.eventTypes.session',
meetup: 'placesUi.eventTypes.meetup',
premiere: 'placesUi.eventTypes.premiere',
workshop: 'placesUi.eventTypes.workshop',
demo: 'placesUi.eventTypes.demo',
jam: 'placesUi.eventTypes.jam',
};
import { EVENT_TYPE_KEYS } from '@/lib/labelKeys';
function eventTypeLabel(t: (key: string) => string, type: string): string {
return EVENT_TYPE_KEYS[type] ? t(EVENT_TYPE_KEYS[type]) : (EVENT_TYPE_LABELS[type] || type);

View file

@ -20,6 +20,7 @@ import { TagInput } from '@/components/TagInput';
import { SKATE_SPOT_KIND } from '@/lib/types';
import { reverseGeocodeRegions, pushRegionTags } from '@/lib/geocodeRegions';
import { spotCategories, MAX_TAGS, shelterOptions, DEFAULT_SHELTER, SHELTERED_VALUES, type ShelterType } from '@/lib/skateTags';
import { SPOT_TAG_CATEGORY_KEYS, SPOT_TAG_CATEGORY_DESC_KEYS, SHELTER_KEYS, SHELTER_DESC_KEYS } from '@/lib/labelKeys';
import { cn } from '@/lib/utils';
import {
MapPin,
@ -578,8 +579,8 @@ export default function EditSpotPage() {
onClick={() => handleCategoryChange(category.value)}
>
<CardContent className="p-3 sm:p-4 text-center relative">
<div className="font-bold text-sm sm:text-base">{category.label}</div>
<div className="text-xs text-gray-400 mt-1">{category.description}</div>
<div className="font-bold text-sm sm:text-base">{SPOT_TAG_CATEGORY_KEYS[category.value] ? t(SPOT_TAG_CATEGORY_KEYS[category.value]) : category.label}</div>
<div className="text-xs text-gray-400 mt-1">{SPOT_TAG_CATEGORY_DESC_KEYS[category.value] ? t(SPOT_TAG_CATEGORY_DESC_KEYS[category.value]) : category.description}</div>
{selectedCategory === category.value && (
<div className="absolute top-1 right-1 w-4 h-4 sm:w-5 sm:h-5 rounded-full bg-[#ff0040] flex items-center justify-center">
<Check className="h-2.5 w-2.5 sm:h-3 sm:w-3 text-white" />
@ -613,8 +614,8 @@ export default function EditSpotPage() {
<div className="flex items-start gap-2.5">
<div className="text-xl leading-none mt-0.5">{option.emoji}</div>
<div className="flex-1 min-w-0">
<div className="font-bold text-sm sm:text-base text-white">{option.label}</div>
<div className="text-xs text-gray-400 mt-0.5">{option.description}</div>
<div className="font-bold text-sm sm:text-base text-white">{SHELTER_KEYS[option.value] ? t(SHELTER_KEYS[option.value]) : option.label}</div>
<div className="text-xs text-gray-400 mt-0.5">{SHELTER_DESC_KEYS[option.value] ? t(SHELTER_DESC_KEYS[option.value]) : option.description}</div>
</div>
</div>
{shelter === option.value && (

View file

@ -35,16 +35,7 @@ import { ControllerBadge } from '@/components/ControllerBadge';
import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/language';
/** Map event type keys to translation keys (types live in lib/types). */
const EVENT_TYPE_KEYS: Record<string, string> = {
contest: 'placesUi.eventTypes.contest',
session: 'placesUi.eventTypes.session',
meetup: 'placesUi.eventTypes.meetup',
premiere: 'placesUi.eventTypes.premiere',
workshop: 'placesUi.eventTypes.workshop',
demo: 'placesUi.eventTypes.demo',
jam: 'placesUi.eventTypes.jam',
};
import { EVENT_TYPE_KEYS } from '@/lib/labelKeys';
function eventTypeLabel(t: (key: string) => string, type: string): string {
return EVENT_TYPE_KEYS[type] ? t(EVENT_TYPE_KEYS[type]) : (EVENT_TYPE_LABELS[type] || type);

View file

@ -101,16 +101,7 @@ const DATE_WINDOW_LABELS_SHORT: Record<DateWindow, string> = {
month: 'placesUi.dateWindow.monthShort',
};
/** Map event type keys to translation keys (types live in lib/types). */
const EVENT_TYPE_KEYS: Record<string, string> = {
contest: 'placesUi.eventTypes.contest',
session: 'placesUi.eventTypes.session',
meetup: 'placesUi.eventTypes.meetup',
premiere: 'placesUi.eventTypes.premiere',
workshop: 'placesUi.eventTypes.workshop',
demo: 'placesUi.eventTypes.demo',
jam: 'placesUi.eventTypes.jam',
};
import { EVENT_TYPE_KEYS, TOUR_CATEGORY_KEYS } from '@/lib/labelKeys';
function eventTypeLabel(t: (key: string) => string, type: string): string {
return EVENT_TYPE_KEYS[type] ? t(EVENT_TYPE_KEYS[type]) : (EVENT_TYPE_LABELS[type] || type);
@ -641,7 +632,7 @@ function FilterDrawer({
color: isActive ? ((info.color as string) === '#ccff00' ? 'black' : 'white') : info.color,
}}
>
{info.emoji} {info.label}
{info.emoji} {TOUR_CATEGORY_KEYS[key] ? t(TOUR_CATEGORY_KEYS[key]) : info.label}
</button>
);
})}

View file

@ -17,6 +17,7 @@ import { useBatchSellerSales } from '@/hooks/useSellerSales';
import { useFollowList } from '@/hooks/useFollowList';
import { GearSwapItemCard } from '@/components/GearSwapItemCard';
import { GEAR_CATEGORIES } from '@/lib/types';
import { GEAR_CATEGORY_KEYS } from '@/lib/labelKeys';
import { getLastLocation } from '@/lib/lastLocation';
import { itemDistanceKm } from '@/lib/sortOptions';
import { ShoppingCart, Plus, Search, Filter, ArrowUpDown, Users, ShieldCheck, UserCheck, AlertCircle, MapPin } from 'lucide-react';
@ -183,9 +184,9 @@ export default function GearSwapPage() {
const categoryOptions = [
{ value: 'all', label: t('marketUi.gearSwap.allCategories') },
...Object.entries(GEAR_CATEGORIES).map(([key, label]) => ({
...Object.entries(GEAR_CATEGORIES).map(([key]) => ({
value: key,
label,
label: GEAR_CATEGORY_KEYS[key] ? t(GEAR_CATEGORY_KEYS[key]) : GEAR_CATEGORIES[key as keyof typeof GEAR_CATEGORIES],
})),
];

View file

@ -23,6 +23,7 @@ import { useAuthor } from '@/hooks/useAuthor';
import { useSellerInfo } from '@/hooks/useSellerInfo';
import { useToast } from '@/hooks/useToast';
import { formatPrice, GEAR_CONDITIONS, GEAR_CATEGORIES } from '@/lib/types';
import { GEAR_CATEGORY_KEYS, GEAR_CONDITION_KEYS } from '@/lib/labelKeys';
import { genUserName } from '@/lib/genUserName';
import { cn } from '@/lib/utils';
import {
@ -101,10 +102,15 @@ export default function ListingDetailPage() {
const isWTB = item.listingType === 'wtb';
const isSold = item.status === 'sold';
const priceDisplay = item.price
? `${formatPrice(item.price, item.priceType)}${item.price.frequency ? `/${item.price.frequency}` : ''}`
: formatPrice(undefined, item.priceType);
? `${formatPrice(item.price, item.priceType, t)}${item.price.frequency ? `/${item.price.frequency}` : ''}`
: formatPrice(undefined, item.priceType, t);
const conditionInfo = item.condition ? GEAR_CONDITIONS[item.condition] : null;
const categoryLabel = item.category.find((c) => GEAR_CATEGORIES[c as keyof typeof GEAR_CATEGORIES]);
const categoryValue = item.category.find((c) => GEAR_CATEGORIES[c as keyof typeof GEAR_CATEGORIES]);
const categoryLabel = categoryValue
? GEAR_CATEGORY_KEYS[categoryValue]
? t(GEAR_CATEGORY_KEYS[categoryValue])
: GEAR_CATEGORIES[categoryValue as keyof typeof GEAR_CATEGORIES]
: null;
const displayName = metadata?.name ?? (pubkey ? genUserName(pubkey) : t('marketUi.listingDetail.unknown'));
const npub = pubkey ? nip19.npubEncode(pubkey) : '';
@ -198,7 +204,7 @@ export default function ListingDetailPage() {
<div className="flex flex-wrap gap-1.5">
{conditionInfo && (
<Badge variant="outline" className="border-gray-600 bg-gray-800/50">
{conditionInfo.emoji} {conditionInfo.label}
{conditionInfo.emoji} {item.condition && GEAR_CONDITION_KEYS[item.condition] ? t(GEAR_CONDITION_KEYS[item.condition]) : conditionInfo.label}
</Badge>
)}
{item.size && (

View file

@ -46,7 +46,7 @@ import { useSatsToUsd } from '@/hooks/useExchangeRate';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { genUserName } from '@/lib/genUserName';
import { useTranslation } from '@/contexts/language';
import { SPOT_CATEGORIES, SPOT_TYPE_LABELS, SHELTER_INFO, SKATE_RELAY } from '@/lib/types';
import { SPOT_CATEGORIES, SHELTER_INFO, SKATE_RELAY } from '@/lib/types';
import type { SkateSpot } from '@/lib/types';
import { spotTypeLabel } from '@/lib/spotTypeLabel';
import ngeohash from 'ngeohash';
@ -475,7 +475,7 @@ function SpotBottomSheet({
key={type}
className="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold bg-white/10 text-white/70"
>
{SPOT_TYPE_LABELS[type] || type}
{spotTypeLabel(t, type)}
</span>
))}
</div>

View file

@ -35,11 +35,12 @@ import { useSpotServices } from '@/hooks/useSpotServices';
import { MarketplaceItemCard } from '@/components/MarketplaceItemCard';
import { genUserName } from '@/lib/genUserName';
import {
SPOT_TYPE_LABELS,
ACCESS_TYPE_INFO,
SHELTER_INFO,
SKATE_SPOT_KIND,
} from '@/lib/types';
import { spotTypeLabel } from '@/lib/spotTypeLabel';
import { ACCESS_TYPE_KEYS, SHELTER_KEYS } from '@/lib/labelKeys';
import NotFound from './NotFound';
import {
MapPin,
@ -495,7 +496,7 @@ export default function SpotDetailPage() {
backgroundColor: `${ACCESS_TYPE_INFO[spot.accessType].color}20`
}}
>
{ACCESS_TYPE_INFO[spot.accessType].emoji} {ACCESS_TYPE_INFO[spot.accessType].label}
{ACCESS_TYPE_INFO[spot.accessType].emoji} {ACCESS_TYPE_KEYS[spot.accessType] ? t(ACCESS_TYPE_KEYS[spot.accessType]) : ACCESS_TYPE_INFO[spot.accessType].label}
</Badge>
)}
{spot.shelter && spot.shelter !== 'outdoor' && SHELTER_INFO[spot.shelter] && (
@ -507,7 +508,7 @@ export default function SpotDetailPage() {
backgroundColor: `${SHELTER_INFO[spot.shelter].color}20`,
}}
>
{SHELTER_INFO[spot.shelter].emoji} {SHELTER_INFO[spot.shelter].label}
{SHELTER_INFO[spot.shelter].emoji} {SHELTER_KEYS[spot.shelter] ? t(SHELTER_KEYS[spot.shelter]) : SHELTER_INFO[spot.shelter].label}
</Badge>
)}
{/* Staff verdict wins if present: a human reviewed it. */}
@ -551,7 +552,7 @@ export default function SpotDetailPage() {
)}
{spot.types.map((type) => (
<Badge key={type} className="bg-[#ccff00]/20 text-[#ccff00] border border-[#ccff00]/50 font-bold uppercase">
{SPOT_TYPE_LABELS[type] || type}
{spotTypeLabel(t, type)}
</Badge>
))}
{/* Community suggested tags */}
@ -562,7 +563,7 @@ export default function SpotDetailPage() {
title={count > 1 ? t('spotUi.suggestedByUsers', { count }) : t('spotUi.suggestedByUser', { count })}
>
<Users className="h-3 w-3 mr-1" />
{SPOT_TYPE_LABELS[tag] || tag}
{spotTypeLabel(t, tag)}
{count > 1 && <span className="ml-1 text-xs opacity-70">({count})</span>}
</Badge>
))}

View file

@ -12,6 +12,7 @@ import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { genUserName } from '@/lib/genUserName';
import { TOUR_SKILL_LEVELS, TOUR_SPOT_TYPES, TOUR_CATEGORIES, SKATE_TOUR_KIND } from '@/lib/types';
import { TOUR_CATEGORY_KEYS, TOUR_SPOT_TYPE_KEYS } from '@/lib/labelKeys';
import { ZapButton } from '@/components/ZapButton';
import { ProBadge } from '@/components/ProBadge';
import { AddToCalendar } from '@/components/AddToCalendar';
@ -49,7 +50,7 @@ export default function TourDetailPage() {
useSeoMeta({
title: tour
? `${tour.title} - ${TOUR_CATEGORIES[tour.category].label} - SkateSpots`
? `${tour.title} - ${TOUR_CATEGORY_KEYS[tour.category] ? t(TOUR_CATEGORY_KEYS[tour.category]) : TOUR_CATEGORIES[tour.category].label} - SkateSpots`
: 'Skate Tour - SkateSpots',
description: tour?.description || 'Discover skate spots and tours on SkateSpots',
});
@ -197,7 +198,7 @@ export default function TourDetailPage() {
? {
uid: `${SKATE_TOUR_KIND}:${tour.pubkey}:${tour.identifier}`,
title: tour.title,
description: tour.description || `${categoryInfo.label} on SkateSpots`,
description: tour.description || `${TOUR_CATEGORY_KEYS[tour.category] ? t(TOUR_CATEGORY_KEYS[tour.category]) : categoryInfo.label} on SkateSpots`,
location: tour.location || tour.meetingPoint || tour.cities?.join(', ') || tour.countries?.join(', '),
start: tour.startDate,
end: tour.endDate,
@ -266,7 +267,7 @@ export default function TourDetailPage() {
borderColor: `${accent}55`,
}}
>
{categoryInfo.emoji} {categoryInfo.label}
{categoryInfo.emoji} {TOUR_CATEGORY_KEYS[tour.category] ? t(TOUR_CATEGORY_KEYS[tour.category]) : categoryInfo.label}
</Badge>
<ProBadge
featuredUntil={tour.featuredUntil}
@ -447,7 +448,7 @@ export default function TourDetailPage() {
variant="outline"
className="border-cyan-500/30 text-cyan-300"
>
{typeInfo?.emoji || '🛹'} {typeInfo?.label || type}
{typeInfo?.emoji || '🛹'} {TOUR_SPOT_TYPE_KEYS[type] ? t(TOUR_SPOT_TYPE_KEYS[type]) : (typeInfo?.label || type)}
</Badge>
);
})}