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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -17,6 +17,7 @@ import { getLastLocation } from '@/lib/lastLocation';
import { itemDistanceKm } from '@/lib/sortOptions'; import { itemDistanceKm } from '@/lib/sortOptions';
import { getSkateboardRating, getTrustLevel, SELLER_TRUST_BADGES } from '@/lib/skateboardRating'; import { getSkateboardRating, getTrustLevel, SELLER_TRUST_BADGES } from '@/lib/skateboardRating';
import { GEAR_CONDITIONS, GEAR_CATEGORIES, formatPrice, type MarketplaceItem } from '@/lib/types'; 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 { 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 { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from '@/components/ui/dropdown-menu';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'; 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 // 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 }); 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; const conditionInfo = item.condition ? GEAR_CONDITIONS[item.condition] : null;
// Get category label // Get category label
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]);
? GEAR_CATEGORIES[item.category.find((c) => GEAR_CATEGORIES[c as keyof typeof GEAR_CATEGORIES]) 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; : null;
// Get price type label // Get price type label
@ -198,7 +202,7 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
variant="outline" variant="outline"
className="absolute bottom-2 left-2 bg-black/80 border-gray-600 text-xs backdrop-blur-sm" className="absolute bottom-2 left-2 bg-black/80 border-gray-600 text-xs backdrop-blur-sm"
> >
{conditionInfo.emoji} {conditionInfo.label} {conditionInfo.emoji} {item.condition && GEAR_CONDITION_KEYS[item.condition] ? t(GEAR_CONDITION_KEYS[item.condition]) : conditionInfo.label}
</Badge> </Badge>
)} )}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -137,7 +137,7 @@ export function SasquatchProDialog({
<p className="text-sm text-gray-300"> <p className="text-sm text-gray-300">
{t('sasquatchUi.scanToPayPre')}{' '} {t('sasquatchUi.scanToPayPre')}{' '}
<span className="font-black text-white">{t('sasquatchUi.scanToPayAmount', { sats: selected.sats.toLocaleString() })}</span>{' '} <span className="font-black text-white">{t('sasquatchUi.scanToPayAmount', { sats: selected.sats.toLocaleString() })}</span>{' '}
{t('sasquatchUi.scanToPayPost', { label: selected.label })} {t('sasquatchUi.scanToPayPost', { label: t(selected.labelKey) })}
</p> </p>
{qr && <img src={qr} alt={t('sasquatchUi.invoiceQrAlt')} className="mx-auto rounded-xl bg-white p-2" width={240} height={240} />} {qr && <img src={qr} alt={t('sasquatchUi.invoiceQrAlt')} className="mx-auto rounded-xl bg-white p-2" width={240} height={240} />}
<div className="flex gap-2"> <div className="flex gap-2">
@ -221,9 +221,9 @@ function TierPicker({ selected, onSelect }: { selected: ProTier; onSelect: (t: P
{t('sasquatchUi.popular')} {t('sasquatchUi.popular')}
</span> </span>
)} )}
<p className="text-sm font-black text-white">{tier.label}</p> <p className="text-sm font-black text-white">{t(tier.labelKey)}</p>
<p className="text-lg font-black text-red-400">{tier.sats.toLocaleString()}<span className="ml-0.5 text-xs text-gray-500">{t('sasquatchUi.sats')}</span></p> <p className="text-lg font-black text-red-400">{tier.sats.toLocaleString()}<span className="ml-0.5 text-xs text-gray-500">{t('sasquatchUi.sats')}</span></p>
<p className="text-[10px] text-gray-500">{[usd, tier.badge].filter(Boolean).join(' · ')}</p> <p className="text-[10px] text-gray-500">{[usd, tier.badgeKey ? t(tier.badgeKey) : tier.badge].filter(Boolean).join(' · ')}</p>
</button> </button>
); );
})} })}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

@ -109,6 +109,35 @@ const sasquatchUi = {
theNode: 'the node', theNode: 'the node',
lowBalance: 'Running low — top up at {{provider}} or AI will fall back to the keyless provider.', 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.', 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: { es: {
// ── Connection status ── // ── Connection status ──
@ -211,6 +240,35 @@ const sasquatchUi = {
theNode: 'el nodo', theNode: 'el nodo',
lowBalance: 'Saldo bajo — recarga en {{provider}} o la IA recurrirá al proveedor sin clave.', 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.', aiDesc: 'IA de pago por uso, financiada en sats vía Routstr. Se recarga cuando baja.',
// ── Pro tier labels & badges (PRO_TIERS) ──
tier7d: '7 días',
tier1m: '1 mes',
tier3m: '3 meses',
tier1y: '1 año',
badgeTry: 'Pruébalo',
badgeOff20: '20 % dto.',
badgeBest: 'Mejor precio · 40 % dto.',
// ── Draft action summary ──
draftPostStatusTitle: 'Publicar un estado de skate',
draftAutoExpires: 'Se borra automáticamente en {{count}} min',
draftPostStatusCta: 'Publicar estado',
draftCategory: 'Categoría: {{value}}',
draftSize: 'Talla: {{value}}',
draftCondition: 'Estado: {{value}}',
draftMaxPrice: 'Precio máx.: {{price}} {{currency}}',
draftPriceOpen: 'Precio: abierto',
draftLookingForTitle: 'Buscando "{{title}}"',
draftWantedCta: 'Publicar anuncio',
draftPrice: 'Precio: {{price}} {{currency}}',
draftPriceOffer: 'Precio: aceptar ofertas',
draftListTitle: 'Vender "{{title}}"',
draftPublishCta: 'Publicar anuncio',
statusPosted: 'Estado publicado 🛹',
wantedPublished: 'Anuncio de búsqueda publicado 🔎',
listingPublished: 'Anuncio publicado 💸',
mustBeLoggedIn: 'Tienes que iniciar sesión.',
}, },
}; };

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

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

View file

