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 { useCollectiveMembers, type CollectiveMember } from '@/hooks/useCollectiveMembers';
|
||||
import { CREW_ROLE_META, type CrewRole } from '@/lib/types';
|
||||
import { CREW_ROLE_KEYS } from '@/lib/labelKeys';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface CollectiveMembersProps {
|
||||
|
|
@ -33,10 +34,11 @@ interface CollectiveMembersProps {
|
|||
|
||||
/** Skate-role flex chip (🎥 Filmer, 📷 Photographer, …). */
|
||||
function RoleChip({ role }: { role: CrewRole }) {
|
||||
const { t } = useTranslation();
|
||||
const meta = CREW_ROLE_META[role];
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-white/10 px-2 py-0.5 text-xs font-bold text-gray-200">
|
||||
<span className="leading-none">{meta.emoji}</span> {meta.label}
|
||||
<span className="leading-none">{meta.emoji}</span> {CREW_ROLE_KEYS[role] ? t(CREW_ROLE_KEYS[role]) : meta.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
MEMBER_VISIBILITY_DESCRIPTIONS,
|
||||
type MemberVisibility,
|
||||
} from '@/lib/types';
|
||||
import { MEMBER_VISIBILITY_DESC_KEYS } from '@/lib/labelKeys';
|
||||
|
||||
interface ConfirmMembershipCardProps {
|
||||
kind: number;
|
||||
|
|
@ -61,7 +62,9 @@ export function VisibilityPicker({
|
|||
})}
|
||||
</div>
|
||||
<p className="text-xs leading-relaxed text-gray-500">
|
||||
{MEMBER_VISIBILITY_DESCRIPTIONS[value]}
|
||||
{MEMBER_VISIBILITY_DESC_KEYS[value]
|
||||
? t(MEMBER_VISIBILITY_DESC_KEYS[value])
|
||||
: MEMBER_VISIBILITY_DESCRIPTIONS[value]}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { isCrewVideoUrl, parseCrewVideo } from '@/lib/videoEmbed';
|
|||
import { CrewSponsorPicker } from '@/components/crew/CrewSponsorPicker';
|
||||
import { NofferField } from '@/components/NofferField';
|
||||
import { SKATE_CREW_KIND, SKATE_SPOT_KIND, SKATE_SHOP_KIND, parseAddrCoord, ZAP_GOAL_KIND, CREW_VIBE_LABELS, CREW_FOCUS_LABELS, CREW_FOCUS_DESCRIPTIONS, CREW_FOCUS_TAG, CREW_LOOKING_FOR_OPTIONS, CREW_ROLE_META, CREW_ROLE_ORDER, SHOW_CREW_TREASURY, isValidNoffer, type SkateCrew, type CrewFocus, type CrewRole } from '@/lib/types';
|
||||
import { CREW_VIBE_KEYS, CREW_FOCUS_KEYS, CREW_FOCUS_DESC_KEYS, CREW_LOOKING_FOR_KEYS, CREW_ROLE_KEYS } from '@/lib/labelKeys';
|
||||
import { useCrewGoal } from '@/hooks/useCrewGoal';
|
||||
import { useCoordinator } from '@/hooks/useCoordinator';
|
||||
import { coordinatorAddUrl, crewCoordinatorInputValue, parseCrewCoordinatorInput, shortNpub } from '@/lib/coordinator';
|
||||
|
|
@ -40,7 +41,7 @@ const FOCUS_ICONS: Record<CrewFocus, typeof Users> = {
|
|||
wellness: Dumbbell,
|
||||
};
|
||||
|
||||
const VIBE_ENTRIES = Object.entries(CREW_VIBE_LABELS);
|
||||
const VIBE_ENTRIES = Object.keys(CREW_VIBE_LABELS);
|
||||
const TEAL = 'bg-teal-500 hover:bg-teal-600 text-black';
|
||||
/** Number of steps in the create wizard (0-indexed last step). Keep in sync with `steps`. */
|
||||
const WIZARD_LAST_STEP = 3;
|
||||
|
|
@ -121,7 +122,7 @@ function MemberRow({
|
|||
)}
|
||||
aria-pressed={active}
|
||||
>
|
||||
<span className="leading-none">{meta.emoji}</span> {meta.label}
|
||||
<span className="leading-none">{meta.emoji}</span> {CREW_ROLE_KEYS[role] ? t(CREW_ROLE_KEYS[role]) : meta.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
|
@ -666,7 +667,7 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
<div className="space-y-3">
|
||||
<Label>{t('socialUi.form.vibeLabel')}</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{VIBE_ENTRIES.map(([key, label]) => (
|
||||
{VIBE_ENTRIES.map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
|
|
@ -678,7 +679,7 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
: 'bg-gray-800 hover:bg-gray-700 border-gray-700 text-gray-300'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{CREW_VIBE_KEYS[key] ? t(CREW_VIBE_KEYS[key]) : key}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -760,10 +761,10 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
<Icon className={cn('mt-0.5 h-4 w-4 flex-shrink-0', selected ? 'text-teal-300' : 'text-gray-400')} />
|
||||
<span className="min-w-0">
|
||||
<span className={cn('block text-sm font-bold leading-tight', selected ? 'text-teal-200' : 'text-gray-200')}>
|
||||
{CREW_FOCUS_LABELS[key]}
|
||||
{CREW_FOCUS_KEYS[key] ? t(CREW_FOCUS_KEYS[key]) : CREW_FOCUS_LABELS[key]}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-[11px] leading-snug text-gray-500">
|
||||
{CREW_FOCUS_DESCRIPTIONS[key]}
|
||||
{CREW_FOCUS_DESC_KEYS[key] ? t(CREW_FOCUS_DESC_KEYS[key]) : CREW_FOCUS_DESCRIPTIONS[key]}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
|
@ -846,7 +847,7 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
)}
|
||||
>
|
||||
<span>{opt.emoji}</span>
|
||||
{opt.label}
|
||||
{CREW_LOOKING_FOR_KEYS[opt.slug] ? t(CREW_LOOKING_FOR_KEYS[opt.slug]) : opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
|
@ -964,7 +965,7 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
)}
|
||||
aria-pressed={active}
|
||||
>
|
||||
<span className="leading-none">{meta.emoji}</span> {meta.label}
|
||||
<span className="leading-none">{meta.emoji}</span> {CREW_ROLE_KEYS[role] ? t(CREW_ROLE_KEYS[role]) : meta.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { CREW_LOOKING_FOR_LABELS } from '@/lib/types';
|
||||
import { CREW_LOOKING_FOR_KEYS } from '@/lib/labelKeys';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { Megaphone } from 'lucide-react';
|
||||
|
||||
|
|
@ -30,7 +31,7 @@ export function CrewLookingForCard({ lookingFor }: { lookingFor: string[] }) {
|
|||
className="inline-flex items-center gap-1.5 rounded-xl border border-fuchsia-500/40 bg-fuchsia-500/10 px-3 py-1.5 text-sm font-bold text-fuchsia-200"
|
||||
>
|
||||
<span>{meta?.emoji ?? '🛹'}</span>
|
||||
{meta?.label ?? slug}
|
||||
{CREW_LOOKING_FOR_KEYS[slug] ? t(CREW_LOOKING_FOR_KEYS[slug]) : (meta?.label ?? slug)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { Input } from '@/components/ui/input';
|
|||
import { useSkateSpots } from '@/hooks/useSkateSpots';
|
||||
import { useShops } from '@/hooks/useShops';
|
||||
import { useBtcMapPlaces } from '@/hooks/useBtcMapPlaces';
|
||||
import { SPOT_TYPE_LABELS } from '@/lib/types';
|
||||
import { spotTypeLabel } from '@/lib/spotTypeLabel';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
|
|
@ -70,7 +70,7 @@ export function DestinationSearch({
|
|||
key: `spot:${spot.pubkey}:${spot.identifier}`,
|
||||
kind: 'spot',
|
||||
label: spot.title,
|
||||
sub: [spot.location, spot.types.map((t) => SPOT_TYPE_LABELS[t] ?? t).join(', ')]
|
||||
sub: [spot.location, spot.types.map((type) => spotTypeLabel(t, type)).join(', ')]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
lat: latitude,
|
||||
|
|
@ -103,7 +103,7 @@ export function DestinationSearch({
|
|||
}
|
||||
|
||||
return out;
|
||||
}, [spots, shops, merchants]);
|
||||
}, [spots, shops, merchants, t]);
|
||||
|
||||
const results = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
|
|
|
|||
|
|
@ -26,17 +26,7 @@ import { ReportDialog } from '@/components/ReportDialog';
|
|||
import { useTranslation } from '@/contexts/language';
|
||||
import type { SkateEvent } from '@/lib/types';
|
||||
import { EVENT_TYPE_LABELS } from '@/lib/types';
|
||||
|
||||
/** Map event type keys to translation keys (types live in lib/types; labels translate at render). */
|
||||
const EVENT_TYPE_KEYS: Record<string, string> = {
|
||||
contest: 'placesUi.eventTypes.contest',
|
||||
session: 'placesUi.eventTypes.session',
|
||||
meetup: 'placesUi.eventTypes.meetup',
|
||||
premiere: 'placesUi.eventTypes.premiere',
|
||||
workshop: 'placesUi.eventTypes.workshop',
|
||||
demo: 'placesUi.eventTypes.demo',
|
||||
jam: 'placesUi.eventTypes.jam',
|
||||
};
|
||||
import { EVENT_TYPE_KEYS } from '@/lib/labelKeys';
|
||||
|
||||
function eventTypeLabel(t: (key: string) => string, type: string): string {
|
||||
return EVENT_TYPE_KEYS[type] ? t(EVENT_TYPE_KEYS[type]) : (EVENT_TYPE_LABELS[type] || type);
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { getLastLocation } from '@/lib/lastLocation';
|
|||
import { itemDistanceKm } from '@/lib/sortOptions';
|
||||
import { getSkateboardRating, getTrustLevel, SELLER_TRUST_BADGES } from '@/lib/skateboardRating';
|
||||
import { GEAR_CONDITIONS, GEAR_CATEGORIES, formatPrice, type MarketplaceItem } from '@/lib/types';
|
||||
import { GEAR_CATEGORY_KEYS, GEAR_CONDITION_KEYS } from '@/lib/labelKeys';
|
||||
import { MessageCircle, MapPin, Search, CheckCircle, ShieldCheck, AlertTriangle, MoreVertical, Flag, Trash2, RotateCcw, Loader2 } from 'lucide-react';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from '@/components/ui/dropdown-menu';
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog';
|
||||
|
|
@ -80,7 +81,7 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
|
|||
};
|
||||
|
||||
// Format price display
|
||||
const priceDisplay = formatPrice(item.price, item.priceType);
|
||||
const priceDisplay = formatPrice(item.price, item.priceType, t);
|
||||
|
||||
const timeAgo = formatDistanceToNow(new Date(item.createdAt * 1000), { addSuffix: true });
|
||||
|
||||
|
|
@ -91,8 +92,11 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
|
|||
const conditionInfo = item.condition ? GEAR_CONDITIONS[item.condition] : null;
|
||||
|
||||
// Get category label
|
||||
const categoryLabel = item.category?.find((c) => GEAR_CATEGORIES[c as keyof typeof GEAR_CATEGORIES])
|
||||
? GEAR_CATEGORIES[item.category.find((c) => GEAR_CATEGORIES[c as keyof typeof GEAR_CATEGORIES]) as keyof typeof GEAR_CATEGORIES]
|
||||
const categoryValue = item.category?.find((c) => GEAR_CATEGORIES[c as keyof typeof GEAR_CATEGORIES]);
|
||||
const categoryLabel = categoryValue
|
||||
? GEAR_CATEGORY_KEYS[categoryValue]
|
||||
? t(GEAR_CATEGORY_KEYS[categoryValue])
|
||||
: GEAR_CATEGORIES[categoryValue as keyof typeof GEAR_CATEGORIES]
|
||||
: null;
|
||||
|
||||
// Get price type label
|
||||
|
|
@ -198,7 +202,7 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
|
|||
variant="outline"
|
||||
className="absolute bottom-2 left-2 bg-black/80 border-gray-600 text-xs backdrop-blur-sm"
|
||||
>
|
||||
{conditionInfo.emoji} {conditionInfo.label}
|
||||
{conditionInfo.emoji} {item.condition && GEAR_CONDITION_KEYS[item.condition] ? t(GEAR_CONDITION_KEYS[item.condition]) : conditionInfo.label}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|||
import { Users, ChevronRight, MapPin } from 'lucide-react';
|
||||
import { useCrewsByFocus } from '@/hooks/useCrews';
|
||||
import { CREW_FOCUS_LABELS, crewFocusForServiceType, type SkateCrew, type CrewFocus } from '@/lib/types';
|
||||
import { CREW_FOCUS_KEYS } from '@/lib/labelKeys';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
|
|
@ -92,7 +93,7 @@ export function LabCrewsStrip({ serviceType }: { serviceType: ServiceType }) {
|
|||
<Users className="h-4 w-4" />
|
||||
{t('labUi.crewsStrip.title')}
|
||||
<span className="rounded-full bg-teal-500/15 px-2 py-0.5 text-[10px] font-bold text-teal-300">
|
||||
{CREW_FOCUS_LABELS[focus]}
|
||||
{CREW_FOCUS_KEYS[focus] ? t(CREW_FOCUS_KEYS[focus]) : CREW_FOCUS_LABELS[focus]}
|
||||
</span>
|
||||
</h2>
|
||||
<Link
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import { Clock, Search, Tag, Flag, MoreVertical, ShieldCheck, Calendar, Package,
|
|||
import { formatDistanceToNow } from '@/lib/dateFnsI18n';
|
||||
import type { MarketplaceItem } from '@/lib/types';
|
||||
import { GEAR_CONDITIONS, GEAR_CATEGORIES, PRICE_TYPES, LISTING_REPORT_REASONS, formatPrice } from '@/lib/types';
|
||||
import { GEAR_CATEGORY_KEYS, GEAR_CONDITION_KEYS, PRICE_TYPE_KEYS } from '@/lib/labelKeys';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
interface MarketplaceItemCardProps {
|
||||
|
|
@ -78,8 +79,8 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
|||
|
||||
// Format price display
|
||||
const priceDisplay = item.price
|
||||
? `${formatPrice(item.price, item.priceType)}${item.price.frequency ? `/${item.price.frequency}` : ''}`
|
||||
: formatPrice(undefined, item.priceType);
|
||||
? `${formatPrice(item.price, item.priceType, t)}${item.price.frequency ? `/${item.price.frequency}` : ''}`
|
||||
: formatPrice(undefined, item.priceType, t);
|
||||
|
||||
const timeAgo = formatDistanceToNow(new Date(item.createdAt * 1000), { addSuffix: true });
|
||||
|
||||
|
|
@ -93,12 +94,19 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
|||
const conditionInfo = item.condition ? GEAR_CONDITIONS[item.condition] : null;
|
||||
|
||||
// Get category label
|
||||
const categoryLabel = item.category?.find((c) => GEAR_CATEGORIES[c as keyof typeof GEAR_CATEGORIES])
|
||||
? GEAR_CATEGORIES[item.category.find((c) => GEAR_CATEGORIES[c as keyof typeof GEAR_CATEGORIES]) as keyof typeof GEAR_CATEGORIES]
|
||||
const categoryValue = item.category?.find((c) => GEAR_CATEGORIES[c as keyof typeof GEAR_CATEGORIES]);
|
||||
const categoryLabel = categoryValue
|
||||
? GEAR_CATEGORY_KEYS[categoryValue]
|
||||
? t(GEAR_CATEGORY_KEYS[categoryValue])
|
||||
: GEAR_CATEGORIES[categoryValue as keyof typeof GEAR_CATEGORIES]
|
||||
: null;
|
||||
|
||||
// Get price type label
|
||||
const priceTypeLabel = item.priceType ? PRICE_TYPES[item.priceType]?.label : null;
|
||||
const priceTypeLabel = item.priceType
|
||||
? PRICE_TYPE_KEYS[item.priceType]
|
||||
? t(PRICE_TYPE_KEYS[item.priceType])
|
||||
: PRICE_TYPES[item.priceType]?.label
|
||||
: null;
|
||||
|
||||
// Sats estimate from the LIVE BTC/USD rate — never a hardcoded constant
|
||||
// (that quoted buyers wildly wrong amounts). Non-USD fiat is left unestimated
|
||||
|
|
@ -254,7 +262,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
|||
>
|
||||
{priceDisplay}
|
||||
</span>
|
||||
{priceTypeLabel && priceTypeLabel !== 'Fixed Price' && (
|
||||
{priceTypeLabel && item.priceType !== 'fixed' && (
|
||||
<div className="text-xs text-gray-400">{priceTypeLabel}</div>
|
||||
)}
|
||||
{priceInSats > 0 && (
|
||||
|
|
@ -332,7 +340,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
|||
<div className="flex flex-wrap gap-1 mb-2">
|
||||
{conditionInfo && (
|
||||
<Badge variant="outline" className="text-xs border-gray-600 bg-gray-800/50">
|
||||
{conditionInfo.emoji} {conditionInfo.label}
|
||||
{conditionInfo.emoji} {item.condition && GEAR_CONDITION_KEYS[item.condition] ? t(GEAR_CONDITION_KEYS[item.condition]) : conditionInfo.label}
|
||||
</Badge>
|
||||
)}
|
||||
{item.size && (
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ function ReportRow({ report }: { report: SpotStatusReport }) {
|
|||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="font-semibold">{meta.label}</span>
|
||||
<span className="font-semibold">{t(meta.labelKey)}</span>
|
||||
<span className="text-muted-foreground truncate">{t('spotUi.byAuthor', { name })}</span>
|
||||
<span className="text-muted-foreground ml-auto flex-shrink-0">
|
||||
{statusTimeAgo(report.createdAt)}
|
||||
|
|
@ -154,8 +154,8 @@ export function ReportSpotStatusDialog({
|
|||
style={{ backgroundColor: meta.dot, width: 12, height: 12 }}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-semibold text-sm">{meta.label}</p>
|
||||
<p className="text-xs text-muted-foreground">{meta.description}</p>
|
||||
<p className="font-semibold text-sm">{t(meta.labelKey)}</p>
|
||||
<p className="text-xs text-muted-foreground">{t(meta.descriptionKey)}</p>
|
||||
</div>
|
||||
{isSel && <Check className="h-4 w-4 text-[#ff0040] flex-shrink-0" />}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import {
|
|||
skateTime,
|
||||
type TravelMode,
|
||||
type RouteResult,
|
||||
type RouteStep,
|
||||
type TransitPlanOptions,
|
||||
} from '@/lib/routing';
|
||||
import {
|
||||
|
|
@ -66,6 +67,43 @@ const TRANSIT_GROUP_LABEL_KEYS: Record<string, string> = {
|
|||
ferry: 'routeUi.group.ferry',
|
||||
};
|
||||
|
||||
/** Minimal translation-function shape. */
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
/** Maneuver tokens live in `lib/routing`; map them to literal translation keys. */
|
||||
const MANEUVER_LABEL_KEYS: Record<string, string> = {
|
||||
headOut: 'routeUi.maneuver.headOut',
|
||||
arrive: 'routeUi.maneuver.arrive',
|
||||
continueStraight: 'routeUi.maneuver.continueStraight',
|
||||
bearLeft: 'routeUi.maneuver.bearLeft',
|
||||
bearRight: 'routeUi.maneuver.bearRight',
|
||||
turnLeft: 'routeUi.maneuver.turnLeft',
|
||||
turnRight: 'routeUi.maneuver.turnRight',
|
||||
sharpLeft: 'routeUi.maneuver.sharpLeft',
|
||||
sharpRight: 'routeUi.maneuver.sharpRight',
|
||||
turn: 'routeUi.maneuver.turn',
|
||||
keepLeft: 'routeUi.maneuver.keepLeft',
|
||||
keepRight: 'routeUi.maneuver.keepRight',
|
||||
takeFork: 'routeUi.maneuver.takeFork',
|
||||
merge: 'routeUi.maneuver.merge',
|
||||
roundabout: 'routeUi.maneuver.roundabout',
|
||||
takeRamp: 'routeUi.maneuver.takeRamp',
|
||||
takeExit: 'routeUi.maneuver.takeExit',
|
||||
};
|
||||
|
||||
/**
|
||||
* Translate a route step. New steps carry a maneuver token + optional road
|
||||
* name (feed data, appended but never translated); legacy cached steps only
|
||||
* have the English `instruction`.
|
||||
*/
|
||||
function stepInstruction(step: RouteStep, t: Translate): string {
|
||||
if (!step.label) return step.instruction;
|
||||
const key = MANEUVER_LABEL_KEYS[step.label];
|
||||
if (!key) return step.instruction;
|
||||
const base = t(key);
|
||||
return step.road ? t('routeUi.maneuverOnto', { instruction: base, road: step.road }) : base;
|
||||
}
|
||||
|
||||
/** A stop name that opens the departure board, when the feed gave a stop id. */
|
||||
function StopButton({
|
||||
id,
|
||||
|
|
@ -489,14 +527,14 @@ export function RouteDialog({
|
|||
const metres = Math.max(50, Math.round((km * 1000) / 10) * 10);
|
||||
speak(t('routeUi.voiceIn', {
|
||||
metres,
|
||||
instruction: steps[idx]?.instruction ?? t('routeUi.voiceContinue'),
|
||||
instruction: steps[idx] ? stepInstruction(steps[idx], t) : t('routeUi.voiceContinue'),
|
||||
}));
|
||||
buzz(60);
|
||||
} else if (km <= 0.04 && !bands.has(40)) {
|
||||
// ~40 m out — the turn itself.
|
||||
bands.add(40);
|
||||
announcedRef.current.set(idx, bands);
|
||||
speak(steps[idx]?.instruction ?? t('routeUi.voiceContinueCaps'));
|
||||
speak(steps[idx] ? stepInstruction(steps[idx], t) : t('routeUi.voiceContinueCaps'));
|
||||
buzz(140);
|
||||
}
|
||||
}, [following, nav, route, speak, buzz, stopFollowing, t]);
|
||||
|
|
@ -861,7 +899,7 @@ export function RouteDialog({
|
|||
<div className="pointer-events-none absolute bottom-2 left-2 flex flex-col items-start gap-1">
|
||||
{cachedAge !== null && (
|
||||
<div className="rounded-full border border-amber-500/40 bg-amber-500/15 px-2 py-1 text-[10px] font-bold uppercase tracking-wide text-amber-300 backdrop-blur-sm">
|
||||
{t('routeUi.offlineSavedRoute', { age: formatAge(cachedAge) })}
|
||||
{t('routeUi.offlineSavedRoute', { age: formatAge(cachedAge, t) })}
|
||||
</div>
|
||||
)}
|
||||
{locating && origin && (
|
||||
|
|
@ -888,8 +926,8 @@ export function RouteDialog({
|
|||
</p>
|
||||
<p className="truncate text-base font-black text-white">
|
||||
{nav.stepIndex >= 0
|
||||
? route?.steps?.[nav.stepIndex]?.instruction ?? t('routeUi.followTheLine')
|
||||
: route?.steps?.[0]?.instruction ?? t('routeUi.followTheLine')}
|
||||
? route?.steps?.[nav.stepIndex] ? stepInstruction(route.steps[nav.stepIndex], t) : t('routeUi.followTheLine')
|
||||
: route?.steps?.[0] ? stepInstruction(route.steps[0], t) : t('routeUi.followTheLine')}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{nav.stepIndex >= 0 && Number.isFinite(nav.stepKm)
|
||||
|
|
@ -1073,7 +1111,7 @@ export function RouteDialog({
|
|||
: t('routeUi.direct')}
|
||||
</span>
|
||||
{mode === 'foot'
|
||||
? <span>· {skateTime(route.distanceM / 1000)} {t('routeUi.onABoard')}</span>
|
||||
? <span>· {skateTime(route.distanceM / 1000, t)}</span>
|
||||
: speed && (
|
||||
<span>· {t('routeUi.speedAvg', { speed })}</span>
|
||||
)}
|
||||
|
|
@ -1127,7 +1165,7 @@ export function RouteDialog({
|
|||
<span className="mt-px w-4 shrink-0 text-right font-mono text-[10px] text-muted-foreground/60">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 leading-snug text-gray-300">{s.instruction}</span>
|
||||
<span className="min-w-0 flex-1 leading-snug text-gray-300">{stepInstruction(s, t)}</span>
|
||||
{s.distanceM >= 10 && (
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground/70">
|
||||
{formatDistance(s.distanceM / 1000)}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ interface Props {
|
|||
export function SasquatchActionCard({ draft, onPublish, onCancel, status, resultLabel }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [edited, setEdited] = useState<SasquatchDraft>(draft);
|
||||
const summary = summarizeDraft(edited);
|
||||
const summary = summarizeDraft(edited, t);
|
||||
|
||||
if (status === 'done') {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ export function SasquatchProDialog({
|
|||
<p className="text-sm text-gray-300">
|
||||
{t('sasquatchUi.scanToPayPre')}{' '}
|
||||
<span className="font-black text-white">{t('sasquatchUi.scanToPayAmount', { sats: selected.sats.toLocaleString() })}</span>{' '}
|
||||
{t('sasquatchUi.scanToPayPost', { label: selected.label })}
|
||||
{t('sasquatchUi.scanToPayPost', { label: t(selected.labelKey) })}
|
||||
</p>
|
||||
{qr && <img src={qr} alt={t('sasquatchUi.invoiceQrAlt')} className="mx-auto rounded-xl bg-white p-2" width={240} height={240} />}
|
||||
<div className="flex gap-2">
|
||||
|
|
@ -221,9 +221,9 @@ function TierPicker({ selected, onSelect }: { selected: ProTier; onSelect: (t: P
|
|||
{t('sasquatchUi.popular')}
|
||||
</span>
|
||||
)}
|
||||
<p className="text-sm font-black text-white">{tier.label}</p>
|
||||
<p className="text-sm font-black text-white">{t(tier.labelKey)}</p>
|
||||
<p className="text-lg font-black text-red-400">{tier.sats.toLocaleString()}<span className="ml-0.5 text-xs text-gray-500">{t('sasquatchUi.sats')}</span></p>
|
||||
<p className="text-[10px] text-gray-500">{[usd, tier.badge].filter(Boolean).join(' · ')}</p>
|
||||
<p className="text-[10px] text-gray-500">{[usd, tier.badgeKey ? t(tier.badgeKey) : tier.badge].filter(Boolean).join(' · ')}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,9 @@ import { AddToCollectionDialog } from '@/components/AddToCollectionDialog';
|
|||
import { ReportDialog } from '@/components/ReportDialog';
|
||||
import { SpotStatusBadge } from '@/components/SpotStatusBadge';
|
||||
import type { SkateSpot } from '@/lib/types';
|
||||
import { SPOT_TYPE_LABELS, ACCESS_TYPE_INFO, SHELTER_INFO, SKATE_SHOP_KIND } from '@/lib/types';
|
||||
import { ACCESS_TYPE_INFO, SHELTER_INFO, SKATE_SHOP_KIND } from '@/lib/types';
|
||||
import { spotTypeLabel } from '@/lib/spotTypeLabel';
|
||||
import { ACCESS_TYPE_KEYS, SHELTER_SHORT_KEYS } from '@/lib/labelKeys';
|
||||
import type { AggregatedSpotStatus } from '@/hooks/useSpotStatus';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
|
@ -115,7 +117,7 @@ export function SpotCard({ spot, status }: SpotCardProps) {
|
|||
color: ACCESS_TYPE_INFO[spot.accessType].color
|
||||
}}
|
||||
>
|
||||
{ACCESS_TYPE_INFO[spot.accessType].emoji} {ACCESS_TYPE_INFO[spot.accessType].label.split(' - ')[0]}
|
||||
{ACCESS_TYPE_INFO[spot.accessType].emoji} {(ACCESS_TYPE_KEYS[spot.accessType] ? t(ACCESS_TYPE_KEYS[spot.accessType]) : ACCESS_TYPE_INFO[spot.accessType].label).split(' - ')[0]}
|
||||
</Badge>
|
||||
)}
|
||||
{spot.shelter && spot.shelter !== 'outdoor' && SHELTER_INFO[spot.shelter] && (
|
||||
|
|
@ -126,7 +128,7 @@ export function SpotCard({ spot, status }: SpotCardProps) {
|
|||
color: SHELTER_INFO[spot.shelter].color,
|
||||
}}
|
||||
>
|
||||
{SHELTER_INFO[spot.shelter].emoji} {SHELTER_INFO[spot.shelter].short}
|
||||
{SHELTER_INFO[spot.shelter].emoji} {SHELTER_SHORT_KEYS[spot.shelter] ? t(SHELTER_SHORT_KEYS[spot.shelter]) : SHELTER_INFO[spot.shelter].short}
|
||||
</Badge>
|
||||
)}
|
||||
{spot.types.length > 0 && (
|
||||
|
|
@ -136,7 +138,7 @@ export function SpotCard({ spot, status }: SpotCardProps) {
|
|||
key={type}
|
||||
className="bg-black/80 text-[#ccff00] backdrop-blur-sm border border-[#ccff00]/50 font-bold uppercase text-[10px]"
|
||||
>
|
||||
{SPOT_TYPE_LABELS[type] || type}
|
||||
{spotTypeLabel(t, type)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -743,7 +743,7 @@ function createEnhancedPopupContent(
|
|||
const itemDiv = document.createElement('div');
|
||||
itemDiv.className = 'text-xs text-gray-300';
|
||||
|
||||
const price = formatPrice(item.price, item.priceType);
|
||||
const price = formatPrice(item.price, item.priceType, t);
|
||||
itemDiv.textContent = `• ${item.title} - ${price}`;
|
||||
|
||||
itemsList.appendChild(itemDiv);
|
||||
|
|
@ -1198,7 +1198,7 @@ export function SpotMap({
|
|||
group.marketplaceItems.slice(0, 3).forEach((item) => {
|
||||
const itemDiv = document.createElement('div');
|
||||
itemDiv.className = 'text-xs text-gray-300';
|
||||
const price = formatPrice(item.price, item.priceType);
|
||||
const price = formatPrice(item.price, item.priceType, t);
|
||||
itemDiv.textContent = `• ${item.title} - ${price}`;
|
||||
itemsList.appendChild(itemDiv);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ export function SpotStatusBadge({
|
|||
height: size === 'sm' ? 6 : 8,
|
||||
}}
|
||||
/>
|
||||
{meta.label}
|
||||
{t(meta.labelKey)}
|
||||
</span>
|
||||
);
|
||||
|
||||
|
|
@ -75,8 +75,8 @@ export function SpotStatusBadge({
|
|||
<Tooltip>
|
||||
<TooltipTrigger asChild>{badge}</TooltipTrigger>
|
||||
<TooltipContent className="max-w-56">
|
||||
<p className="font-medium">{meta.label}</p>
|
||||
<p className="text-xs text-muted-foreground">{meta.description}</p>
|
||||
<p className="font-medium">{t(meta.labelKey)}</p>
|
||||
<p className="text-xs text-muted-foreground">{t(meta.descriptionKey)}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{reporterCount} {reporterCount === 1 ? t('spotUi.reportOne') : t('spotUi.reports')}
|
||||
{lastAt ? ` · ${t('spotUi.lastReported', { time: statusTimeAgo(lastAt) })}` : ''}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { ReportDialog } from '@/components/ReportDialog';
|
|||
|
||||
import type { SkateTour, TourCategory } from '@/lib/types';
|
||||
import { TOUR_CATEGORIES, TOUR_SKILL_LEVELS } from '@/lib/types';
|
||||
import { TOUR_CATEGORY_KEYS } from '@/lib/labelKeys';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
|
|
@ -229,7 +230,7 @@ export function TourCard({ tour, compact = false }: TourCardProps) {
|
|||
className="text-[10px] px-1.5 py-0 font-bold"
|
||||
style={{ borderColor: categoryInfo.color, color: categoryInfo.color }}
|
||||
>
|
||||
{categoryInfo.label}
|
||||
{TOUR_CATEGORY_KEYS[tour.category] ? t(TOUR_CATEGORY_KEYS[tour.category]) : categoryInfo.label}
|
||||
</Badge>
|
||||
{tour.duration && (
|
||||
<span className="flex items-center gap-1">
|
||||
|
|
@ -299,7 +300,7 @@ export function TourCard({ tour, compact = false }: TourCardProps) {
|
|||
color: (categoryInfo.color as string) === '#ccff00' ? 'black' : 'white'
|
||||
}}
|
||||
>
|
||||
{categoryInfo.emoji} {categoryInfo.label}
|
||||
{categoryInfo.emoji} {TOUR_CATEGORY_KEYS[tour.category] ? t(TOUR_CATEGORY_KEYS[tour.category]) : categoryInfo.label}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,8 @@ import { useAuthor } from '@/hooks/useAuthor';
|
|||
import { useTranslation } from '@/contexts/language';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { crewAccent, crewBadge, type CrewActivity } from '@/lib/crewHelpers';
|
||||
import { CREW_FOCUS_LABELS, CREW_VIBE_LABELS, type CrewFocus, type SkateCrew } from '@/lib/types';
|
||||
import type { CrewFocus, SkateCrew } from '@/lib/types';
|
||||
import { CREW_FOCUS_KEYS, CREW_VIBE_KEYS } from '@/lib/labelKeys';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const FOCUS_ICON: Record<CrewFocus, typeof Users> = {
|
||||
|
|
@ -161,7 +162,7 @@ export function CrewHero({
|
|||
style={{ color: accent.hex, borderColor: `${accent.hex}55`, background: `${accent.hex}14` }}
|
||||
>
|
||||
<FocusIcon className="h-3.5 w-3.5" />
|
||||
{CREW_FOCUS_LABELS[crew.focus]}
|
||||
{CREW_FOCUS_KEYS[crew.focus] ? t(CREW_FOCUS_KEYS[crew.focus]) : crew.focus}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -217,7 +218,7 @@ export function CrewHero({
|
|||
style={{ color: accent.hex, borderColor: `${accent.hex}55`, background: `${accent.hex}14` }}
|
||||
>
|
||||
<FocusIcon className="h-3 w-3" />
|
||||
{CREW_FOCUS_LABELS[crew.focus]}
|
||||
{CREW_FOCUS_KEYS[crew.focus] ? t(CREW_FOCUS_KEYS[crew.focus]) : crew.focus}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -229,7 +230,7 @@ export function CrewHero({
|
|||
key={v}
|
||||
className="rounded-full border border-white/10 bg-white/[0.06] px-2.5 py-0.5 text-xs font-bold text-gray-300"
|
||||
>
|
||||
{CREW_VIBE_LABELS[v] || v}
|
||||
{CREW_VIBE_KEYS[v] ? t(CREW_VIBE_KEYS[v]) : v}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|||
import { genUserName } from '@/lib/genUserName';
|
||||
import { crewAccent } from '@/lib/crewHelpers';
|
||||
import { CREW_ROLE_META, type CrewRole, type SkateCrew } from '@/lib/types';
|
||||
import { CREW_ROLE_KEYS } from '@/lib/labelKeys';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
|
|
@ -98,7 +99,7 @@ function LeaderCard({
|
|||
key={r}
|
||||
className="inline-flex items-center gap-0.5 rounded-full bg-white/10 px-1.5 py-0.5 text-[10px] font-bold text-gray-200"
|
||||
>
|
||||
<span className="leading-none">{CREW_ROLE_META[r].emoji}</span> {CREW_ROLE_META[r].label}
|
||||
<span className="leading-none">{CREW_ROLE_META[r].emoji}</span> {CREW_ROLE_KEYS[r] ? t(CREW_ROLE_KEYS[r]) : CREW_ROLE_META[r].label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { useNWC } from '@/hooks/useNWCContext';
|
|||
import { useWallet } from '@/hooks/useWallet';
|
||||
import { useRelays } from '@/lib/relays';
|
||||
import { useSendDirectMessage } from '@/hooks/useDirectMessages';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions';
|
||||
import {
|
||||
getZapProviderPubkey,
|
||||
|
|
@ -49,6 +50,7 @@ export function usePurchasePro() {
|
|||
const { webln } = useWallet();
|
||||
const { read, write } = useRelays();
|
||||
const { mutateAsync: sendDM } = useSendDirectMessage();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [result, setResult] = useState<PurchaseResult>({ invoice: null, state: 'idle', error: null });
|
||||
|
||||
|
|
@ -66,7 +68,7 @@ export function usePurchasePro() {
|
|||
const content = [
|
||||
`🦧⚡ Sasquatch Pro — you're in!`,
|
||||
``,
|
||||
`Pass: ${tier.label} (${tier.sats.toLocaleString()} sats)`,
|
||||
`Pass: ${t(tier.labelKey)} (${tier.sats.toLocaleString()} sats)`,
|
||||
`Active until: ${until}`,
|
||||
``,
|
||||
`What you unlocked:`,
|
||||
|
|
@ -83,7 +85,7 @@ export function usePurchasePro() {
|
|||
/* best-effort — don't fail the purchase over a receipt DM */
|
||||
}
|
||||
},
|
||||
[user, sendDM],
|
||||
[user, sendDM, t],
|
||||
);
|
||||
|
||||
/** Publish the self-signed fallback receipt for a paid tier. */
|
||||
|
|
@ -93,20 +95,20 @@ export function usePurchasePro() {
|
|||
const now = Math.floor(Date.now() / 1000);
|
||||
const event = await user.signer.signEvent({
|
||||
kind: SASQUATCH_PRO_KIND,
|
||||
content: `Sasquatch Pro — ${tier.label} supporter pass 🦧⚡`,
|
||||
content: `Sasquatch Pro — ${t(tier.labelKey)} supporter pass 🦧⚡`,
|
||||
tags: [
|
||||
['d', 'sasquatch-pro'],
|
||||
['tier', tier.id],
|
||||
['expiry', String(expiry)],
|
||||
['amount', String(tier.sats)],
|
||||
['p', PLATFORM_PUBKEY],
|
||||
['alt', `Sasquatch Pro supporter pass (${tier.label}), valid until ${new Date(expiry * 1000).toISOString()}`],
|
||||
['alt', `Sasquatch Pro supporter pass (${t(tier.labelKey)}), valid until ${new Date(expiry * 1000).toISOString()}`],
|
||||
],
|
||||
created_at: now,
|
||||
});
|
||||
await nostr.event(event, { signal: AbortSignal.timeout(10000) });
|
||||
},
|
||||
[user, nostr],
|
||||
[user, nostr, t],
|
||||
);
|
||||
|
||||
const purchase = useCallback(
|
||||
|
|
@ -123,7 +125,7 @@ export function usePurchasePro() {
|
|||
const expiry = now + tier.days * DAY_SECONDS;
|
||||
const amountMsats = tier.sats * 1000;
|
||||
const relays = [...new Set([...read, ...write])];
|
||||
const comment = `Sasquatch Pro — ${tier.label} 🦧`;
|
||||
const comment = `Sasquatch Pro — ${t(tier.labelKey)} 🦧`;
|
||||
|
||||
// Try a NIP-57 zap first (unforgeable, npub-bound). Fall back to a plain
|
||||
// invoice if the provider doesn't publish zap receipts.
|
||||
|
|
@ -199,7 +201,7 @@ export function usePurchasePro() {
|
|||
});
|
||||
}
|
||||
},
|
||||
[user, read, write, getActiveConnection, sendPayment, webln, publishReceipt, sendConfirmationDM],
|
||||
[user, read, write, getActiveConnection, sendPayment, webln, publishReceipt, sendConfirmationDM, t],
|
||||
);
|
||||
|
||||
/** Called after a manual (QR) payment to record the pass locally. */
|
||||
|
|
|
|||
|
|
@ -13,9 +13,13 @@
|
|||
import { useCallback } from 'react';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { MARKETPLACE_ITEM_KIND } from '@/lib/types';
|
||||
import { USER_STATUS_KIND } from '@/hooks/useUserStatus';
|
||||
|
||||
/** Minimal translation-function shape (this module is imported by components). */
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
// ─── Draft shapes ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PostStatusDraft {
|
||||
|
|
@ -57,31 +61,31 @@ export type SasquatchDraft = PostStatusDraft | ListGearDraft | WtbGearDraft;
|
|||
|
||||
// ─── Human-readable summary for the confirmation card ────────────────────────
|
||||
|
||||
export function summarizeDraft(d: SasquatchDraft): { title: string; lines: string[]; cta: string } {
|
||||
export function summarizeDraft(d: SasquatchDraft, t: Translate): { title: string; lines: string[]; cta: string } {
|
||||
if (d.type === 'post_status') {
|
||||
const mins = d.expiresInMinutes ?? 120;
|
||||
return {
|
||||
title: 'Post a skating status',
|
||||
lines: [`"${d.text}"`, `Auto-expires in ${mins} min`],
|
||||
cta: 'Post status',
|
||||
title: t('sasquatchUi.draftPostStatusTitle'),
|
||||
lines: [`"${d.text}"`, t('sasquatchUi.draftAutoExpires', { count: mins })],
|
||||
cta: t('sasquatchUi.draftPostStatusCta'),
|
||||
};
|
||||
}
|
||||
if (d.type === 'wtb_gear') {
|
||||
const lines: string[] = [];
|
||||
if (d.category) lines.push(`Category: ${d.category}`);
|
||||
if (d.size) lines.push(`Size: ${d.size}`);
|
||||
if (d.condition) lines.push(`Condition: ${d.condition}`);
|
||||
lines.push(d.price ? `Max price: ${d.price.toLocaleString()} ${d.currency ?? 'sats'}` : 'Price: open');
|
||||
if (d.category) lines.push(t('sasquatchUi.draftCategory', { value: d.category }));
|
||||
if (d.size) lines.push(t('sasquatchUi.draftSize', { value: d.size }));
|
||||
if (d.condition) lines.push(t('sasquatchUi.draftCondition', { value: d.condition }));
|
||||
lines.push(d.price ? t('sasquatchUi.draftMaxPrice', { price: d.price.toLocaleString(), currency: d.currency ?? 'sats' }) : t('sasquatchUi.draftPriceOpen'));
|
||||
if (d.description) lines.push(d.description);
|
||||
return { title: `Looking for "${d.title}"`, lines, cta: 'Post wanted ad' };
|
||||
return { title: t('sasquatchUi.draftLookingForTitle', { title: d.title }), lines, cta: t('sasquatchUi.draftWantedCta') };
|
||||
}
|
||||
const lines: string[] = [];
|
||||
if (d.category) lines.push(`Category: ${d.category}`);
|
||||
if (d.condition) lines.push(`Condition: ${d.condition}`);
|
||||
if (d.size) lines.push(`Size: ${d.size}`);
|
||||
lines.push(d.price ? `Price: ${d.price.toLocaleString()} ${d.currency ?? 'sats'}` : 'Price: make offer');
|
||||
if (d.category) lines.push(t('sasquatchUi.draftCategory', { value: d.category }));
|
||||
if (d.condition) lines.push(t('sasquatchUi.draftCondition', { value: d.condition }));
|
||||
if (d.size) lines.push(t('sasquatchUi.draftSize', { value: d.size }));
|
||||
lines.push(d.price ? t('sasquatchUi.draftPrice', { price: d.price.toLocaleString(), currency: d.currency ?? 'sats' }) : t('sasquatchUi.draftPriceOffer'));
|
||||
if (d.description) lines.push(d.description);
|
||||
return { title: `List "${d.title}" for sale`, lines, cta: 'Publish listing' };
|
||||
return { title: t('sasquatchUi.draftListTitle', { title: d.title }), lines, cta: t('sasquatchUi.draftPublishCta') };
|
||||
}
|
||||
|
||||
// ─── The hook ─────────────────────────────────────────────────────────────────
|
||||
|
|
@ -89,17 +93,18 @@ export function summarizeDraft(d: SasquatchDraft): { title: string; lines: strin
|
|||
export function useSasquatchActions() {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const publishDraft = useCallback(
|
||||
async (draft: SasquatchDraft): Promise<{ ok: true; label: string }> => {
|
||||
if (!user) throw new Error('You must be logged in.');
|
||||
if (!user) throw new Error(t('sasquatchUi.mustBeLoggedIn'));
|
||||
|
||||
if (draft.type === 'post_status') {
|
||||
const tags: string[][] = [['d', 'general']];
|
||||
const mins = draft.expiresInMinutes ?? 120;
|
||||
tags.push(['expiration', String(Math.floor(Date.now() / 1000) + mins * 60)]);
|
||||
await publishEvent({ kind: USER_STATUS_KIND, content: draft.text.slice(0, 140), tags });
|
||||
return { ok: true, label: 'Status posted 🛹' };
|
||||
return { ok: true, label: t('sasquatchUi.statusPosted') };
|
||||
}
|
||||
|
||||
if (draft.type === 'wtb_gear') {
|
||||
|
|
@ -129,7 +134,7 @@ export function useSasquatchActions() {
|
|||
content: (draft.description ?? '').slice(0, 2000),
|
||||
tags,
|
||||
});
|
||||
return { ok: true, label: 'Wanted ad published 🔎' };
|
||||
return { ok: true, label: t('sasquatchUi.wantedPublished') };
|
||||
}
|
||||
|
||||
// list_gear
|
||||
|
|
@ -160,9 +165,9 @@ export function useSasquatchActions() {
|
|||
content: (draft.description ?? '').slice(0, 2000),
|
||||
tags,
|
||||
});
|
||||
return { ok: true, label: 'Listing published 💸' };
|
||||
return { ok: true, label: t('sasquatchUi.listingPublished') };
|
||||
},
|
||||
[user, publishEvent],
|
||||
[user, publishEvent, t],
|
||||
);
|
||||
|
||||
return { publishDraft };
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import routeUi from './routeUi';
|
|||
import sasquatchUi from './sasquatchUi';
|
||||
import collectionUi from './collectionUi';
|
||||
import bootUi from './bootUi';
|
||||
import labelsUi from './labelsUi';
|
||||
|
||||
/** Namespaces added by the batch-3 translation pass, spread into `en`. */
|
||||
export const batch3En = {
|
||||
|
|
@ -9,6 +10,7 @@ export const batch3En = {
|
|||
sasquatchUi: sasquatchUi.en,
|
||||
collectionUi: collectionUi.en,
|
||||
bootUi: bootUi.en,
|
||||
labelsUi: labelsUi.en,
|
||||
};
|
||||
|
||||
/** Namespaces added by the batch-3 translation pass, spread into `es`. */
|
||||
|
|
@ -17,4 +19,5 @@ export const batch3Es = {
|
|||
sasquatchUi: sasquatchUi.es,
|
||||
collectionUi: collectionUi.es,
|
||||
bootUi: bootUi.es,
|
||||
labelsUi: labelsUi.es,
|
||||
};
|
||||
|
|
|
|||
488
src/i18n/batch3/labelsUi.ts
Normal file
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',
|
||||
gettingAround: 'Getting around',
|
||||
noMiddleman: 'I plan the skating — flights & stays open on the real sites (no KYC, no middleman).',
|
||||
// Cached-route age
|
||||
ageJustNow: 'just now',
|
||||
ageMinAgo: '{{count}} min ago',
|
||||
ageHourAgo: '{{count}} h ago',
|
||||
ageDayAgo: '{{count}} d ago',
|
||||
// Skate-paced cruise estimate
|
||||
skateRightHere: 'right here',
|
||||
skateMin: '~{{count}} min cruise',
|
||||
skateHours: '~{{hours}} h cruise',
|
||||
// Turn-by-turn maneuvers
|
||||
maneuver: {
|
||||
headOut: 'Head out',
|
||||
arrive: 'Arrive at the spot',
|
||||
continueStraight: 'Continue',
|
||||
bearLeft: 'Bear left',
|
||||
bearRight: 'Bear right',
|
||||
turnLeft: 'Turn left',
|
||||
turnRight: 'Turn right',
|
||||
sharpLeft: 'Sharp left',
|
||||
sharpRight: 'Sharp right',
|
||||
turn: 'Turn',
|
||||
keepLeft: 'Keep left',
|
||||
keepRight: 'Keep right',
|
||||
takeFork: 'Take the fork',
|
||||
merge: 'Merge',
|
||||
roundabout: 'Take the roundabout',
|
||||
takeRamp: 'Take the ramp',
|
||||
takeExit: 'Take the exit',
|
||||
},
|
||||
maneuverOnto: '{{instruction}} onto {{road}}',
|
||||
},
|
||||
es: {
|
||||
// Header / search
|
||||
|
|
@ -291,6 +321,36 @@ const routeUi = {
|
|||
stays: 'Alojamiento',
|
||||
gettingAround: 'Moverse',
|
||||
noMiddleman: 'Yo planifico el skate — los vuelos y alojamientos se abren en los sitios reales (sin KYC, sin intermediarios).',
|
||||
// Cached-route age
|
||||
ageJustNow: 'ahora mismo',
|
||||
ageMinAgo: 'hace {{count}} min',
|
||||
ageHourAgo: 'hace {{count}} h',
|
||||
ageDayAgo: 'hace {{count}} d',
|
||||
// Skate-paced cruise estimate
|
||||
skateRightHere: 'aquí mismo',
|
||||
skateMin: '~{{count}} min en tabla',
|
||||
skateHours: '~{{hours}} h en tabla',
|
||||
// Turn-by-turn maneuvers
|
||||
maneuver: {
|
||||
headOut: 'Sal',
|
||||
arrive: 'Llega al spot',
|
||||
continueStraight: 'Continúa',
|
||||
bearLeft: 'Desvíate a la izquierda',
|
||||
bearRight: 'Desvíate a la derecha',
|
||||
turnLeft: 'Gira a la izquierda',
|
||||
turnRight: 'Gira a la derecha',
|
||||
sharpLeft: 'Giro cerrado a la izquierda',
|
||||
sharpRight: 'Giro cerrado a la derecha',
|
||||
turn: 'Gira',
|
||||
keepLeft: 'Mantente a la izquierda',
|
||||
keepRight: 'Mantente a la derecha',
|
||||
takeFork: 'Toma la bifurcación',
|
||||
merge: 'Incorpórate',
|
||||
roundabout: 'Toma la rotonda',
|
||||
takeRamp: 'Toma la incorporación',
|
||||
takeExit: 'Toma la salida',
|
||||
},
|
||||
maneuverOnto: '{{instruction}} hacia {{road}}',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -109,6 +109,35 @@ const sasquatchUi = {
|
|||
theNode: 'the node',
|
||||
lowBalance: 'Running low — top up at {{provider}} or AI will fall back to the keyless provider.',
|
||||
aiDesc: 'Pay-per-request AI, funded in sats via Routstr. Tops up when low.',
|
||||
|
||||
// ── Pro tier labels & badges (PRO_TIERS) ──
|
||||
tier7d: '7 days',
|
||||
tier1m: '1 month',
|
||||
tier3m: '3 months',
|
||||
tier1y: '1 year',
|
||||
badgeTry: 'Try it',
|
||||
badgeOff20: '20% off',
|
||||
badgeBest: 'Best value · 40% off',
|
||||
|
||||
// ── Draft action summary ──
|
||||
draftPostStatusTitle: 'Post a skating status',
|
||||
draftAutoExpires: 'Auto-expires in {{count}} min',
|
||||
draftPostStatusCta: 'Post status',
|
||||
draftCategory: 'Category: {{value}}',
|
||||
draftSize: 'Size: {{value}}',
|
||||
draftCondition: 'Condition: {{value}}',
|
||||
draftMaxPrice: 'Max price: {{price}} {{currency}}',
|
||||
draftPriceOpen: 'Price: open',
|
||||
draftLookingForTitle: 'Looking for "{{title}}"',
|
||||
draftWantedCta: 'Post wanted ad',
|
||||
draftPrice: 'Price: {{price}} {{currency}}',
|
||||
draftPriceOffer: 'Price: make offer',
|
||||
draftListTitle: 'List "{{title}}" for sale',
|
||||
draftPublishCta: 'Publish listing',
|
||||
statusPosted: 'Status posted 🛹',
|
||||
wantedPublished: 'Wanted ad published 🔎',
|
||||
listingPublished: 'Listing published 💸',
|
||||
mustBeLoggedIn: 'You must be logged in.',
|
||||
},
|
||||
es: {
|
||||
// ── Connection status ──
|
||||
|
|
@ -211,6 +240,35 @@ const sasquatchUi = {
|
|||
theNode: 'el nodo',
|
||||
lowBalance: 'Saldo bajo — recarga en {{provider}} o la IA recurrirá al proveedor sin clave.',
|
||||
aiDesc: 'IA de pago por uso, financiada en sats vía Routstr. Se recarga cuando baja.',
|
||||
|
||||
// ── Pro tier labels & badges (PRO_TIERS) ──
|
||||
tier7d: '7 días',
|
||||
tier1m: '1 mes',
|
||||
tier3m: '3 meses',
|
||||
tier1y: '1 año',
|
||||
badgeTry: 'Pruébalo',
|
||||
badgeOff20: '20 % dto.',
|
||||
badgeBest: 'Mejor precio · 40 % dto.',
|
||||
|
||||
// ── Draft action summary ──
|
||||
draftPostStatusTitle: 'Publicar un estado de skate',
|
||||
draftAutoExpires: 'Se borra automáticamente en {{count}} min',
|
||||
draftPostStatusCta: 'Publicar estado',
|
||||
draftCategory: 'Categoría: {{value}}',
|
||||
draftSize: 'Talla: {{value}}',
|
||||
draftCondition: 'Estado: {{value}}',
|
||||
draftMaxPrice: 'Precio máx.: {{price}} {{currency}}',
|
||||
draftPriceOpen: 'Precio: abierto',
|
||||
draftLookingForTitle: 'Buscando "{{title}}"',
|
||||
draftWantedCta: 'Publicar anuncio',
|
||||
draftPrice: 'Precio: {{price}} {{currency}}',
|
||||
draftPriceOffer: 'Precio: aceptar ofertas',
|
||||
draftListTitle: 'Vender "{{title}}"',
|
||||
draftPublishCta: 'Publicar anuncio',
|
||||
statusPosted: 'Estado publicado 🛹',
|
||||
wantedPublished: 'Anuncio de búsqueda publicado 🔎',
|
||||
listingPublished: 'Anuncio publicado 💸',
|
||||
mustBeLoggedIn: 'Tienes que iniciar sesión.',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
330
src/lib/labelKeys.ts
Normal file
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.
|
||||
|
|
@ -101,11 +101,11 @@ export function loadRoute(
|
|||
}
|
||||
|
||||
/** "just now" | "12 min ago" | "2 h ago" — for the cached-route badge. */
|
||||
export function formatAge(ageMs: number): string {
|
||||
export function formatAge(ageMs: number, t: TranslateFn): string {
|
||||
const mins = Math.round(ageMs / 60000);
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins < 60) return `${mins} min ago`;
|
||||
if (mins < 1) return t('routeUi.ageJustNow');
|
||||
if (mins < 60) return t('routeUi.ageMinAgo', { count: mins });
|
||||
const hours = Math.round(mins / 60);
|
||||
if (hours < 24) return `${hours} h ago`;
|
||||
return `${Math.round(hours / 24)} d ago`;
|
||||
if (hours < 24) return t('routeUi.ageHourAgo', { count: hours });
|
||||
return t('routeUi.ageDayAgo', { count: Math.round(hours / 24) });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,12 @@ import { dateLocaleCode } from '@/lib/dateFnsI18n';
|
|||
/** Travel modes offered in the UI. */
|
||||
export type TravelMode = 'foot' | 'bike' | 'moto' | 'car' | 'transit';
|
||||
|
||||
/**
|
||||
* Minimal shape of the translation function, declared locally so pure lib
|
||||
* helpers can accept `t` without importing the React context.
|
||||
*/
|
||||
export type TranslateFn = (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
export interface TravelModeMeta {
|
||||
mode: TravelMode;
|
||||
/** OSRM routing profile actually used (undefined for transit). */
|
||||
|
|
@ -184,8 +190,15 @@ interface OsrmResponse {
|
|||
|
||||
/** One turn-by-turn instruction (used for orientation, not live navigation). */
|
||||
export interface RouteStep {
|
||||
/** Human instruction, e.g. "Turn left onto Sukhumvit Rd". */
|
||||
/** Human instruction, e.g. "Turn left onto Sukhumvit Rd". English fallback
|
||||
* used for routes cached before `label` existed. */
|
||||
instruction: string;
|
||||
/** Maneuver token (see `maneuverDescriptor`). The UI maps it to a literal
|
||||
* translation key; absent on legacy cached steps. */
|
||||
label?: string;
|
||||
/** Road/way name to append ("… onto Sukhumvit Rd") — feed data, never
|
||||
* translated. Only set for maneuvers that take a road. */
|
||||
road?: string;
|
||||
/** Length of this step in metres. */
|
||||
distanceM: number;
|
||||
/** Where the manoeuvre happens. */
|
||||
|
|
@ -193,30 +206,46 @@ export interface RouteStep {
|
|||
lng: number;
|
||||
}
|
||||
|
||||
/** Turn OSRM's terse maneuver codes into something readable. */
|
||||
function maneuverLabel(type: string, modifier: string): string {
|
||||
/** The app-owned maneuver token + English fallback, from OSRM's terse codes. */
|
||||
function maneuverDescriptor(type: string, modifier: string): { token: string | null; en: string } {
|
||||
const t = type.toLowerCase();
|
||||
const m = modifier.toLowerCase();
|
||||
if (t === 'depart') return 'Head out';
|
||||
if (t === 'arrive') return 'Arrive at the spot';
|
||||
if (t === 'new name') return 'Continue';
|
||||
if (t === 'continue') return m.includes('left') ? 'Bear left' : m.includes('right') ? 'Bear right' : 'Continue';
|
||||
if (t === 'turn') {
|
||||
if (m.includes('sharp left')) return 'Sharp left';
|
||||
if (m.includes('sharp right')) return 'Sharp right';
|
||||
if (m.includes('slight left')) return 'Bear left';
|
||||
if (m.includes('slight right')) return 'Bear right';
|
||||
if (m.includes('left')) return 'Turn left';
|
||||
if (m.includes('right')) return 'Turn right';
|
||||
return 'Turn';
|
||||
const pick = (token: string, en: string) => ({ token, en });
|
||||
if (t === 'depart') return pick('headOut', 'Head out');
|
||||
if (t === 'arrive') return pick('arrive', 'Arrive at the spot');
|
||||
if (t === 'new name') return pick('continueStraight', 'Continue');
|
||||
if (t === 'continue') {
|
||||
return m.includes('left')
|
||||
? pick('bearLeft', 'Bear left')
|
||||
: m.includes('right')
|
||||
? pick('bearRight', 'Bear right')
|
||||
: pick('continueStraight', 'Continue');
|
||||
}
|
||||
if (t === 'fork') return m.includes('left') ? 'Keep left' : m.includes('right') ? 'Keep right' : 'Take the fork';
|
||||
if (t === 'merge') return 'Merge';
|
||||
if (t === 'roundabout' || t === 'rotary') return 'Take the roundabout';
|
||||
if (t === 'end of road') return m.includes('left') ? 'Turn left' : 'Turn right';
|
||||
if (t === 'on ramp') return 'Take the ramp';
|
||||
if (t === 'off ramp') return 'Take the exit';
|
||||
return t.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
if (t === 'turn') {
|
||||
if (m.includes('sharp left')) return pick('sharpLeft', 'Sharp left');
|
||||
if (m.includes('sharp right')) return pick('sharpRight', 'Sharp right');
|
||||
if (m.includes('slight left')) return pick('bearLeft', 'Bear left');
|
||||
if (m.includes('slight right')) return pick('bearRight', 'Bear right');
|
||||
if (m.includes('left')) return pick('turnLeft', 'Turn left');
|
||||
if (m.includes('right')) return pick('turnRight', 'Turn right');
|
||||
return pick('turn', 'Turn');
|
||||
}
|
||||
if (t === 'fork') {
|
||||
return m.includes('left')
|
||||
? pick('keepLeft', 'Keep left')
|
||||
: m.includes('right')
|
||||
? pick('keepRight', 'Keep right')
|
||||
: pick('takeFork', 'Take the fork');
|
||||
}
|
||||
if (t === 'merge') return pick('merge', 'Merge');
|
||||
if (t === 'roundabout' || t === 'rotary') return pick('roundabout', 'Take the roundabout');
|
||||
if (t === 'end of road') {
|
||||
return m.includes('left') ? pick('turnLeft', 'Turn left') : pick('turnRight', 'Turn right');
|
||||
}
|
||||
if (t === 'on ramp') return pick('takeRamp', 'Take the ramp');
|
||||
if (t === 'off ramp') return pick('takeExit', 'Take the exit');
|
||||
// Unknown OSRM code — keep it raw (feed-ish data, not app copy).
|
||||
return { token: null, en: t.replace(/\b\w/g, (c) => c.toUpperCase()) };
|
||||
}
|
||||
|
||||
function parseSteps(data: OsrmResponse): RouteStep[] {
|
||||
|
|
@ -225,11 +254,14 @@ function parseSteps(data: OsrmResponse): RouteStep[] {
|
|||
const steps: RouteStep[] = [];
|
||||
for (const leg of route.legs) {
|
||||
for (const s of leg.steps ?? []) {
|
||||
const label = maneuverLabel(s.maneuver?.type ?? 'continue', s.maneuver?.modifier ?? '');
|
||||
const { token, en } = maneuverDescriptor(s.maneuver?.type ?? 'continue', s.maneuver?.modifier ?? '');
|
||||
const road = (s.name ?? '').trim();
|
||||
const loc = s.maneuver?.location;
|
||||
const takesRoad = token !== null && token !== 'arrive' && !!road;
|
||||
steps.push({
|
||||
instruction: road && !label.startsWith('Arrive') ? `${label} onto ${road}` : label,
|
||||
instruction: takesRoad ? `${en} onto ${road}` : en,
|
||||
label: token ?? undefined,
|
||||
road: takesRoad ? road : undefined,
|
||||
distanceM: s.distance ?? 0,
|
||||
lat: loc?.[1] ?? 0,
|
||||
lng: loc?.[0] ?? 0,
|
||||
|
|
@ -546,23 +578,24 @@ export function formatDuration(seconds: number): string {
|
|||
* Skaters think in time, not distance. Estimate a cruising time from a
|
||||
* straight-line distance (km) at a relaxed ~10 km/h push, and phrase it in a
|
||||
* skate-native way. No routing call needed — pure formula, works offline.
|
||||
* `t` is passed in by the caller (this lib has no React context).
|
||||
*/
|
||||
export function skateTime(km: number): string {
|
||||
export function skateTime(km: number, t: TranslateFn): string {
|
||||
if (!isFinite(km) || km < 0) return '';
|
||||
const CRUISE_KMH = 10; // relaxed cruising pace
|
||||
const mins = Math.round((km / CRUISE_KMH) * 60);
|
||||
if (mins < 1) return 'right here';
|
||||
if (mins < 60) return `~${mins} min cruise`;
|
||||
if (mins < 1) return t('routeUi.skateRightHere');
|
||||
if (mins < 60) return t('routeUi.skateMin', { count: mins });
|
||||
const h = (mins / 60).toFixed(1);
|
||||
return `~${h} h cruise`;
|
||||
return t('routeUi.skateHours', { hours: h });
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact one-line readout for cards/popups given a distance in km.
|
||||
* e.g. "1.2 km · ~7 min cruise"
|
||||
*/
|
||||
export function distanceReadout(km: number): string {
|
||||
export function distanceReadout(km: number, t: TranslateFn): string {
|
||||
const d = formatDistance(km);
|
||||
const t = skateTime(km);
|
||||
return t ? `${d} · ${t}` : d;
|
||||
const time = skateTime(km, t);
|
||||
return time ? `${d} · ${time}` : d;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,20 +25,25 @@ export interface ProProof {
|
|||
|
||||
export interface ProTier {
|
||||
id: string;
|
||||
/** English label — kept as a non-UI fallback. Prefer `labelKey` + `t`. */
|
||||
label: string;
|
||||
/** `sasquatchUi.tier*` literal key for UI rendering. */
|
||||
labelKey: string;
|
||||
days: number;
|
||||
sats: number;
|
||||
/** Marketing flag. */
|
||||
/** English marketing flag — kept as a non-UI fallback. Prefer `badgeKey`. */
|
||||
badge?: string;
|
||||
/** `sasquatchUi.badge*` literal key, when a badge exists. */
|
||||
badgeKey?: string;
|
||||
popular?: boolean;
|
||||
}
|
||||
|
||||
/** The four supporter tiers. Kept deliberately small to avoid choice paralysis. */
|
||||
export const PRO_TIERS: ProTier[] = [
|
||||
{ id: '7d', label: '7 days', days: 7, sats: 2000, badge: 'Try it' },
|
||||
{ id: '1m', label: '1 month', days: 30, sats: 5000 },
|
||||
{ id: '3m', label: '3 months', days: 90, sats: 12000, badge: '20% off', popular: true },
|
||||
{ id: '1y', label: '1 year', days: 365, sats: 36000, badge: 'Best value · 40% off' },
|
||||
{ id: '7d', label: '7 days', labelKey: 'sasquatchUi.tier7d', days: 7, sats: 2000, badge: 'Try it', badgeKey: 'sasquatchUi.badgeTry' },
|
||||
{ id: '1m', label: '1 month', labelKey: 'sasquatchUi.tier1m', days: 30, sats: 5000 },
|
||||
{ id: '3m', label: '3 months', labelKey: 'sasquatchUi.tier3m', days: 90, sats: 12000, badge: '20% off', badgeKey: 'sasquatchUi.badgeOff20', popular: true },
|
||||
{ id: '1y', label: '1 year', labelKey: 'sasquatchUi.tier1y', days: 365, sats: 36000, badge: 'Best value · 40% off', badgeKey: 'sasquatchUi.badgeBest' },
|
||||
];
|
||||
|
||||
export function tierById(id: string): ProTier | undefined {
|
||||
|
|
|
|||
|
|
@ -11,14 +11,20 @@
|
|||
|
||||
export interface TagCategory {
|
||||
name: string;
|
||||
/** `labelsUi.tagCategory.*` translation key for `name`. */
|
||||
nameKey: string;
|
||||
description: string;
|
||||
/** `labelsUi.tagCategory.*` translation key for `description`. */
|
||||
descriptionKey: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
// Feature tags - obstacles and terrain types
|
||||
export const featureTags: TagCategory = {
|
||||
name: 'Features',
|
||||
nameKey: 'labelsUi.tagCategory.features.name',
|
||||
description: 'Obstacles and terrain',
|
||||
descriptionKey: 'labelsUi.tagCategory.features.description',
|
||||
tags: [
|
||||
// Ledges & Manual Pads
|
||||
'ledge',
|
||||
|
|
@ -110,7 +116,9 @@ export const featureTags: TagCategory = {
|
|||
// Surface tags - materials and textures
|
||||
export const surfaceTags: TagCategory = {
|
||||
name: 'Surfaces',
|
||||
nameKey: 'labelsUi.tagCategory.surfaces.name',
|
||||
description: 'Materials and textures',
|
||||
descriptionKey: 'labelsUi.tagCategory.surfaces.description',
|
||||
tags: [
|
||||
'concrete',
|
||||
'marble',
|
||||
|
|
@ -139,7 +147,9 @@ export const surfaceTags: TagCategory = {
|
|||
// Size tags - dimensions and scale
|
||||
export const sizeTags: TagCategory = {
|
||||
name: 'Sizes',
|
||||
nameKey: 'labelsUi.tagCategory.sizes.name',
|
||||
description: 'Dimensions and scale',
|
||||
descriptionKey: 'labelsUi.tagCategory.sizes.description',
|
||||
tags: [
|
||||
'small',
|
||||
'medium',
|
||||
|
|
@ -163,7 +173,9 @@ export const sizeTags: TagCategory = {
|
|||
// Stair count tags
|
||||
export const stairTags: TagCategory = {
|
||||
name: 'Stair Counts',
|
||||
nameKey: 'labelsUi.tagCategory.stairs.name',
|
||||
description: 'Number of stairs',
|
||||
descriptionKey: 'labelsUi.tagCategory.stairs.description',
|
||||
tags: [
|
||||
'2-stair',
|
||||
'3-stair',
|
||||
|
|
@ -185,7 +197,9 @@ export const stairTags: TagCategory = {
|
|||
// Vibe & condition tags
|
||||
export const vibeTags: TagCategory = {
|
||||
name: 'Vibes',
|
||||
nameKey: 'labelsUi.tagCategory.vibes.name',
|
||||
description: 'Atmosphere and condition',
|
||||
descriptionKey: 'labelsUi.tagCategory.vibes.description',
|
||||
tags: [
|
||||
// Fame & History
|
||||
'famous',
|
||||
|
|
@ -258,7 +272,9 @@ export const vibeTags: TagCategory = {
|
|||
// Location context tags
|
||||
export const locationTags: TagCategory = {
|
||||
name: 'Location',
|
||||
nameKey: 'labelsUi.tagCategory.location.name',
|
||||
description: 'Location context',
|
||||
descriptionKey: 'labelsUi.tagCategory.location.description',
|
||||
tags: [
|
||||
'downtown',
|
||||
'city-center',
|
||||
|
|
@ -294,7 +310,9 @@ export const locationTags: TagCategory = {
|
|||
// Trick-specific tags
|
||||
export const trickTags: TagCategory = {
|
||||
name: 'Tricks',
|
||||
nameKey: 'labelsUi.tagCategory.tricks.name',
|
||||
description: 'Good for specific tricks',
|
||||
descriptionKey: 'labelsUi.tagCategory.tricks.description',
|
||||
tags: [
|
||||
'grind-spot',
|
||||
'slide-spot',
|
||||
|
|
|
|||
|
|
@ -1,13 +1,19 @@
|
|||
import type { SpotStatusValue } from '@/lib/types';
|
||||
import { formatDistanceToNow } from '@/lib/dateFnsI18n';
|
||||
import { STATUS_KEYS, STATUS_DESC_KEYS } from '@/lib/labelKeys';
|
||||
|
||||
export interface SpotStatusMeta {
|
||||
value: SpotStatusValue;
|
||||
/** Short human label. */
|
||||
/** Short human label (English fallback — translate via `labelKey`). */
|
||||
label: string;
|
||||
/** `labelsUi.status.*` translation key for `label`. */
|
||||
labelKey: string;
|
||||
/** Emoji used in compact contexts. */
|
||||
emoji: string;
|
||||
/** One-line description for pickers. */
|
||||
/** One-line description for pickers (English fallback — translate via `descriptionKey`). */
|
||||
description: string;
|
||||
/** `labelsUi.status.*` translation key for `description`. */
|
||||
descriptionKey: string;
|
||||
/** Tailwind classes for badge text + background + border. */
|
||||
badgeClass: string;
|
||||
/** Hex dot color for map/legend use. */
|
||||
|
|
@ -18,40 +24,50 @@ export const SPOT_STATUS_META: Record<SpotStatusValue, SpotStatusMeta> = {
|
|||
active: {
|
||||
value: 'active',
|
||||
label: 'Active',
|
||||
labelKey: STATUS_KEYS.active,
|
||||
emoji: '🟢',
|
||||
description: 'Good to go — skate it.',
|
||||
descriptionKey: STATUS_DESC_KEYS.active,
|
||||
badgeClass: 'text-green-400 bg-green-500/10 border-green-500/30',
|
||||
dot: '#22c55e',
|
||||
},
|
||||
bust: {
|
||||
value: 'bust',
|
||||
label: 'Bust',
|
||||
labelKey: STATUS_KEYS.bust,
|
||||
emoji: '🟡',
|
||||
description: 'Heavy kick-out risk — security or cops.',
|
||||
descriptionKey: STATUS_DESC_KEYS.bust,
|
||||
badgeClass: 'text-yellow-400 bg-yellow-500/10 border-yellow-500/30',
|
||||
dot: '#eab308',
|
||||
},
|
||||
skate_stopped: {
|
||||
value: 'skate_stopped',
|
||||
label: 'Skate-stopped',
|
||||
labelKey: STATUS_KEYS.skate_stopped,
|
||||
emoji: '🟠',
|
||||
description: 'Knobbed / capped, but partially skateable.',
|
||||
descriptionKey: STATUS_DESC_KEYS.skate_stopped,
|
||||
badgeClass: 'text-orange-400 bg-orange-500/10 border-orange-500/30',
|
||||
dot: '#f97316',
|
||||
},
|
||||
closed: {
|
||||
value: 'closed',
|
||||
label: 'Closed',
|
||||
labelKey: STATUS_KEYS.closed,
|
||||
emoji: '⚪',
|
||||
description: 'Temporarily closed — construction or event.',
|
||||
descriptionKey: STATUS_DESC_KEYS.closed,
|
||||
badgeClass: 'text-gray-300 bg-gray-500/10 border-gray-500/30',
|
||||
dot: '#9ca3af',
|
||||
},
|
||||
gone: {
|
||||
value: 'gone',
|
||||
label: 'Gone',
|
||||
labelKey: STATUS_KEYS.gone,
|
||||
emoji: '🔴',
|
||||
description: 'Demolished, paved over, or fenced off for good.',
|
||||
descriptionKey: STATUS_DESC_KEYS.gone,
|
||||
badgeClass: 'text-red-400 bg-red-500/10 border-red-500/40',
|
||||
dot: '#ef4444',
|
||||
},
|
||||
|
|
@ -66,22 +82,10 @@ export const SPOT_STATUS_ORDER: SpotStatusValue[] = [
|
|||
'gone',
|
||||
];
|
||||
|
||||
/** Human "time ago" for a unix-seconds timestamp. */
|
||||
/**
|
||||
* Human "time ago" for a unix-seconds timestamp, in the active app language
|
||||
* (via the locale-aware `dateFnsI18n` shim — "3 days ago" / "hace 3 días").
|
||||
*/
|
||||
export function statusTimeAgo(unixSeconds: number): string {
|
||||
const diff = Math.max(0, Math.floor(Date.now() / 1000) - unixSeconds);
|
||||
const days = Math.floor(diff / 86400);
|
||||
if (days >= 365) {
|
||||
const y = Math.floor(days / 365);
|
||||
return `${y}y ago`;
|
||||
}
|
||||
if (days >= 30) {
|
||||
const m = Math.floor(days / 30);
|
||||
return `${m}mo ago`;
|
||||
}
|
||||
if (days >= 1) return `${days}d ago`;
|
||||
const hours = Math.floor(diff / 3600);
|
||||
if (hours >= 1) return `${hours}h ago`;
|
||||
const mins = Math.floor(diff / 60);
|
||||
if (mins >= 1) return `${mins}m ago`;
|
||||
return 'just now';
|
||||
return formatDistanceToNow(new Date(unixSeconds * 1000), { addSuffix: true });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,31 +1,16 @@
|
|||
import { SPOT_TYPE_LABELS } from '@/lib/types';
|
||||
import { SPOT_TYPE_KEYS } from '@/lib/labelKeys';
|
||||
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
/**
|
||||
* Spot-type keys that have a translated label under `chromeUi.spotType.*`.
|
||||
* Anything else (feature tags like `wallrides`, legacy values) falls back to
|
||||
* the English label so map popups never show a raw tag string.
|
||||
*/
|
||||
const TRANSLATED_SPOT_TYPES = new Set([
|
||||
'street',
|
||||
'transition',
|
||||
'park',
|
||||
'plaza',
|
||||
'diy',
|
||||
'bowl',
|
||||
'vert',
|
||||
'indoor',
|
||||
'skatepark',
|
||||
'miniramp',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Localised label for a spot type/category key. Translates the ten spot-type
|
||||
* keys; every other key keeps its English `SPOT_TYPE_LABELS` label (or the key
|
||||
* itself as a last resort).
|
||||
* Localised label for a spot type/category key. Every key with a
|
||||
* `labelsUi.spotType.*` translation is translated; anything else (legacy values)
|
||||
* falls back to the English `SPOT_TYPE_LABELS` label, or the key itself as a
|
||||
* last resort, so map popups never show a raw tag string.
|
||||
*/
|
||||
export function spotTypeLabel(t: Translate, key: string): string {
|
||||
if (TRANSLATED_SPOT_TYPES.has(key)) return t(`chromeUi.spotType.${key}`);
|
||||
const labelKey = SPOT_TYPE_KEYS[key];
|
||||
if (labelKey) return t(labelKey);
|
||||
return SPOT_TYPE_LABELS[key] ?? key;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import ngeohash from 'ngeohash';
|
|||
import { countLeadingZeroBits } from '@/lib/pow';
|
||||
import { parseCrewVideo } from '@/lib/videoEmbed';
|
||||
import { isAdminPubkey } from '@/lib/admins';
|
||||
import { SPOT_FEATURE_KEYS } from '@/lib/labelKeys';
|
||||
|
||||
// Community relay for all skate-specific content (spots, events, coaches, shops, etc.)
|
||||
// This ensures all users see the same content regardless of their personal relay settings
|
||||
|
|
@ -1232,18 +1233,18 @@ export const SPOT_FEATURES = {
|
|||
} as const;
|
||||
|
||||
// Get features available for a category
|
||||
export function getFeaturesForCategory(category: string): Array<{ key: string; label: string; emoji: string }> {
|
||||
export function getFeaturesForCategory(category: string): Array<{ key: string; label: string; labelKey: string; emoji: string }> {
|
||||
const allStreetFeatures = Object.entries(SPOT_FEATURES)
|
||||
.filter(([, v]) => v.category === 'street')
|
||||
.map(([key, v]) => ({ key, label: v.label, emoji: v.emoji }));
|
||||
.map(([key, v]) => ({ key, label: v.label, labelKey: SPOT_FEATURE_KEYS[key], emoji: v.emoji }));
|
||||
|
||||
const allTransitionFeatures = Object.entries(SPOT_FEATURES)
|
||||
.filter(([, v]) => v.category === 'transition')
|
||||
.map(([key, v]) => ({ key, label: v.label, emoji: v.emoji }));
|
||||
.map(([key, v]) => ({ key, label: v.label, labelKey: SPOT_FEATURE_KEYS[key], emoji: v.emoji }));
|
||||
|
||||
const allParkFeatures = Object.entries(SPOT_FEATURES)
|
||||
.filter(([, v]) => v.category === 'park')
|
||||
.map(([key, v]) => ({ key, label: v.label, emoji: v.emoji }));
|
||||
.map(([key, v]) => ({ key, label: v.label, labelKey: SPOT_FEATURE_KEYS[key], emoji: v.emoji }));
|
||||
|
||||
switch (category) {
|
||||
case 'street':
|
||||
|
|
@ -1260,10 +1261,11 @@ export function getFeaturesForCategory(category: string): Array<{ key: string; l
|
|||
}
|
||||
|
||||
// Get all features as flat list (for filters)
|
||||
export function getAllFeatures(): Array<{ key: string; label: string; emoji: string; category: string }> {
|
||||
export function getAllFeatures(): Array<{ key: string; label: string; labelKey: string; emoji: string; category: string }> {
|
||||
return Object.entries(SPOT_FEATURES).map(([key, v]) => ({
|
||||
key,
|
||||
label: v.label,
|
||||
labelKey: SPOT_FEATURE_KEYS[key],
|
||||
emoji: v.emoji,
|
||||
category: v.category,
|
||||
}));
|
||||
|
|
@ -3533,12 +3535,16 @@ export function getCurrencySymbol(code: string): string {
|
|||
}
|
||||
|
||||
// Helper to format price with correct currency symbol
|
||||
export function formatPrice(price: { amount: string; currency: string } | undefined, priceType?: string): string {
|
||||
export function formatPrice(
|
||||
price: { amount: string; currency: string } | undefined,
|
||||
priceType?: string,
|
||||
t?: (key: string) => string,
|
||||
): string {
|
||||
if (!price) {
|
||||
if (priceType === 'free') return 'FREE';
|
||||
if (priceType === 'trade') return 'Trade';
|
||||
if (priceType === 'offer') return 'Make Offer';
|
||||
return 'Offer';
|
||||
if (priceType === 'free') return t ? t('labelsUi.price.free') : 'FREE';
|
||||
if (priceType === 'trade') return t ? t('labelsUi.price.trade') : 'Trade';
|
||||
if (priceType === 'offer') return t ? t('labelsUi.price.makeOffer') : 'Make Offer';
|
||||
return t ? t('labelsUi.price.offer') : 'Offer';
|
||||
}
|
||||
|
||||
const symbol = getCurrencySymbol(price.currency);
|
||||
|
|
|
|||
|
|
@ -247,7 +247,7 @@ export default function CommunityStatsPage() {
|
|||
{t('walletUi.stats.dataSource')}
|
||||
<span className="text-[#ccff00] font-mono">{SKATE_RELAY.replace('wss://', '')}</span>
|
||||
{dataUpdatedAt > 0 && (
|
||||
<><span aria-hidden>·</span><span>{t('walletUi.stats.updated', { age: formatAge(Date.now() - dataUpdatedAt) })}</span></>
|
||||
<><span aria-hidden>·</span><span>{t('walletUi.stats.updated', { age: formatAge(Date.now() - dataUpdatedAt, t) })}</span></>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -47,16 +47,7 @@ import { Link } from 'react-router-dom';
|
|||
|
||||
const EVENT_TYPES = Object.entries(EVENT_TYPE_LABELS);
|
||||
|
||||
/** Map event type keys to translation keys (types live in lib/types). */
|
||||
const EVENT_TYPE_KEYS: Record<string, string> = {
|
||||
contest: 'placesUi.eventTypes.contest',
|
||||
session: 'placesUi.eventTypes.session',
|
||||
meetup: 'placesUi.eventTypes.meetup',
|
||||
premiere: 'placesUi.eventTypes.premiere',
|
||||
workshop: 'placesUi.eventTypes.workshop',
|
||||
demo: 'placesUi.eventTypes.demo',
|
||||
jam: 'placesUi.eventTypes.jam',
|
||||
};
|
||||
import { EVENT_TYPE_KEYS, SPOT_CATEGORY_KEYS, TOUR_CATEGORY_KEYS } from '@/lib/labelKeys';
|
||||
|
||||
function eventTypeLabel(t: (key: string) => string, type: string): string {
|
||||
return EVENT_TYPE_KEYS[type] ? t(EVENT_TYPE_KEYS[type]) : (EVENT_TYPE_LABELS[type] || type);
|
||||
|
|
@ -702,7 +693,7 @@ export default function CreateEventPage() {
|
|||
<div className="flex-1 min-w-0">
|
||||
{selectedSpot.category && SPOT_CATEGORIES[selectedSpot.category as keyof typeof SPOT_CATEGORIES] && (
|
||||
<Badge variant="outline" className="mb-1 text-[10px] border-[#ff0040]/50 text-[#ff0040]">
|
||||
{SPOT_CATEGORIES[selectedSpot.category as keyof typeof SPOT_CATEGORIES].emoji} {SPOT_CATEGORIES[selectedSpot.category as keyof typeof SPOT_CATEGORIES].label}
|
||||
{SPOT_CATEGORIES[selectedSpot.category as keyof typeof SPOT_CATEGORIES].emoji} {SPOT_CATEGORY_KEYS[selectedSpot.category] ? t(SPOT_CATEGORY_KEYS[selectedSpot.category]) : SPOT_CATEGORIES[selectedSpot.category as keyof typeof SPOT_CATEGORIES].label}
|
||||
</Badge>
|
||||
)}
|
||||
<h4 className="font-bold text-white text-sm">{selectedSpot.title}</h4>
|
||||
|
|
@ -757,7 +748,7 @@ export default function CreateEventPage() {
|
|||
return (
|
||||
<div key={category}>
|
||||
<div className="px-2 py-1.5 text-xs font-bold text-gray-400 uppercase flex items-center gap-1 sticky top-0 bg-black/80 backdrop-blur-sm">
|
||||
{catInfo?.emoji || '🛹'} {catInfo?.label || category} ({categorySpots.length})
|
||||
{catInfo?.emoji || '🛹'} {SPOT_CATEGORY_KEYS[category] ? t(SPOT_CATEGORY_KEYS[category]) : (catInfo?.label || category)} ({categorySpots.length})
|
||||
</div>
|
||||
{categorySpots.map((spot) => (
|
||||
<button
|
||||
|
|
@ -999,7 +990,7 @@ export default function CreateEventPage() {
|
|||
color: TOUR_CATEGORIES[selectedTour.category]?.color
|
||||
}}
|
||||
>
|
||||
{TOUR_CATEGORIES[selectedTour.category]?.emoji} {TOUR_CATEGORIES[selectedTour.category]?.label}
|
||||
{TOUR_CATEGORIES[selectedTour.category]?.emoji} {selectedTour.category && TOUR_CATEGORY_KEYS[selectedTour.category] ? t(TOUR_CATEGORY_KEYS[selectedTour.category]) : TOUR_CATEGORIES[selectedTour.category]?.label}
|
||||
</Badge>
|
||||
<h4 className="font-bold text-white text-sm">{selectedTour.title}</h4>
|
||||
{selectedTour.location && (
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { LoginArea } from '@/components/auth/LoginArea';
|
|||
import { Nip05Required } from '@/components/Nip05Required';
|
||||
import { SpamFeePayment } from '@/components/SpamFeePayment';
|
||||
import { MARKETPLACE_ITEM_KIND, GEAR_CATEGORIES, GEAR_CONDITIONS, PRICE_TYPES, SIZE_OPTIONS, TRUCK_HEIGHTS, WHEEL_HARDNESS, EXPIRATION_OPTIONS, CURRENCY_OPTIONS, CATEGORY_REQUIRED_FIELDS } from '@/lib/types';
|
||||
import { GEAR_CATEGORY_KEYS, GEAR_CONDITION_KEYS, GEAR_CONDITION_DESC_KEYS, PRICE_TYPE_KEYS, PRICE_TYPE_DESC_KEYS } from '@/lib/labelKeys';
|
||||
import { ShoppingBag, Upload, X, Loader2, ArrowLeft, MapPin, AlertTriangle, CheckCircle, Search, Clock, Shield, Zap } from 'lucide-react';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
|
|
@ -237,7 +238,7 @@ export default function CreateListingPage() {
|
|||
if (requiresSize && !size) {
|
||||
toast({
|
||||
title: t('marketUi.create.sizeRequired'),
|
||||
description: t('marketUi.create.sizeRequiredBody', { category: GEAR_CATEGORIES[category as keyof typeof GEAR_CATEGORIES] }),
|
||||
description: t('marketUi.create.sizeRequiredBody', { category: GEAR_CATEGORY_KEYS[category] ? t(GEAR_CATEGORY_KEYS[category]) : GEAR_CATEGORIES[category as keyof typeof GEAR_CATEGORIES] }),
|
||||
variant: 'destructive'
|
||||
});
|
||||
return;
|
||||
|
|
@ -246,7 +247,7 @@ export default function CreateListingPage() {
|
|||
if (requiresCondition && !condition) {
|
||||
toast({
|
||||
title: t('marketUi.create.conditionRequired'),
|
||||
description: t('marketUi.create.conditionRequiredBody', { category: GEAR_CATEGORIES[category as keyof typeof GEAR_CATEGORIES] }),
|
||||
description: t('marketUi.create.conditionRequiredBody', { category: GEAR_CATEGORY_KEYS[category] ? t(GEAR_CATEGORY_KEYS[category]) : GEAR_CATEGORIES[category as keyof typeof GEAR_CATEGORIES] }),
|
||||
variant: 'destructive'
|
||||
});
|
||||
return;
|
||||
|
|
@ -596,7 +597,7 @@ export default function CreateListingPage() {
|
|||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2">
|
||||
{Object.entries(GEAR_CATEGORIES).map(([key, label]) => (
|
||||
{Object.entries(GEAR_CATEGORIES).map(([key]) => (
|
||||
<Button
|
||||
key={key}
|
||||
type="button"
|
||||
|
|
@ -614,7 +615,7 @@ export default function CreateListingPage() {
|
|||
setWheelHardness('');
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
{GEAR_CATEGORY_KEYS[key] ? t(GEAR_CATEGORY_KEYS[key]) : key}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -687,9 +688,9 @@ export default function CreateListingPage() {
|
|||
onClick={() => setCondition(key)}
|
||||
>
|
||||
<span className="font-bold">
|
||||
{emoji} {label}
|
||||
{emoji} {GEAR_CONDITION_KEYS[key] ? t(GEAR_CONDITION_KEYS[key]) : label}
|
||||
</span>
|
||||
<span className="text-xs opacity-70">{description}</span>
|
||||
<span className="text-xs opacity-70">{GEAR_CONDITION_DESC_KEYS[key] ? t(GEAR_CONDITION_DESC_KEYS[key]) : description}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -835,8 +836,8 @@ export default function CreateListingPage() {
|
|||
}`}
|
||||
onClick={() => setPriceType(key)}
|
||||
>
|
||||
<span className="font-bold">{label}</span>
|
||||
<span className="text-xs opacity-70">{description}</span>
|
||||
<span className="font-bold">{PRICE_TYPE_KEYS[key] ? t(PRICE_TYPE_KEYS[key]) : label}</span>
|
||||
<span className="text-xs opacity-70">{PRICE_TYPE_DESC_KEYS[key] ? t(PRICE_TYPE_DESC_KEYS[key]) : description}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { TagInput } from '@/components/TagInput';
|
|||
import { SKATE_SPOT_KIND } from '@/lib/types';
|
||||
import { reverseGeocodeRegions, pushRegionTags } from '@/lib/geocodeRegions';
|
||||
import { spotCategories, MAX_TAGS, shelterOptions, DEFAULT_SHELTER, SHELTERED_VALUES, type ShelterType } from '@/lib/skateTags';
|
||||
import { SPOT_TAG_CATEGORY_KEYS, SPOT_TAG_CATEGORY_DESC_KEYS, SHELTER_KEYS, SHELTER_DESC_KEYS } from '@/lib/labelKeys';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
MapPin,
|
||||
|
|
@ -648,8 +649,8 @@ export default function CreateSpotPage() {
|
|||
onClick={() => handleCategoryChange(category.value)}
|
||||
>
|
||||
<CardContent className="p-3 sm:p-4 text-center relative">
|
||||
<div className="font-bold text-sm sm:text-base">{category.label}</div>
|
||||
<div className="text-xs text-gray-400 mt-1">{category.description}</div>
|
||||
<div className="font-bold text-sm sm:text-base">{SPOT_TAG_CATEGORY_KEYS[category.value] ? t(SPOT_TAG_CATEGORY_KEYS[category.value]) : category.label}</div>
|
||||
<div className="text-xs text-gray-400 mt-1">{SPOT_TAG_CATEGORY_DESC_KEYS[category.value] ? t(SPOT_TAG_CATEGORY_DESC_KEYS[category.value]) : category.description}</div>
|
||||
{selectedCategory === category.value && (
|
||||
<div className="absolute top-1 right-1 w-4 h-4 sm:w-5 sm:h-5 rounded-full bg-[#ff0040] flex items-center justify-center">
|
||||
<Check className="h-2.5 w-2.5 sm:h-3 sm:w-3 text-white" />
|
||||
|
|
@ -683,8 +684,8 @@ export default function CreateSpotPage() {
|
|||
<div className="flex items-start gap-2.5">
|
||||
<div className="text-xl leading-none mt-0.5">{option.emoji}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-bold text-sm sm:text-base text-white">{option.label}</div>
|
||||
<div className="text-xs text-gray-400 mt-0.5">{option.description}</div>
|
||||
<div className="font-bold text-sm sm:text-base text-white">{SHELTER_KEYS[option.value] ? t(SHELTER_KEYS[option.value]) : option.label}</div>
|
||||
<div className="text-xs text-gray-400 mt-0.5">{SHELTER_DESC_KEYS[option.value] ? t(SHELTER_DESC_KEYS[option.value]) : option.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
{shelter === option.value && (
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { Nip05Required } from '@/components/Nip05Required';
|
|||
import { FeaturedUpgrade } from '@/components/FeaturedEventUpgrade';
|
||||
import { usePlatformFee } from '@/hooks/usePlatformFee';
|
||||
import { SKATE_TOUR_KIND, TOUR_SKILL_LEVELS, TOUR_SPOT_TYPES, TOUR_CATEGORIES, type TourCategory, type DestructionChallenge, type SkateTour } from '@/lib/types';
|
||||
import { TOUR_CATEGORY_KEYS, TOUR_CATEGORY_DESC_KEYS, TOUR_SPOT_TYPE_KEYS } from '@/lib/labelKeys';
|
||||
import { Route, Upload, X, Loader2, ArrowLeft, MapPin, Clock, Users, Zap, Globe, Info, ChevronDown, Store, Percent, Plane, Car, Skull, Trophy, Target, Plus, Trash2, Calendar, EyeOff } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
|
@ -947,7 +948,7 @@ export default function CreateTourPage({ existing }: { existing?: SkateTour } =
|
|||
className="text-[11px] font-black uppercase tracking-[0.25em]"
|
||||
style={{ color: TOUR_CATEGORIES[tourCategory].color }}
|
||||
>
|
||||
{isEditing ? t('common.edit') : t('common.new')} · {TOUR_CATEGORIES[tourCategory].label}
|
||||
{isEditing ? t('common.edit') : t('common.new')} · {TOUR_CATEGORY_KEYS[tourCategory] ? t(TOUR_CATEGORY_KEYS[tourCategory]) : TOUR_CATEGORIES[tourCategory].label}
|
||||
</p>
|
||||
<h1
|
||||
className="text-3xl sm:text-4xl font-black uppercase leading-none"
|
||||
|
|
@ -960,7 +961,7 @@ export default function CreateTourPage({ existing }: { existing?: SkateTour } =
|
|||
{isEditing ? t('placesUi.createTour.editTour') : t('placesUi.createTour.dropTour')}
|
||||
</h1>
|
||||
<p className="text-gray-400 mt-1 text-sm">
|
||||
{TOUR_CATEGORIES[tourCategory].description}
|
||||
{TOUR_CATEGORY_DESC_KEYS[tourCategory] ? t(TOUR_CATEGORY_DESC_KEYS[tourCategory]) : TOUR_CATEGORIES[tourCategory].description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1425,7 +1426,7 @@ export default function CreateTourPage({ existing }: { existing?: SkateTour } =
|
|||
style={spotTypes.includes(key) ? { backgroundColor: TOUR_CATEGORIES[tourCategory].color } : undefined}
|
||||
>
|
||||
<span>{type.emoji}</span>
|
||||
<span>{type.label}</span>
|
||||
<span>{TOUR_SPOT_TYPE_KEYS[key] ? t(TOUR_SPOT_TYPE_KEYS[key]) : type.label}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import { useAuthor } from '@/hooks/useAuthor';
|
|||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { SKATE_CREW_KIND, SHOW_CREW_TREASURY, CREW_LOOKING_FOR_LABELS, type CrewRole } from '@/lib/types';
|
||||
import { CREW_LOOKING_FOR_KEYS } from '@/lib/labelKeys';
|
||||
import { sanitizeUrl, urlDomain } from '@/lib/sanitizeUrl';
|
||||
import NotFound from './NotFound';
|
||||
import { Users, MapPin, ArrowLeft, Calendar, Globe, Instagram, Edit, MessageSquare, Zap, ShieldCheck, Music } from 'lucide-react';
|
||||
|
|
@ -550,7 +551,7 @@ export default function CrewDetailPage() {
|
|||
vibes: crew.vibes,
|
||||
founderName,
|
||||
memberCount: 1 + crew.members.length,
|
||||
lookingFor: crew.lookingFor.map((s) => CREW_LOOKING_FOR_LABELS[s]?.label ?? s),
|
||||
lookingFor: crew.lookingFor.map((s) => CREW_LOOKING_FOR_KEYS[s] ? t(CREW_LOOKING_FOR_KEYS[s]) : (CREW_LOOKING_FOR_LABELS[s]?.label ?? s)),
|
||||
bandName: crew.bandName,
|
||||
homeSpotCount: crew.spotRefs.length,
|
||||
website: crew.website,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ import { useCrews } from '@/hooks/useCrews';
|
|||
import { useCrewsActivity, crewCoordinate } from '@/hooks/useCrewActivity';
|
||||
import { useGeolocation } from '@/hooks/useGeolocation';
|
||||
import { crewAccent, crewDistanceKm, formatDistance, type LatLng } from '@/lib/crewHelpers';
|
||||
import { CREW_VIBE_LABELS, CREW_FOCUS_LABELS, type SkateCrew, type CrewFocus } from '@/lib/types';
|
||||
import type { SkateCrew, CrewFocus } from '@/lib/types';
|
||||
import { CREW_VIBE_KEYS, CREW_FOCUS_KEYS } from '@/lib/labelKeys';
|
||||
import type { CrewActivity } from '@/lib/crewHelpers';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Users, Plus, Search, MapPin, X, HardHat, Camera, GraduationCap, Sparkles, Navigation, Loader2, Music, Palette, Dumbbell, Film, ChevronDown, Check } from 'lucide-react';
|
||||
|
|
@ -99,7 +100,7 @@ function CrewCard({
|
|||
{FocusIcon && (
|
||||
<span className="inline-flex max-w-full items-center gap-1 truncate rounded-full border border-white/10 bg-white/[0.06] px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-gray-300">
|
||||
<FocusIcon className="h-2.5 w-2.5 shrink-0" />
|
||||
{CREW_FOCUS_LABELS[crew.focus]}
|
||||
{CREW_FOCUS_KEYS[crew.focus] ? t(CREW_FOCUS_KEYS[crew.focus]) : crew.focus}
|
||||
</span>
|
||||
)}
|
||||
{crew.videoUrl && (
|
||||
|
|
@ -126,7 +127,7 @@ function CrewCard({
|
|||
className="inline-flex max-w-full items-center truncate rounded-full border px-2 py-0.5 text-[10px] font-bold"
|
||||
style={{ color: accent.hex, borderColor: `${accent.hex}55`, background: `${accent.hex}18` }}
|
||||
>
|
||||
{CREW_VIBE_LABELS[v] || v}
|
||||
{CREW_VIBE_KEYS[v] ? t(CREW_VIBE_KEYS[v]) : v}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -152,13 +153,13 @@ export default function CrewsPage() {
|
|||
// Remembered preference: compact short labels vs. the full filter names.
|
||||
const FOCUS_FILTERS: { key: CrewFocus | 'all'; label: string; short: string; icon: typeof Users }[] = [
|
||||
{ key: 'all', label: t('socialUi.crews.focusAll'), short: t('socialUi.crews.focusAllShort'), icon: Users },
|
||||
{ key: 'builder', label: CREW_FOCUS_LABELS.builder, short: t('socialUi.crews.focusDiy'), icon: HardHat },
|
||||
{ key: 'photo', label: CREW_FOCUS_LABELS.photo, short: t('socialUi.crews.focusPhoto'), icon: Camera },
|
||||
{ key: 'coaching', label: CREW_FOCUS_LABELS.coaching, short: t('socialUi.crews.focusCoaching'), icon: GraduationCap },
|
||||
{ key: 'art', label: CREW_FOCUS_LABELS.art, short: t('socialUi.crews.focusArt'), icon: Palette },
|
||||
{ key: 'music', label: CREW_FOCUS_LABELS.music, short: t('socialUi.crews.focusMusic'), icon: Music },
|
||||
{ key: 'wellness', label: CREW_FOCUS_LABELS.wellness, short: t('socialUi.crews.focusPhysio'), icon: Dumbbell },
|
||||
{ key: 'mixed', label: CREW_FOCUS_LABELS.mixed, short: t('socialUi.crews.focusMixed'), icon: Sparkles },
|
||||
{ key: 'builder', label: CREW_FOCUS_KEYS.builder ? t(CREW_FOCUS_KEYS.builder) : 'builder', short: t('socialUi.crews.focusDiy'), icon: HardHat },
|
||||
{ key: 'photo', label: CREW_FOCUS_KEYS.photo ? t(CREW_FOCUS_KEYS.photo) : 'photo', short: t('socialUi.crews.focusPhoto'), icon: Camera },
|
||||
{ key: 'coaching', label: CREW_FOCUS_KEYS.coaching ? t(CREW_FOCUS_KEYS.coaching) : 'coaching', short: t('socialUi.crews.focusCoaching'), icon: GraduationCap },
|
||||
{ key: 'art', label: CREW_FOCUS_KEYS.art ? t(CREW_FOCUS_KEYS.art) : 'art', short: t('socialUi.crews.focusArt'), icon: Palette },
|
||||
{ key: 'music', label: CREW_FOCUS_KEYS.music ? t(CREW_FOCUS_KEYS.music) : 'music', short: t('socialUi.crews.focusMusic'), icon: Music },
|
||||
{ key: 'wellness', label: CREW_FOCUS_KEYS.wellness ? t(CREW_FOCUS_KEYS.wellness) : 'wellness', short: t('socialUi.crews.focusPhysio'), icon: Dumbbell },
|
||||
{ key: 'mixed', label: CREW_FOCUS_KEYS.mixed ? t(CREW_FOCUS_KEYS.mixed) : 'mixed', short: t('socialUi.crews.focusMixed'), icon: Sparkles },
|
||||
];
|
||||
const currentFocus = FOCUS_FILTERS.find((f) => f.key === focusFilter) ?? FOCUS_FILTERS[0];
|
||||
const FocusIcon = currentFocus.icon;
|
||||
|
|
@ -428,7 +429,7 @@ function EmptyState({
|
|||
// 2) A scene filter with no crews in it — there *are* crews, just not here.
|
||||
// Saying "no crews planted here yet" would be flatly wrong, so offer the way out.
|
||||
if (focus !== 'all') {
|
||||
const label = CREW_FOCUS_LABELS[focus];
|
||||
const label = CREW_FOCUS_KEYS[focus] ? t(CREW_FOCUS_KEYS[focus]) : focus;
|
||||
return (
|
||||
<SharedEmptyState
|
||||
icon={Users}
|
||||
|
|
|
|||
|
|
@ -35,16 +35,7 @@ import NotFound from './NotFound';
|
|||
|
||||
const EVENT_TYPES = Object.entries(EVENT_TYPE_LABELS);
|
||||
|
||||
/** Map event type keys to translation keys (types live in lib/types). */
|
||||
const EVENT_TYPE_KEYS: Record<string, string> = {
|
||||
contest: 'placesUi.eventTypes.contest',
|
||||
session: 'placesUi.eventTypes.session',
|
||||
meetup: 'placesUi.eventTypes.meetup',
|
||||
premiere: 'placesUi.eventTypes.premiere',
|
||||
workshop: 'placesUi.eventTypes.workshop',
|
||||
demo: 'placesUi.eventTypes.demo',
|
||||
jam: 'placesUi.eventTypes.jam',
|
||||
};
|
||||
import { EVENT_TYPE_KEYS } from '@/lib/labelKeys';
|
||||
|
||||
function eventTypeLabel(t: (key: string) => string, type: string): string {
|
||||
return EVENT_TYPE_KEYS[type] ? t(EVENT_TYPE_KEYS[type]) : (EVENT_TYPE_LABELS[type] || type);
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { TagInput } from '@/components/TagInput';
|
|||
import { SKATE_SPOT_KIND } from '@/lib/types';
|
||||
import { reverseGeocodeRegions, pushRegionTags } from '@/lib/geocodeRegions';
|
||||
import { spotCategories, MAX_TAGS, shelterOptions, DEFAULT_SHELTER, SHELTERED_VALUES, type ShelterType } from '@/lib/skateTags';
|
||||
import { SPOT_TAG_CATEGORY_KEYS, SPOT_TAG_CATEGORY_DESC_KEYS, SHELTER_KEYS, SHELTER_DESC_KEYS } from '@/lib/labelKeys';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
MapPin,
|
||||
|
|
@ -578,8 +579,8 @@ export default function EditSpotPage() {
|
|||
onClick={() => handleCategoryChange(category.value)}
|
||||
>
|
||||
<CardContent className="p-3 sm:p-4 text-center relative">
|
||||
<div className="font-bold text-sm sm:text-base">{category.label}</div>
|
||||
<div className="text-xs text-gray-400 mt-1">{category.description}</div>
|
||||
<div className="font-bold text-sm sm:text-base">{SPOT_TAG_CATEGORY_KEYS[category.value] ? t(SPOT_TAG_CATEGORY_KEYS[category.value]) : category.label}</div>
|
||||
<div className="text-xs text-gray-400 mt-1">{SPOT_TAG_CATEGORY_DESC_KEYS[category.value] ? t(SPOT_TAG_CATEGORY_DESC_KEYS[category.value]) : category.description}</div>
|
||||
{selectedCategory === category.value && (
|
||||
<div className="absolute top-1 right-1 w-4 h-4 sm:w-5 sm:h-5 rounded-full bg-[#ff0040] flex items-center justify-center">
|
||||
<Check className="h-2.5 w-2.5 sm:h-3 sm:w-3 text-white" />
|
||||
|
|
@ -613,8 +614,8 @@ export default function EditSpotPage() {
|
|||
<div className="flex items-start gap-2.5">
|
||||
<div className="text-xl leading-none mt-0.5">{option.emoji}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-bold text-sm sm:text-base text-white">{option.label}</div>
|
||||
<div className="text-xs text-gray-400 mt-0.5">{option.description}</div>
|
||||
<div className="font-bold text-sm sm:text-base text-white">{SHELTER_KEYS[option.value] ? t(SHELTER_KEYS[option.value]) : option.label}</div>
|
||||
<div className="text-xs text-gray-400 mt-0.5">{SHELTER_DESC_KEYS[option.value] ? t(SHELTER_DESC_KEYS[option.value]) : option.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
{shelter === option.value && (
|
||||
|
|
|
|||
|
|
@ -35,16 +35,7 @@ import { ControllerBadge } from '@/components/ControllerBadge';
|
|||
import { useToast } from '@/hooks/useToast';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
/** Map event type keys to translation keys (types live in lib/types). */
|
||||
const EVENT_TYPE_KEYS: Record<string, string> = {
|
||||
contest: 'placesUi.eventTypes.contest',
|
||||
session: 'placesUi.eventTypes.session',
|
||||
meetup: 'placesUi.eventTypes.meetup',
|
||||
premiere: 'placesUi.eventTypes.premiere',
|
||||
workshop: 'placesUi.eventTypes.workshop',
|
||||
demo: 'placesUi.eventTypes.demo',
|
||||
jam: 'placesUi.eventTypes.jam',
|
||||
};
|
||||
import { EVENT_TYPE_KEYS } from '@/lib/labelKeys';
|
||||
|
||||
function eventTypeLabel(t: (key: string) => string, type: string): string {
|
||||
return EVENT_TYPE_KEYS[type] ? t(EVENT_TYPE_KEYS[type]) : (EVENT_TYPE_LABELS[type] || type);
|
||||
|
|
|
|||
|
|
@ -101,16 +101,7 @@ const DATE_WINDOW_LABELS_SHORT: Record<DateWindow, string> = {
|
|||
month: 'placesUi.dateWindow.monthShort',
|
||||
};
|
||||
|
||||
/** Map event type keys to translation keys (types live in lib/types). */
|
||||
const EVENT_TYPE_KEYS: Record<string, string> = {
|
||||
contest: 'placesUi.eventTypes.contest',
|
||||
session: 'placesUi.eventTypes.session',
|
||||
meetup: 'placesUi.eventTypes.meetup',
|
||||
premiere: 'placesUi.eventTypes.premiere',
|
||||
workshop: 'placesUi.eventTypes.workshop',
|
||||
demo: 'placesUi.eventTypes.demo',
|
||||
jam: 'placesUi.eventTypes.jam',
|
||||
};
|
||||
import { EVENT_TYPE_KEYS, TOUR_CATEGORY_KEYS } from '@/lib/labelKeys';
|
||||
|
||||
function eventTypeLabel(t: (key: string) => string, type: string): string {
|
||||
return EVENT_TYPE_KEYS[type] ? t(EVENT_TYPE_KEYS[type]) : (EVENT_TYPE_LABELS[type] || type);
|
||||
|
|
@ -641,7 +632,7 @@ function FilterDrawer({
|
|||
color: isActive ? ((info.color as string) === '#ccff00' ? 'black' : 'white') : info.color,
|
||||
}}
|
||||
>
|
||||
{info.emoji} {info.label}
|
||||
{info.emoji} {TOUR_CATEGORY_KEYS[key] ? t(TOUR_CATEGORY_KEYS[key]) : info.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { useBatchSellerSales } from '@/hooks/useSellerSales';
|
|||
import { useFollowList } from '@/hooks/useFollowList';
|
||||
import { GearSwapItemCard } from '@/components/GearSwapItemCard';
|
||||
import { GEAR_CATEGORIES } from '@/lib/types';
|
||||
import { GEAR_CATEGORY_KEYS } from '@/lib/labelKeys';
|
||||
import { getLastLocation } from '@/lib/lastLocation';
|
||||
import { itemDistanceKm } from '@/lib/sortOptions';
|
||||
import { ShoppingCart, Plus, Search, Filter, ArrowUpDown, Users, ShieldCheck, UserCheck, AlertCircle, MapPin } from 'lucide-react';
|
||||
|
|
@ -183,9 +184,9 @@ export default function GearSwapPage() {
|
|||
|
||||
const categoryOptions = [
|
||||
{ value: 'all', label: t('marketUi.gearSwap.allCategories') },
|
||||
...Object.entries(GEAR_CATEGORIES).map(([key, label]) => ({
|
||||
...Object.entries(GEAR_CATEGORIES).map(([key]) => ({
|
||||
value: key,
|
||||
label,
|
||||
label: GEAR_CATEGORY_KEYS[key] ? t(GEAR_CATEGORY_KEYS[key]) : GEAR_CATEGORIES[key as keyof typeof GEAR_CATEGORIES],
|
||||
})),
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { useAuthor } from '@/hooks/useAuthor';
|
|||
import { useSellerInfo } from '@/hooks/useSellerInfo';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { formatPrice, GEAR_CONDITIONS, GEAR_CATEGORIES } from '@/lib/types';
|
||||
import { GEAR_CATEGORY_KEYS, GEAR_CONDITION_KEYS } from '@/lib/labelKeys';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
|
|
@ -101,10 +102,15 @@ export default function ListingDetailPage() {
|
|||
const isWTB = item.listingType === 'wtb';
|
||||
const isSold = item.status === 'sold';
|
||||
const priceDisplay = item.price
|
||||
? `${formatPrice(item.price, item.priceType)}${item.price.frequency ? `/${item.price.frequency}` : ''}`
|
||||
: formatPrice(undefined, item.priceType);
|
||||
? `${formatPrice(item.price, item.priceType, t)}${item.price.frequency ? `/${item.price.frequency}` : ''}`
|
||||
: formatPrice(undefined, item.priceType, t);
|
||||
const conditionInfo = item.condition ? GEAR_CONDITIONS[item.condition] : null;
|
||||
const categoryLabel = item.category.find((c) => GEAR_CATEGORIES[c as keyof typeof GEAR_CATEGORIES]);
|
||||
const categoryValue = item.category.find((c) => GEAR_CATEGORIES[c as keyof typeof GEAR_CATEGORIES]);
|
||||
const categoryLabel = categoryValue
|
||||
? GEAR_CATEGORY_KEYS[categoryValue]
|
||||
? t(GEAR_CATEGORY_KEYS[categoryValue])
|
||||
: GEAR_CATEGORIES[categoryValue as keyof typeof GEAR_CATEGORIES]
|
||||
: null;
|
||||
const displayName = metadata?.name ?? (pubkey ? genUserName(pubkey) : t('marketUi.listingDetail.unknown'));
|
||||
const npub = pubkey ? nip19.npubEncode(pubkey) : '';
|
||||
|
||||
|
|
@ -198,7 +204,7 @@ export default function ListingDetailPage() {
|
|||
<div className="flex flex-wrap gap-1.5">
|
||||
{conditionInfo && (
|
||||
<Badge variant="outline" className="border-gray-600 bg-gray-800/50">
|
||||
{conditionInfo.emoji} {conditionInfo.label}
|
||||
{conditionInfo.emoji} {item.condition && GEAR_CONDITION_KEYS[item.condition] ? t(GEAR_CONDITION_KEYS[item.condition]) : conditionInfo.label}
|
||||
</Badge>
|
||||
)}
|
||||
{item.size && (
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ import { useSatsToUsd } from '@/hooks/useExchangeRate';
|
|||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { SPOT_CATEGORIES, SPOT_TYPE_LABELS, SHELTER_INFO, SKATE_RELAY } from '@/lib/types';
|
||||
import { SPOT_CATEGORIES, SHELTER_INFO, SKATE_RELAY } from '@/lib/types';
|
||||
import type { SkateSpot } from '@/lib/types';
|
||||
import { spotTypeLabel } from '@/lib/spotTypeLabel';
|
||||
import ngeohash from 'ngeohash';
|
||||
|
|
@ -475,7 +475,7 @@ function SpotBottomSheet({
|
|||
key={type}
|
||||
className="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold bg-white/10 text-white/70"
|
||||
>
|
||||
{SPOT_TYPE_LABELS[type] || type}
|
||||
{spotTypeLabel(t, type)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -35,11 +35,12 @@ import { useSpotServices } from '@/hooks/useSpotServices';
|
|||
import { MarketplaceItemCard } from '@/components/MarketplaceItemCard';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import {
|
||||
SPOT_TYPE_LABELS,
|
||||
ACCESS_TYPE_INFO,
|
||||
SHELTER_INFO,
|
||||
SKATE_SPOT_KIND,
|
||||
} from '@/lib/types';
|
||||
import { spotTypeLabel } from '@/lib/spotTypeLabel';
|
||||
import { ACCESS_TYPE_KEYS, SHELTER_KEYS } from '@/lib/labelKeys';
|
||||
import NotFound from './NotFound';
|
||||
import {
|
||||
MapPin,
|
||||
|
|
@ -495,7 +496,7 @@ export default function SpotDetailPage() {
|
|||
backgroundColor: `${ACCESS_TYPE_INFO[spot.accessType].color}20`
|
||||
}}
|
||||
>
|
||||
{ACCESS_TYPE_INFO[spot.accessType].emoji} {ACCESS_TYPE_INFO[spot.accessType].label}
|
||||
{ACCESS_TYPE_INFO[spot.accessType].emoji} {ACCESS_TYPE_KEYS[spot.accessType] ? t(ACCESS_TYPE_KEYS[spot.accessType]) : ACCESS_TYPE_INFO[spot.accessType].label}
|
||||
</Badge>
|
||||
)}
|
||||
{spot.shelter && spot.shelter !== 'outdoor' && SHELTER_INFO[spot.shelter] && (
|
||||
|
|
@ -507,7 +508,7 @@ export default function SpotDetailPage() {
|
|||
backgroundColor: `${SHELTER_INFO[spot.shelter].color}20`,
|
||||
}}
|
||||
>
|
||||
{SHELTER_INFO[spot.shelter].emoji} {SHELTER_INFO[spot.shelter].label}
|
||||
{SHELTER_INFO[spot.shelter].emoji} {SHELTER_KEYS[spot.shelter] ? t(SHELTER_KEYS[spot.shelter]) : SHELTER_INFO[spot.shelter].label}
|
||||
</Badge>
|
||||
)}
|
||||
{/* Staff verdict wins if present: a human reviewed it. */}
|
||||
|
|
@ -551,7 +552,7 @@ export default function SpotDetailPage() {
|
|||
)}
|
||||
{spot.types.map((type) => (
|
||||
<Badge key={type} className="bg-[#ccff00]/20 text-[#ccff00] border border-[#ccff00]/50 font-bold uppercase">
|
||||
{SPOT_TYPE_LABELS[type] || type}
|
||||
{spotTypeLabel(t, type)}
|
||||
</Badge>
|
||||
))}
|
||||
{/* Community suggested tags */}
|
||||
|
|
@ -562,7 +563,7 @@ export default function SpotDetailPage() {
|
|||
title={count > 1 ? t('spotUi.suggestedByUsers', { count }) : t('spotUi.suggestedByUser', { count })}
|
||||
>
|
||||
<Users className="h-3 w-3 mr-1" />
|
||||
{SPOT_TYPE_LABELS[tag] || tag}
|
||||
{spotTypeLabel(t, tag)}
|
||||
{count > 1 && <span className="ml-1 text-xs opacity-70">({count})</span>}
|
||||
</Badge>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { useAuthor } from '@/hooks/useAuthor';
|
|||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { TOUR_SKILL_LEVELS, TOUR_SPOT_TYPES, TOUR_CATEGORIES, SKATE_TOUR_KIND } from '@/lib/types';
|
||||
import { TOUR_CATEGORY_KEYS, TOUR_SPOT_TYPE_KEYS } from '@/lib/labelKeys';
|
||||
import { ZapButton } from '@/components/ZapButton';
|
||||
import { ProBadge } from '@/components/ProBadge';
|
||||
import { AddToCalendar } from '@/components/AddToCalendar';
|
||||
|
|
@ -49,7 +50,7 @@ export default function TourDetailPage() {
|
|||
|
||||
useSeoMeta({
|
||||
title: tour
|
||||
? `${tour.title} - ${TOUR_CATEGORIES[tour.category].label} - SkateSpots`
|
||||
? `${tour.title} - ${TOUR_CATEGORY_KEYS[tour.category] ? t(TOUR_CATEGORY_KEYS[tour.category]) : TOUR_CATEGORIES[tour.category].label} - SkateSpots`
|
||||
: 'Skate Tour - SkateSpots',
|
||||
description: tour?.description || 'Discover skate spots and tours on SkateSpots',
|
||||
});
|
||||
|
|
@ -197,7 +198,7 @@ export default function TourDetailPage() {
|
|||
? {
|
||||
uid: `${SKATE_TOUR_KIND}:${tour.pubkey}:${tour.identifier}`,
|
||||
title: tour.title,
|
||||
description: tour.description || `${categoryInfo.label} on SkateSpots`,
|
||||
description: tour.description || `${TOUR_CATEGORY_KEYS[tour.category] ? t(TOUR_CATEGORY_KEYS[tour.category]) : categoryInfo.label} on SkateSpots`,
|
||||
location: tour.location || tour.meetingPoint || tour.cities?.join(', ') || tour.countries?.join(', '),
|
||||
start: tour.startDate,
|
||||
end: tour.endDate,
|
||||
|
|
@ -266,7 +267,7 @@ export default function TourDetailPage() {
|
|||
borderColor: `${accent}55`,
|
||||
}}
|
||||
>
|
||||
{categoryInfo.emoji} {categoryInfo.label}
|
||||
{categoryInfo.emoji} {TOUR_CATEGORY_KEYS[tour.category] ? t(TOUR_CATEGORY_KEYS[tour.category]) : categoryInfo.label}
|
||||
</Badge>
|
||||
<ProBadge
|
||||
featuredUntil={tour.featuredUntil}
|
||||
|
|
@ -447,7 +448,7 @@ export default function TourDetailPage() {
|
|||
variant="outline"
|
||||
className="border-cyan-500/30 text-cyan-300"
|
||||
>
|
||||
{typeInfo?.emoji || '🛹'} {typeInfo?.label || type}
|
||||
{typeInfo?.emoji || '🛹'} {TOUR_SPOT_TYPE_KEYS[type] ? t(TOUR_SPOT_TYPE_KEYS[type]) : (typeInfo?.label || type)}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
Loading…
Reference in a new issue