diff --git a/src/components/CollectiveMembers.tsx b/src/components/CollectiveMembers.tsx index df5651a..7becfad 100644 --- a/src/components/CollectiveMembers.tsx +++ b/src/components/CollectiveMembers.tsx @@ -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 ( - {meta.emoji} {meta.label} + {meta.emoji} {CREW_ROLE_KEYS[role] ? t(CREW_ROLE_KEYS[role]) : meta.label} ); } diff --git a/src/components/ConfirmMembershipCard.tsx b/src/components/ConfirmMembershipCard.tsx index b0705a9..976f4f4 100644 --- a/src/components/ConfirmMembershipCard.tsx +++ b/src/components/ConfirmMembershipCard.tsx @@ -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({ })}

- {MEMBER_VISIBILITY_DESCRIPTIONS[value]} + {MEMBER_VISIBILITY_DESC_KEYS[value] + ? t(MEMBER_VISIBILITY_DESC_KEYS[value]) + : MEMBER_VISIBILITY_DESCRIPTIONS[value]}

); diff --git a/src/components/CrewForm.tsx b/src/components/CrewForm.tsx index 0b902ad..2a2fb65 100644 --- a/src/components/CrewForm.tsx +++ b/src/components/CrewForm.tsx @@ -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 = { 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} > - {meta.emoji} {meta.label} + {meta.emoji} {CREW_ROLE_KEYS[role] ? t(CREW_ROLE_KEYS[role]) : meta.label} ); })} @@ -666,7 +667,7 @@ export function CrewForm({ existing }: CrewFormProps) {
- {VIBE_ENTRIES.map(([key, label]) => ( + {VIBE_ENTRIES.map((key) => ( ))}
@@ -760,10 +761,10 @@ export function CrewForm({ existing }: CrewFormProps) { - {CREW_FOCUS_LABELS[key]} + {CREW_FOCUS_KEYS[key] ? t(CREW_FOCUS_KEYS[key]) : CREW_FOCUS_LABELS[key]} - {CREW_FOCUS_DESCRIPTIONS[key]} + {CREW_FOCUS_DESC_KEYS[key] ? t(CREW_FOCUS_DESC_KEYS[key]) : CREW_FOCUS_DESCRIPTIONS[key]} @@ -846,7 +847,7 @@ export function CrewForm({ existing }: CrewFormProps) { )} > {opt.emoji} - {opt.label} + {CREW_LOOKING_FOR_KEYS[opt.slug] ? t(CREW_LOOKING_FOR_KEYS[opt.slug]) : opt.label} ); })} @@ -964,7 +965,7 @@ export function CrewForm({ existing }: CrewFormProps) { )} aria-pressed={active} > - {meta.emoji} {meta.label} + {meta.emoji} {CREW_ROLE_KEYS[role] ? t(CREW_ROLE_KEYS[role]) : meta.label} ); })} diff --git a/src/components/CrewLookingFor.tsx b/src/components/CrewLookingFor.tsx index 1a63ce0..2419112 100644 --- a/src/components/CrewLookingFor.tsx +++ b/src/components/CrewLookingFor.tsx @@ -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" > {meta?.emoji ?? '🛹'} - {meta?.label ?? slug} + {CREW_LOOKING_FOR_KEYS[slug] ? t(CREW_LOOKING_FOR_KEYS[slug]) : (meta?.label ?? slug)} ); })} diff --git a/src/components/DestinationSearch.tsx b/src/components/DestinationSearch.tsx index 4d40260..6e7e6ad 100644 --- a/src/components/DestinationSearch.tsx +++ b/src/components/DestinationSearch.tsx @@ -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(); diff --git a/src/components/EventCard.tsx b/src/components/EventCard.tsx index a1da7ec..cb1f124 100644 --- a/src/components/EventCard.tsx +++ b/src/components/EventCard.tsx @@ -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 = { - 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); diff --git a/src/components/GearSwapItemCard.tsx b/src/components/GearSwapItemCard.tsx index e4210c0..3933e6e 100644 --- a/src/components/GearSwapItemCard.tsx +++ b/src/components/GearSwapItemCard.tsx @@ -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} )} diff --git a/src/components/LabCrewsStrip.tsx b/src/components/LabCrewsStrip.tsx index 5ac1d73..6e5d108 100644 --- a/src/components/LabCrewsStrip.tsx +++ b/src/components/LabCrewsStrip.tsx @@ -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 }) { {t('labUi.crewsStrip.title')} - {CREW_FOCUS_LABELS[focus]} + {CREW_FOCUS_KEYS[focus] ? t(CREW_FOCUS_KEYS[focus]) : CREW_FOCUS_LABELS[focus]} 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} - {priceTypeLabel && priceTypeLabel !== 'Fixed Price' && ( + {priceTypeLabel && item.priceType !== 'fixed' && (
{priceTypeLabel}
)} {priceInSats > 0 && ( @@ -332,7 +340,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
{conditionInfo && ( - {conditionInfo.emoji} {conditionInfo.label} + {conditionInfo.emoji} {item.condition && GEAR_CONDITION_KEYS[item.condition] ? t(GEAR_CONDITION_KEYS[item.condition]) : conditionInfo.label} )} {item.size && ( diff --git a/src/components/ReportSpotStatusDialog.tsx b/src/components/ReportSpotStatusDialog.tsx index ac41479..b9c7419 100644 --- a/src/components/ReportSpotStatusDialog.tsx +++ b/src/components/ReportSpotStatusDialog.tsx @@ -43,7 +43,7 @@ function ReportRow({ report }: { report: SpotStatusReport }) { />
- {meta.label} + {t(meta.labelKey)} {t('spotUi.byAuthor', { name })} {statusTimeAgo(report.createdAt)} @@ -154,8 +154,8 @@ export function ReportSpotStatusDialog({ style={{ backgroundColor: meta.dot, width: 12, height: 12 }} />
-

{meta.label}

-

{meta.description}

+

{t(meta.labelKey)}

+

{t(meta.descriptionKey)}

{isSel && } diff --git a/src/components/RouteDialog.tsx b/src/components/RouteDialog.tsx index a598e2d..24ae71f 100644 --- a/src/components/RouteDialog.tsx +++ b/src/components/RouteDialog.tsx @@ -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 = { ferry: 'routeUi.group.ferry', }; +/** Minimal translation-function shape. */ +type Translate = (key: string, params?: Record) => string; + +/** Maneuver tokens live in `lib/routing`; map them to literal translation keys. */ +const MANEUVER_LABEL_KEYS: Record = { + 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({
{cachedAge !== null && (
- {t('routeUi.offlineSavedRoute', { age: formatAge(cachedAge) })} + {t('routeUi.offlineSavedRoute', { age: formatAge(cachedAge, t) })}
)} {locating && origin && ( @@ -888,8 +926,8 @@ export function RouteDialog({

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

{nav.stepIndex >= 0 && Number.isFinite(nav.stepKm) @@ -1073,7 +1111,7 @@ export function RouteDialog({ : t('routeUi.direct')} {mode === 'foot' - ? · {skateTime(route.distanceM / 1000)} {t('routeUi.onABoard')} + ? · {skateTime(route.distanceM / 1000, t)} : speed && ( · {t('routeUi.speedAvg', { speed })} )} @@ -1127,7 +1165,7 @@ export function RouteDialog({ {i + 1} - {s.instruction} + {stepInstruction(s, t)} {s.distanceM >= 10 && ( {formatDistance(s.distanceM / 1000)} diff --git a/src/components/SasquatchActionCard.tsx b/src/components/SasquatchActionCard.tsx index 89fbe25..64ae716 100644 --- a/src/components/SasquatchActionCard.tsx +++ b/src/components/SasquatchActionCard.tsx @@ -27,7 +27,7 @@ interface Props { export function SasquatchActionCard({ draft, onPublish, onCancel, status, resultLabel }: Props) { const { t } = useTranslation(); const [edited, setEdited] = useState(draft); - const summary = summarizeDraft(edited); + const summary = summarizeDraft(edited, t); if (status === 'done') { return ( diff --git a/src/components/SasquatchProDialog.tsx b/src/components/SasquatchProDialog.tsx index 6de310e..6fe8377 100644 --- a/src/components/SasquatchProDialog.tsx +++ b/src/components/SasquatchProDialog.tsx @@ -137,7 +137,7 @@ export function SasquatchProDialog({

{t('sasquatchUi.scanToPayPre')}{' '} {t('sasquatchUi.scanToPayAmount', { sats: selected.sats.toLocaleString() })}{' '} - {t('sasquatchUi.scanToPayPost', { label: selected.label })} + {t('sasquatchUi.scanToPayPost', { label: t(selected.labelKey) })}

{qr && {t('sasquatchUi.invoiceQrAlt')}}
@@ -221,9 +221,9 @@ function TierPicker({ selected, onSelect }: { selected: ProTier; onSelect: (t: P {t('sasquatchUi.popular')} )} -

{tier.label}

+

{t(tier.labelKey)}

{tier.sats.toLocaleString()}{t('sasquatchUi.sats')}

-

{[usd, tier.badge].filter(Boolean).join(' · ')}

+

{[usd, tier.badgeKey ? t(tier.badgeKey) : tier.badge].filter(Boolean).join(' · ')}

); })} diff --git a/src/components/SpotCard.tsx b/src/components/SpotCard.tsx index 4bd9a6e..d2ee8d5 100644 --- a/src/components/SpotCard.tsx +++ b/src/components/SpotCard.tsx @@ -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]} )} {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} )} {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)} ))}
diff --git a/src/components/SpotMap.tsx b/src/components/SpotMap.tsx index c2d4b03..bda9fc5 100644 --- a/src/components/SpotMap.tsx +++ b/src/components/SpotMap.tsx @@ -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); }); diff --git a/src/components/SpotStatusBadge.tsx b/src/components/SpotStatusBadge.tsx index c38f3cb..770f62c 100644 --- a/src/components/SpotStatusBadge.tsx +++ b/src/components/SpotStatusBadge.tsx @@ -67,7 +67,7 @@ export function SpotStatusBadge({ height: size === 'sm' ? 6 : 8, }} /> - {meta.label} + {t(meta.labelKey)} ); @@ -75,8 +75,8 @@ export function SpotStatusBadge({ {badge} -

{meta.label}

-

{meta.description}

+

{t(meta.labelKey)}

+

{t(meta.descriptionKey)}

{reporterCount} {reporterCount === 1 ? t('spotUi.reportOne') : t('spotUi.reports')} {lastAt ? ` · ${t('spotUi.lastReported', { time: statusTimeAgo(lastAt) })}` : ''} diff --git a/src/components/TourCard.tsx b/src/components/TourCard.tsx index 544c882..15a5eb2 100644 --- a/src/components/TourCard.tsx +++ b/src/components/TourCard.tsx @@ -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} {tour.duration && ( @@ -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}

diff --git a/src/components/crew/CrewHero.tsx b/src/components/crew/CrewHero.tsx index 064ec63..51d4c3d 100644 --- a/src/components/crew/CrewHero.tsx +++ b/src/components/crew/CrewHero.tsx @@ -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 = { @@ -161,7 +162,7 @@ export function CrewHero({ style={{ color: accent.hex, borderColor: `${accent.hex}55`, background: `${accent.hex}14` }} > - {CREW_FOCUS_LABELS[crew.focus]} + {CREW_FOCUS_KEYS[crew.focus] ? t(CREW_FOCUS_KEYS[crew.focus]) : crew.focus}
@@ -217,7 +218,7 @@ export function CrewHero({ style={{ color: accent.hex, borderColor: `${accent.hex}55`, background: `${accent.hex}14` }} > - {CREW_FOCUS_LABELS[crew.focus]} + {CREW_FOCUS_KEYS[crew.focus] ? t(CREW_FOCUS_KEYS[crew.focus]) : crew.focus}
@@ -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} ))}
diff --git a/src/components/crew/CrewLeadership.tsx b/src/components/crew/CrewLeadership.tsx index d5a0781..853e18e 100644 --- a/src/components/crew/CrewLeadership.tsx +++ b/src/components/crew/CrewLeadership.tsx @@ -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" > - {CREW_ROLE_META[r].emoji} {CREW_ROLE_META[r].label} + {CREW_ROLE_META[r].emoji} {CREW_ROLE_KEYS[r] ? t(CREW_ROLE_KEYS[r]) : CREW_ROLE_META[r].label} ))} diff --git a/src/hooks/usePurchasePro.ts b/src/hooks/usePurchasePro.ts index fd54152..00fff31 100644 --- a/src/hooks/usePurchasePro.ts +++ b/src/hooks/usePurchasePro.ts @@ -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({ 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. */ diff --git a/src/hooks/useSasquatchActions.ts b/src/hooks/useSasquatchActions.ts index 3e4d125..669a605 100644 --- a/src/hooks/useSasquatchActions.ts +++ b/src/hooks/useSasquatchActions.ts @@ -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; + // ─── 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 }; diff --git a/src/i18n/batch3/index.ts b/src/i18n/batch3/index.ts index 99fa90b..6e62fb6 100644 --- a/src/i18n/batch3/index.ts +++ b/src/i18n/batch3/index.ts @@ -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, }; diff --git a/src/i18n/batch3/labelsUi.ts b/src/i18n/batch3/labelsUi.ts new file mode 100644 index 0000000..30d7070 --- /dev/null +++ b/src/i18n/batch3/labelsUi.ts @@ -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; diff --git a/src/i18n/batch3/routeUi.ts b/src/i18n/batch3/routeUi.ts index dade0cd..494f3ae 100644 --- a/src/i18n/batch3/routeUi.ts +++ b/src/i18n/batch3/routeUi.ts @@ -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}}', }, }; diff --git a/src/i18n/batch3/sasquatchUi.ts b/src/i18n/batch3/sasquatchUi.ts index 5d42083..64ad7c7 100644 --- a/src/i18n/batch3/sasquatchUi.ts +++ b/src/i18n/batch3/sasquatchUi.ts @@ -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.', }, }; diff --git a/src/lib/labelKeys.ts b/src/lib/labelKeys.ts new file mode 100644 index 0000000..cb793a3 --- /dev/null +++ b/src/lib/labelKeys.ts @@ -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 = { + 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 = { + 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 = { + 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 = { + 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 = { + public: 'labelsUi.access.public.label', + private: 'labelsUi.access.private.label', + locals: 'labelsUi.access.locals.label', +}; + +export const SHELTER_KEYS: Record = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + selling: 'labelsUi.listingType.selling.label', + wtb: 'labelsUi.listingType.wtb.label', +}; + +export const LISTING_TYPE_DESC_KEYS: Record = { + selling: 'labelsUi.listingType.selling.description', + wtb: 'labelsUi.listingType.wtb.description', +}; + +export const TOUR_SPOT_TYPE_KEYS: Record = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + public: 'labelsUi.memberVisibility.public.label', + crew: 'labelsUi.memberVisibility.crew.label', +}; + +export const MEMBER_VISIBILITY_DESC_KEYS: Record = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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', +}; diff --git a/src/lib/routeCache.ts b/src/lib/routeCache.ts index 94b5365..f4e596d 100644 --- a/src/lib/routeCache.ts +++ b/src/lib/routeCache.ts @@ -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) }); } diff --git a/src/lib/routing.ts b/src/lib/routing.ts index 900c901..2a58cea 100644 --- a/src/lib/routing.ts +++ b/src/lib/routing.ts @@ -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; + 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; } diff --git a/src/lib/sasquatchPro.ts b/src/lib/sasquatchPro.ts index 2fdbba4..b7a17d6 100644 --- a/src/lib/sasquatchPro.ts +++ b/src/lib/sasquatchPro.ts @@ -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 { diff --git a/src/lib/skateTags.ts b/src/lib/skateTags.ts index 02c50d2..d7481af 100644 --- a/src/lib/skateTags.ts +++ b/src/lib/skateTags.ts @@ -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', diff --git a/src/lib/spotStatus.ts b/src/lib/spotStatus.ts index 3243869..b105760 100644 --- a/src/lib/spotStatus.ts +++ b/src/lib/spotStatus.ts @@ -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 = { 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 }); } diff --git a/src/lib/spotTypeLabel.ts b/src/lib/spotTypeLabel.ts index 1305ce6..5b2c427 100644 --- a/src/lib/spotTypeLabel.ts +++ b/src/lib/spotTypeLabel.ts @@ -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; /** - * 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; } diff --git a/src/lib/types.ts b/src/lib/types.ts index acc3ea5..cae10ee 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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); diff --git a/src/pages/CommunityStatsPage.tsx b/src/pages/CommunityStatsPage.tsx index 89e9ae6..8d67df5 100644 --- a/src/pages/CommunityStatsPage.tsx +++ b/src/pages/CommunityStatsPage.tsx @@ -247,7 +247,7 @@ export default function CommunityStatsPage() { {t('walletUi.stats.dataSource')}  {SKATE_RELAY.replace('wss://', '')} {dataUpdatedAt > 0 && ( - <>·{t('walletUi.stats.updated', { age: formatAge(Date.now() - dataUpdatedAt) })} + <>·{t('walletUi.stats.updated', { age: formatAge(Date.now() - dataUpdatedAt, t) })} )} ))} @@ -687,9 +688,9 @@ export default function CreateListingPage() { onClick={() => setCondition(key)} > - {emoji} {label} + {emoji} {GEAR_CONDITION_KEYS[key] ? t(GEAR_CONDITION_KEYS[key]) : label} - {description} + {GEAR_CONDITION_DESC_KEYS[key] ? t(GEAR_CONDITION_DESC_KEYS[key]) : description} ))} @@ -835,8 +836,8 @@ export default function CreateListingPage() { }`} onClick={() => setPriceType(key)} > - {label} - {description} + {PRICE_TYPE_KEYS[key] ? t(PRICE_TYPE_KEYS[key]) : label} + {PRICE_TYPE_DESC_KEYS[key] ? t(PRICE_TYPE_DESC_KEYS[key]) : description} ))} diff --git a/src/pages/CreateSpotPage.tsx b/src/pages/CreateSpotPage.tsx index 06071cc..267aef4 100644 --- a/src/pages/CreateSpotPage.tsx +++ b/src/pages/CreateSpotPage.tsx @@ -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)} > -
{category.label}
-
{category.description}
+
{SPOT_TAG_CATEGORY_KEYS[category.value] ? t(SPOT_TAG_CATEGORY_KEYS[category.value]) : category.label}
+
{SPOT_TAG_CATEGORY_DESC_KEYS[category.value] ? t(SPOT_TAG_CATEGORY_DESC_KEYS[category.value]) : category.description}
{selectedCategory === category.value && (
@@ -683,8 +684,8 @@ export default function CreateSpotPage() {
{option.emoji}
-
{option.label}
-
{option.description}
+
{SHELTER_KEYS[option.value] ? t(SHELTER_KEYS[option.value]) : option.label}
+
{SHELTER_DESC_KEYS[option.value] ? t(SHELTER_DESC_KEYS[option.value]) : option.description}
{shelter === option.value && ( diff --git a/src/pages/CreateTourPage.tsx b/src/pages/CreateTourPage.tsx index 20f2987..692eef7 100644 --- a/src/pages/CreateTourPage.tsx +++ b/src/pages/CreateTourPage.tsx @@ -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}

- {TOUR_CATEGORIES[tourCategory].description} + {TOUR_CATEGORY_DESC_KEYS[tourCategory] ? t(TOUR_CATEGORY_DESC_KEYS[tourCategory]) : TOUR_CATEGORIES[tourCategory].description}

@@ -1425,7 +1426,7 @@ export default function CreateTourPage({ existing }: { existing?: SkateTour } = style={spotTypes.includes(key) ? { backgroundColor: TOUR_CATEGORIES[tourCategory].color } : undefined} > {type.emoji} - {type.label} + {TOUR_SPOT_TYPE_KEYS[key] ? t(TOUR_SPOT_TYPE_KEYS[key]) : type.label} ))} diff --git a/src/pages/CrewDetailPage.tsx b/src/pages/CrewDetailPage.tsx index 7b784ca..307448c 100644 --- a/src/pages/CrewDetailPage.tsx +++ b/src/pages/CrewDetailPage.tsx @@ -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, diff --git a/src/pages/CrewsPage.tsx b/src/pages/CrewsPage.tsx index 6680a7a..52f0b59 100644 --- a/src/pages/CrewsPage.tsx +++ b/src/pages/CrewsPage.tsx @@ -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 && ( - {CREW_FOCUS_LABELS[crew.focus]} + {CREW_FOCUS_KEYS[crew.focus] ? t(CREW_FOCUS_KEYS[crew.focus]) : crew.focus} )} {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} ))} @@ -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 ( = { - 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); diff --git a/src/pages/EditSpotPage.tsx b/src/pages/EditSpotPage.tsx index 1fcf137..1af3a58 100644 --- a/src/pages/EditSpotPage.tsx +++ b/src/pages/EditSpotPage.tsx @@ -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)} > -
{category.label}
-
{category.description}
+
{SPOT_TAG_CATEGORY_KEYS[category.value] ? t(SPOT_TAG_CATEGORY_KEYS[category.value]) : category.label}
+
{SPOT_TAG_CATEGORY_DESC_KEYS[category.value] ? t(SPOT_TAG_CATEGORY_DESC_KEYS[category.value]) : category.description}
{selectedCategory === category.value && (
@@ -613,8 +614,8 @@ export default function EditSpotPage() {
{option.emoji}
-
{option.label}
-
{option.description}
+
{SHELTER_KEYS[option.value] ? t(SHELTER_KEYS[option.value]) : option.label}
+
{SHELTER_DESC_KEYS[option.value] ? t(SHELTER_DESC_KEYS[option.value]) : option.description}
{shelter === option.value && ( diff --git a/src/pages/EventDetailPage.tsx b/src/pages/EventDetailPage.tsx index 512cc04..9b06718 100644 --- a/src/pages/EventDetailPage.tsx +++ b/src/pages/EventDetailPage.tsx @@ -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 = { - 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); diff --git a/src/pages/EventsPage.tsx b/src/pages/EventsPage.tsx index 9ff0736..88324b9 100644 --- a/src/pages/EventsPage.tsx +++ b/src/pages/EventsPage.tsx @@ -101,16 +101,7 @@ const DATE_WINDOW_LABELS_SHORT: Record = { month: 'placesUi.dateWindow.monthShort', }; -/** Map event type keys to translation keys (types live in lib/types). */ -const EVENT_TYPE_KEYS: Record = { - 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} ); })} diff --git a/src/pages/GearSwapPage.tsx b/src/pages/GearSwapPage.tsx index 04877f5..68fe056 100644 --- a/src/pages/GearSwapPage.tsx +++ b/src/pages/GearSwapPage.tsx @@ -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], })), ]; diff --git a/src/pages/ListingDetailPage.tsx b/src/pages/ListingDetailPage.tsx index 4d6d160..ab594f4 100644 --- a/src/pages/ListingDetailPage.tsx +++ b/src/pages/ListingDetailPage.tsx @@ -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() {
{conditionInfo && ( - {conditionInfo.emoji} {conditionInfo.label} + {conditionInfo.emoji} {item.condition && GEAR_CONDITION_KEYS[item.condition] ? t(GEAR_CONDITION_KEYS[item.condition]) : conditionInfo.label} )} {item.size && ( diff --git a/src/pages/MapPage.tsx b/src/pages/MapPage.tsx index c29ec14..f1dca5d 100644 --- a/src/pages/MapPage.tsx +++ b/src/pages/MapPage.tsx @@ -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)} ))}
diff --git a/src/pages/SpotDetailPage.tsx b/src/pages/SpotDetailPage.tsx index 67e0114..c8237f0 100644 --- a/src/pages/SpotDetailPage.tsx +++ b/src/pages/SpotDetailPage.tsx @@ -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} )} {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} )} {/* Staff verdict wins if present: a human reviewed it. */} @@ -551,7 +552,7 @@ export default function SpotDetailPage() { )} {spot.types.map((type) => ( - {SPOT_TYPE_LABELS[type] || type} + {spotTypeLabel(t, type)} ))} {/* Community suggested tags */} @@ -562,7 +563,7 @@ export default function SpotDetailPage() { title={count > 1 ? t('spotUi.suggestedByUsers', { count }) : t('spotUi.suggestedByUser', { count })} > - {SPOT_TYPE_LABELS[tag] || tag} + {spotTypeLabel(t, tag)} {count > 1 && ({count})} ))} diff --git a/src/pages/TourDetailPage.tsx b/src/pages/TourDetailPage.tsx index f582ae5..3eacfbd 100644 --- a/src/pages/TourDetailPage.tsx +++ b/src/pages/TourDetailPage.tsx @@ -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} - {typeInfo?.emoji || '🛹'} {typeInfo?.label || type} + {typeInfo?.emoji || '🛹'} {TOUR_SPOT_TYPE_KEYS[type] ? t(TOUR_SPOT_TYPE_KEYS[type]) : (typeInfo?.label || type)} ); })}