@ -1,4 +1,4 @@
import type { RouteResult } from '@/lib/routing'; import type { RouteResult, TranslateFn } from '@/lib/routing';
/** /**
* Tiny localStorage cache of the last few routes. * 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. */ /** "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); const mins = Math.round(ageMs / 60000);
if (mins < 1) return 'just now'; if (mins < 1) return t('routeUi.ageJustNow');
if (mins < 60) return `${mins} min ago`; if (mins < 60) return t('routeUi.ageMinAgo', { count: mins });
const hours = Math.round(mins / 60); const hours = Math.round(mins / 60);
if (hours < 24) return `${hours} h ago`; if (hours < 24) return t('routeUi.ageHourAgo', { count: hours });
return `${Math.round(hours / 24)} d ago`; return t('routeUi.ageDayAgo', { count: Math.round(hours / 24) });
} }

View file

@ -27,6 +27,12 @@ import { dateLocaleCode } from '@/lib/dateFnsI18n';
/** Travel modes offered in the UI. */ /** Travel modes offered in the UI. */
export type TravelMode = 'foot' | 'bike' | 'moto' | 'car' | 'transit'; export type TravelMode = 'foot' | 'bike' | 'moto' | 'car' | 'transit';
/**
* Minimal shape of the translation function, declared locally so pure lib
* helpers can accept `t` without importing the React context.
*/
export type TranslateFn = (key: string, params?: Record<string, string | number>) => string;
export interface TravelModeMeta { export interface TravelModeMeta {
mode: TravelMode; mode: TravelMode;
/** OSRM routing profile actually used (undefined for transit). */ /** 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). */ /** One turn-by-turn instruction (used for orientation, not live navigation). */
export interface RouteStep { 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; 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. */ /** Length of this step in metres. */
distanceM: number; distanceM: number;
/** Where the manoeuvre happens. */ /** Where the manoeuvre happens. */
@ -193,30 +206,46 @@ export interface RouteStep {
lng: number; lng: number;
} }
/** Turn OSRM's terse maneuver codes into something readable. */ /** The app-owned maneuver token + English fallback, from OSRM's terse codes. */
function maneuverLabel(type: string, modifier: string): string { function maneuverDescriptor(type: string, modifier: string): { token: string | null; en: string } {
const t = type.toLowerCase(); const t = type.toLowerCase();
const m = modifier.toLowerCase(); const m = modifier.toLowerCase();
if (t === 'depart') return 'Head out'; const pick = (token: string, en: string) => ({ token, en });
if (t === 'arrive') return 'Arrive at the spot'; if (t === 'depart') return pick('headOut', 'Head out');
if (t === 'new name') return 'Continue'; if (t === 'arrive') return pick('arrive', 'Arrive at the spot');
if (t === 'continue') return m.includes('left') ? 'Bear left' : m.includes('right') ? 'Bear right' : 'Continue'; if (t === 'new name') return pick('continueStraight', 'Continue');
if (t === 'turn') { if (t === 'continue') {
if (m.includes('sharp left')) return 'Sharp left'; return m.includes('left')
if (m.includes('sharp right')) return 'Sharp right'; ? pick('bearLeft', 'Bear left')
if (m.includes('slight left')) return 'Bear left'; : m.includes('right')
if (m.includes('slight right')) return 'Bear right'; ? pick('bearRight', 'Bear right')
if (m.includes('left')) return 'Turn left'; : pick('continueStraight', 'Continue');
if (m.includes('right')) return 'Turn right';
return 'Turn';
} }
if (t === 'fork') return m.includes('left') ? 'Keep left' : m.includes('right') ? 'Keep right' : 'Take the fork'; if (t === 'turn') {
if (t === 'merge') return 'Merge'; if (m.includes('sharp left')) return pick('sharpLeft', 'Sharp left');
if (t === 'roundabout' || t === 'rotary') return 'Take the roundabout'; if (m.includes('sharp right')) return pick('sharpRight', 'Sharp right');
if (t === 'end of road') return m.includes('left') ? 'Turn left' : 'Turn right'; if (m.includes('slight left')) return pick('bearLeft', 'Bear left');
if (t === 'on ramp') return 'Take the ramp'; if (m.includes('slight right')) return pick('bearRight', 'Bear right');
if (t === 'off ramp') return 'Take the exit'; if (m.includes('left')) return pick('turnLeft', 'Turn left');
return t.replace(/\b\w/g, (c) => c.toUpperCase()); 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[] { function parseSteps(data: OsrmResponse): RouteStep[] {
@ -225,11 +254,14 @@ function parseSteps(data: OsrmResponse): RouteStep[] {
const steps: RouteStep[] = []; const steps: RouteStep[] = [];
for (const leg of route.legs) { for (const leg of route.legs) {
for (const s of leg.steps ?? []) { 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 road = (s.name ?? '').trim();
const loc = s.maneuver?.location; const loc = s.maneuver?.location;
const takesRoad = token !== null && token !== 'arrive' && !!road;
steps.push({ 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, distanceM: s.distance ?? 0,
lat: loc?.[1] ?? 0, lat: loc?.[1] ?? 0,
lng: loc?.[0] ?? 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 * 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 * 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. * 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 ''; if (!isFinite(km) || km < 0) return '';
const CRUISE_KMH = 10; // relaxed cruising pace const CRUISE_KMH = 10; // relaxed cruising pace
const mins = Math.round((km / CRUISE_KMH) * 60); const mins = Math.round((km / CRUISE_KMH) * 60);
if (mins < 1) return 'right here'; if (mins < 1) return t('routeUi.skateRightHere');
if (mins < 60) return `~${mins} min cruise`; if (mins < 60) return t('routeUi.skateMin', { count: mins });
const h = (mins / 60).toFixed(1); 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. * Compact one-line readout for cards/popups given a distance in km.
* e.g. "1.2 km · ~7 min cruise" * 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 d = formatDistance(km);
const t = skateTime(km); const time = skateTime(km, t);
return t ? `${d} · ${t}` : d; return time ? `${d} · ${time}` : d;
} }

View file

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

View file

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

View file

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

View file

@ -1,31 +1,16 @@
import { SPOT_TYPE_LABELS } from '@/lib/types'; import { SPOT_TYPE_LABELS } from '@/lib/types';
import { SPOT_TYPE_KEYS } from '@/lib/labelKeys';
type Translate = (key: string, params?: Record<string, string | number>) => string; type Translate = (key: string, params?: Record<string, string | number>) => string;
/** /**
* Spot-type keys that have a translated label under `chromeUi.spotType.*`. * Localised label for a spot type/category key. Every key with a
* Anything else (feature tags like `wallrides`, legacy values) falls back to * `labelsUi.spotType.*` translation is translated; anything else (legacy values)
* the English label so map popups never show a raw tag string. * 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.
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).
*/ */
export function spotTypeLabel(t: Translate, key: string): 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; return SPOT_TYPE_LABELS[key] ?? key;
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -21,6 +21,7 @@ import { Nip05Required } from '@/components/Nip05Required';
import { FeaturedUpgrade } from '@/components/FeaturedEventUpgrade'; import { FeaturedUpgrade } from '@/components/FeaturedEventUpgrade';
import { usePlatformFee } from '@/hooks/usePlatformFee'; 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 { 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 { 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 { Link } from 'react-router-dom';
import { cn } from '@/lib/utils'; 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]" className="text-[11px] font-black uppercase tracking-[0.25em]"
style={{ color: TOUR_CATEGORIES[tourCategory].color }} style={{ color: TOUR_CATEGORIES[tourCategory].color }}
> >
{isEditing ? t('common.edit') : t('common.new')} · {TOUR_CATEGORIES[tourCategory].label} {isEditing ? t('common.edit') : t('common.new')} · {TOUR_CATEGORY_KEYS[tourCategory] ? t(TOUR_CATEGORY_KEYS[tourCategory]) : TOUR_CATEGORIES[tourCategory].label}
</p> </p>
<h1 <h1
className="text-3xl sm:text-4xl font-black uppercase leading-none" className="text-3xl sm:text-4xl font-black uppercase leading-none"
@ -960,7 +961,7 @@ export default function CreateTourPage({ existing }: { existing?: SkateTour } =
{isEditing ? t('placesUi.createTour.editTour') : t('placesUi.createTour.dropTour')} {isEditing ? t('placesUi.createTour.editTour') : t('placesUi.createTour.dropTour')}
</h1> </h1>
<p className="text-gray-400 mt-1 text-sm"> <p className="text-gray-400 mt-1 text-sm">
{TOUR_CATEGORIES[tourCategory].description} {TOUR_CATEGORY_DESC_KEYS[tourCategory] ? t(TOUR_CATEGORY_DESC_KEYS[tourCategory]) : TOUR_CATEGORIES[tourCategory].description}
</p> </p>
</div> </div>
</div> </div>
@ -1425,7 +1426,7 @@ export default function CreateTourPage({ existing }: { existing?: SkateTour } =
style={spotTypes.includes(key) ? { backgroundColor: TOUR_CATEGORIES[tourCategory].color } : undefined} style={spotTypes.includes(key) ? { backgroundColor: TOUR_CATEGORIES[tourCategory].color } : undefined}
> >
<span>{type.emoji}</span> <span>{type.emoji}</span>
<span>{type.label}</span> <span>{TOUR_SPOT_TYPE_KEYS[key] ? t(TOUR_SPOT_TYPE_KEYS[key]) : type.label}</span>
</Button> </Button>
))} ))}
</div> </div>

View file

@ -46,6 +46,7 @@ import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { genUserName } from '@/lib/genUserName'; import { genUserName } from '@/lib/genUserName';
import { SKATE_CREW_KIND, SHOW_CREW_TREASURY, CREW_LOOKING_FOR_LABELS, type CrewRole } from '@/lib/types'; 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 { sanitizeUrl, urlDomain } from '@/lib/sanitizeUrl';
import NotFound from './NotFound'; import NotFound from './NotFound';
import { Users, MapPin, ArrowLeft, Calendar, Globe, Instagram, Edit, MessageSquare, Zap, ShieldCheck, Music } from 'lucide-react'; 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, vibes: crew.vibes,
founderName, founderName,
memberCount: 1 + crew.members.length, 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, bandName: crew.bandName,
homeSpotCount: crew.spotRefs.length, homeSpotCount: crew.spotRefs.length,
website: crew.website, website: crew.website,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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