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:
parent
9357386bbd
commit
e357833557
49 changed files with 1308 additions and 279 deletions
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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) })}` : ''}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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. */
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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
488
src/i18n/batch3/labelsUi.ts
Normal 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;
|
||||||
|
|
@ -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}}',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
330
src/lib/labelKeys.ts
Normal 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',
|
||||||
|
};
|
||||||
|
|
@ -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) });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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';
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -247,7 +247,7 @@ export default function CommunityStatsPage() {
|
||||||
{t('walletUi.stats.dataSource')}
|
{t('walletUi.stats.dataSource')}
|
||||||
<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"
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue