i18n(es): batch 4 — Lab option labels, Sasquatch static FAQ, fee tiers

- labOptions.* (430 keys): all ~50 role LABELS/DESCRIPTIONS maps in types.ts
  (coach/filmer/editor/photo/builder/artist/graffiti/tattoo/trainer/musician/
  judge/organizer/barber) + TOUR_SKILL_LEVELS; 51 shared key maps in
  labelKeys.ts; consumers in becomeRoles/CraftProfileForm, LabPage,
  CoachDetailPage, the /become/* pages, TourCard/TourDetailPage/CreateTourPage/
  EventsPage. Trick names + tool/brand nouns stay English.
- sasquatchChat.* (83 keys): the free-tier STATIC_FAQ and local-result/fallback
  chat bodies (live AI already replies in-language; prompts stay English).
- feesUi.* (57 keys): usePurchasePro receipt prose + errors, PlatformFeePayment
  fee/duration tiers (LabProUpgrade/FeaturedEventUpgrade were already done).
- New src/i18n/batch4/ fragments spread into en/es.
This commit is contained in:
SkateSpots 2026-10-02 15:57:44 +07:00
parent d26b3b9516
commit 7347f874ad
23 changed files with 2487 additions and 211 deletions

View file

@ -32,6 +32,8 @@ export interface SkillSection {
title: string;
/** [tagValue, label] pairs. */
entries: [string, string][];
/** Optional value → `labOptions.*` translation key for the entry labels. */
keyMap?: Record<string, string>;
/** If true, at least one must be selected before publishing. */
required?: boolean;
}
@ -433,6 +435,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
<div className="mt-3 flex flex-wrap gap-2">
{section.entries.map(([key, label]) => {
const active = selected.includes(key);
const labelKey = section.keyMap?.[key];
return (
<button
key={key}
@ -443,7 +446,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
active ? accentChip : cn('bg-white/5 border-white/10 text-gray-400', accentChipHover)
)}
>
{label}
{labelKey ? t(labelKey) : label}
</button>
);
})}

View file

@ -11,6 +11,7 @@ import {
} from '@/lib/lightningAddress';
import { usePlatformFee, type FeeType } from '@/hooks/usePlatformFee';
import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils';
import {
Zap,
@ -44,6 +45,32 @@ const ZAP_RECEIPT_RELAYS = [
'wss://relay.damus.io',
];
// Static maps to explicit `feesUi.*` literal keys (no template keys).
const FEE_LABEL_KEYS: Record<FeeType, string> = {
featuredEvent: 'feesUi.feeFeaturedEventLabel',
labProfile: 'feesUi.feeLabProfileLabel',
featuredShop: 'feesUi.feeFeaturedShopLabel',
featuredTour: 'feesUi.feeFeaturedTourLabel',
};
const FEE_DESC_KEYS: Record<FeeType, string> = {
featuredEvent: 'feesUi.feeFeaturedEventDesc',
labProfile: 'feesUi.feeLabProfileDesc',
featuredShop: 'feesUi.feeFeaturedShopDesc',
featuredTour: 'feesUi.feeFeaturedTourDesc',
};
const FEE_BENEFIT_KEYS: Record<FeeType, string> = {
featuredEvent: 'feesUi.feeFeaturedEventBenefit',
labProfile: 'feesUi.feeLabProfileBenefit',
featuredShop: 'feesUi.feeFeaturedShopBenefit',
featuredTour: 'feesUi.feeFeaturedTourBenefit',
};
const DURATION_LABEL_KEYS: Record<string, string> = {
'7d': 'feesUi.duration7d',
'30d': 'feesUi.duration30d',
'90d': 'feesUi.duration90d',
'365d': 'feesUi.duration365d',
};
type PaymentState = 'idle' | 'generating' | 'waiting' | 'paying' | 'success' | 'error';
export function PlatformFeePayment({ type, onSuccess, context, entityCoord }: PlatformFeePaymentProps) {
@ -56,6 +83,13 @@ export function PlatformFeePayment({ type, onSuccess, context, entityCoord }: Pl
const { webln, activeNWC } = useWallet();
const { sendPayment } = useNWC();
const { user } = useCurrentUser();
const { t } = useTranslation();
// Localized fee metadata (labels/descriptions/benefits live in `feesUi.*`).
const metaLabel = t(FEE_LABEL_KEYS[type]);
const metaDesc = t(FEE_DESC_KEYS[type]);
const metaBenefit = t(FEE_BENEFIT_KEYS[type]);
const tierLabel = t(DURATION_LABEL_KEYS[tier.id] ?? 'feesUi.duration30d');
const [paymentState, setPaymentState] = useState<PaymentState>('idle');
const [invoice, setInvoice] = useState<string | null>(null);
@ -76,8 +110,12 @@ export function PlatformFeePayment({ type, onSuccess, context, entityCoord }: Pl
<div className="flex items-center gap-3 p-4 rounded-2xl bg-green-500/10 border border-green-500/30">
<ShieldCheck className="h-5 w-5 text-green-400 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="font-bold text-green-400 text-sm">{meta.label} — Active</p>
<p className="text-xs text-gray-500">{daysRemaining} day{daysRemaining !== 1 ? 's' : ''} remaining</p>
<p className="font-bold text-green-400 text-sm">{t('feesUi.metaActive', { label: metaLabel })}</p>
<p className="text-xs text-gray-500">
{daysRemaining === 1
? t('feesUi.daysRemainingOne', { count: daysRemaining })
: t('feesUi.daysRemainingMany', { count: daysRemaining })}
</p>
</div>
</div>
);
@ -107,7 +145,7 @@ export function PlatformFeePayment({ type, onSuccess, context, entityCoord }: Pl
}
const zapRequest = await user.signer.signEvent({
kind: 9734,
content: `SkateSpots ${meta.label} (${tier.label})`,
content: t('feesUi.zapRequestContent', { label: metaLabel, tier: tierLabel }),
created_at: Math.floor(Date.now() / 1000),
tags,
});
@ -126,7 +164,7 @@ export function PlatformFeePayment({ type, onSuccess, context, entityCoord }: Pl
bolt11 = await getInvoiceFromLightningAddress({
lightningAddress: recipient,
amountSats,
comment: `SkateSpots ${meta.label} (${tier.label})`,
comment: t('feesUi.zapRequestContent', { label: metaLabel, tier: tierLabel }),
});
}
@ -144,7 +182,7 @@ export function PlatformFeePayment({ type, onSuccess, context, entityCoord }: Pl
await handleAutoPay();
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to generate invoice');
setError(err instanceof Error ? err.message : t('feesUi.errGenerate'));
setPaymentState('error');
}
};
@ -166,18 +204,18 @@ export function PlatformFeePayment({ type, onSuccess, context, entityCoord }: Pl
recordPayment();
setPaymentState('success');
toast({ title: '⚡ Payment received!', description: `${amountSats.toLocaleString()} sats — ${meta.label} is now active.` });
toast({ title: t('feesUi.paymentReceived'), description: t('feesUi.paymentReceivedDesc', { amount: amountSats.toLocaleString(), label: metaLabel }) });
setTimeout(onSuccess, 1200);
} catch {
setPaymentState('waiting');
toast({ title: 'Auto-pay failed', description: 'Pay manually using the QR or invoice below.', variant: 'destructive' });
toast({ title: t('feesUi.autoPayFailed'), description: t('feesUi.autoPayFailedDesc'), variant: 'destructive' });
}
};
const copyInvoice = async () => {
if (!invoice) return;
await navigator.clipboard.writeText(invoice);
toast({ title: 'Invoice copied!' });
toast({ title: t('feesUi.invoiceCopied') });
};
// ── Success ──
@ -186,8 +224,8 @@ export function PlatformFeePayment({ type, onSuccess, context, entityCoord }: Pl
<div className="flex flex-col items-center gap-3 p-6 rounded-2xl bg-green-500/10 border border-green-500/30 text-center">
<CheckCircle2 className="h-10 w-10 text-green-400" />
<div>
<p className="font-black text-green-400 text-lg uppercase">{meta.emoji} {meta.label} Active!</p>
<p className="text-sm text-gray-400 mt-1">{meta.benefit}</p>
<p className="font-black text-green-400 text-lg uppercase">{meta.emoji} {t('feesUi.activeBang', { label: metaLabel })}</p>
<p className="text-sm text-gray-400 mt-1">{metaBenefit}</p>
</div>
</div>
);
@ -203,8 +241,8 @@ export function PlatformFeePayment({ type, onSuccess, context, entityCoord }: Pl
<Rocket className="h-5 w-5 text-white" />
</div>
<div className="flex-1 min-w-0">
<p className="font-black text-white text-sm uppercase tracking-wide">{meta.emoji} {meta.label}</p>
<p className="text-xs text-gray-500">{meta.description}</p>
<p className="font-black text-white text-sm uppercase tracking-wide">{meta.emoji} {metaLabel}</p>
<p className="text-xs text-gray-500">{metaDesc}</p>
</div>
</div>
</div>
@ -214,18 +252,18 @@ export function PlatformFeePayment({ type, onSuccess, context, entityCoord }: Pl
{paymentState === 'idle' && (
<div>
<p className="text-[11px] font-bold uppercase tracking-wide text-gray-500 mb-2">
Choose duration
{t('feesUi.chooseDuration')}
</p>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{tiers.map((t) => {
const price = feeTierPrice(t);
const perDay = feeTierPerDay(t);
const selected = t.id === tier.id;
{tiers.map((tierOption) => {
const price = feeTierPrice(tierOption);
const perDay = feeTierPerDay(tierOption);
const selected = tierOption.id === tier.id;
return (
<button
key={t.id}
key={tierOption.id}
type="button"
onClick={() => setTierId(t.id)}
onClick={() => setTierId(tierOption.id)}
className={cn(
'relative flex flex-col items-center rounded-xl border-2 px-2 py-2.5 transition-all',
selected
@ -233,18 +271,18 @@ export function PlatformFeePayment({ type, onSuccess, context, entityCoord }: Pl
: 'border-white/10 bg-white/[0.03] hover:border-white/25',
)}
>
{t.bestValue && (
{tierOption.bestValue && (
<span className="absolute -top-2 left-1/2 -translate-x-1/2 text-[8px] font-black uppercase px-1.5 py-0.5 rounded-full bg-[#ccff00] text-black whitespace-nowrap">
Best value
{t('feesUi.bestValue')}
</span>
)}
<span className={cn('text-xs font-black', selected ? 'text-[#ff6600]' : 'text-white')}>
{t.label}
{t(DURATION_LABEL_KEYS[tierOption.id] ?? 'feesUi.duration30d')}
</span>
<span className="text-[11px] font-bold text-gray-300 mt-0.5">
{price.toLocaleString()}
</span>
<span className="text-[9px] text-gray-500">{perDay}/day</span>
<span className="text-[9px] text-gray-500">{t('feesUi.perDay', { price: perDay })}</span>
</button>
);
})}
@ -256,11 +294,11 @@ export function PlatformFeePayment({ type, onSuccess, context, entityCoord }: Pl
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center gap-2 p-2.5 rounded-xl bg-white/4 border border-white/6">
<CheckCircle2 className="h-3.5 w-3.5 text-green-400 flex-shrink-0" />
<span className="text-xs text-gray-300 leading-snug">{meta.benefit}</span>
<span className="text-xs text-gray-300 leading-snug">{metaBenefit}</span>
</div>
<div className="flex items-center gap-2 p-2.5 rounded-xl bg-white/4 border border-white/6">
<Clock className="h-3.5 w-3.5 text-[#ccff00] flex-shrink-0" />
<span className="text-xs text-gray-300">Valid for <strong className="text-white">{tier.label}</strong></span>
<span className="text-xs text-gray-300">{t('feesUi.validFor')} <strong className="text-white">{tierLabel}</strong></span>
</div>
</div>
@ -272,7 +310,7 @@ export function PlatformFeePayment({ type, onSuccess, context, entityCoord }: Pl
<div className="text-center py-3 rounded-xl bg-[#ff6600]/10 border border-[#ff6600]/25">
<p className="text-3xl font-black text-[#ff6600]">⚡ {amountSats.toLocaleString()} sats</p>
<p className="text-xs text-gray-500 mt-1">
~${(amountSats * 0.0006).toFixed(2)} USD · to {recipientName} · {tier.label}
{t('feesUi.amountLine', { usd: (amountSats * 0.0006).toFixed(2), name: recipientName, tier: tierLabel })}
</p>
</div>
@ -283,7 +321,7 @@ export function PlatformFeePayment({ type, onSuccess, context, entityCoord }: Pl
className="w-full h-11 bg-gradient-to-r from-[#ff6600] to-[#ff0040] hover:opacity-90 border-0 text-white font-black uppercase tracking-wide shadow-lg shadow-[#ff6600]/20"
>
<Zap className="h-4 w-4 mr-2" />
Pay {amountSats.toLocaleString()} sats to Continue
{t('feesUi.payToContinue', { amount: amountSats.toLocaleString() })}
</Button>
)}
@ -291,7 +329,7 @@ export function PlatformFeePayment({ type, onSuccess, context, entityCoord }: Pl
<div className="flex flex-col items-center gap-2 py-4">
<Loader2 className="h-7 w-7 animate-spin text-[#ff6600]" />
<p className="text-sm text-gray-400">
{paymentState === 'generating' ? 'Generating invoice…' : 'Processing payment…'}
{paymentState === 'generating' ? t('feesUi.generating') : t('feesUi.processing')}
</p>
</div>
)}
@ -300,23 +338,23 @@ export function PlatformFeePayment({ type, onSuccess, context, entityCoord }: Pl
<div className="space-y-3">
<div className="flex justify-center">
<div className="bg-white p-3 rounded-xl">
<img src={qrCodeUrl} alt="Lightning invoice QR" className="w-44 h-44" />
<img src={qrCodeUrl} alt={t('feesUi.invoiceQrAlt')} className="w-44 h-44" />
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<Button variant="outline" onClick={copyInvoice} className="border-white/15 text-gray-300 hover:bg-white/6 gap-2 text-xs">
<Copy className="h-3.5 w-3.5" />Copy Invoice
<Copy className="h-3.5 w-3.5" />{t('feesUi.copyInvoice')}
</Button>
<Button variant="outline" onClick={() => window.open(`lightning:${invoice}`, '_blank')} className="border-white/15 text-gray-300 hover:bg-white/6 gap-2 text-xs">
<ExternalLink className="h-3.5 w-3.5" />Open Wallet
<ExternalLink className="h-3.5 w-3.5" />{t('feesUi.openWallet')}
</Button>
</div>
{(webln || activeNWC) && (
<Button onClick={handleAutoPay} className={cn('w-full gap-2 font-bold', 'bg-gradient-to-r from-[#ff6600] to-[#ff0040] hover:opacity-90 border-0')}>
<Zap className="h-4 w-4" />Pay with Connected Wallet
<Zap className="h-4 w-4" />{t('feesUi.payConnected')}
</Button>
)}
<p className="text-[11px] text-gray-600 text-center">Scan with any Lightning wallet</p>
<p className="text-[11px] text-gray-600 text-center">{t('feesUi.scanAny')}</p>
</div>
)}
@ -324,7 +362,7 @@ export function PlatformFeePayment({ type, onSuccess, context, entityCoord }: Pl
<div className="space-y-3">
<p className="text-sm text-red-400 text-center">{error}</p>
<Button variant="outline" onClick={generateInvoice} className="w-full border-white/15">
Try Again
{t('feesUi.tryAgain')}
</Button>
</div>
)}

View file

@ -386,8 +386,24 @@ function cleanLocation(raw: string): string {
.trim();
}
// Translation function shape (from LanguageContext) — passed in because this
// helper runs outside the component.
type Translate = (key: string, params?: Record<string, string | number>) => string;
// Static map from tool → label translation key (explicit literals, so the
// i18n guard can't drift on a template key).
const LOCAL_RESULT_LABEL_KEYS: Record<string, string> = {
find_spots: 'sasquatchChat.labelSpots',
find_events: 'sasquatchChat.labelEvents',
find_coaches: 'sasquatchChat.labelLabPros',
find_crews: 'sasquatchChat.labelCrews',
find_shops: 'sasquatchChat.labelShops',
find_tours: 'sasquatchChat.labelTours',
find_gear: 'sasquatchChat.labelGear',
};
// Format a CVM result set into a friendly, no-AI bullet list.
function formatLocalResults(intent: LocalIntent, result: unknown): string {
function formatLocalResults(intent: LocalIntent, result: unknown, t: Translate): string {
const r = result as Record<string, unknown>;
if (intent.tool === 'get_community_stats') {
@ -396,16 +412,16 @@ function formatLocalResults(intent: LocalIntent, result: unknown): string {
.filter(([, v]) => typeof v === 'number')
.map(([k, v]) => `• ${k.replace(/_/g, ' ')}: **${v}**`)
.join('\n');
return `📊 **SkateSpots by the numbers**\n${bits}`;
return `${t('sasquatchChat.statsTitle')}\n${bits}`;
}
if (intent.tool === 'get_weather') {
try {
const text = (r.content as Array<{ text: string }>)?.[0]?.text;
const w = JSON.parse(text) as Record<string, unknown>;
return `🌤️ **${w.city}** — ${w.condition} ${w.weatherIcon ?? ''}\n• ${w.temperature_c}°C (feels ${w.feels_like_c}°C)\n• Skateability: **${w.skateability_label}** (${w.skateability_score}/100)`;
return `🌤️ **${w.city}** — ${w.condition} ${w.weatherIcon ?? ''}\n${t('sasquatchChat.weatherTemp', { temp: String(w.temperature_c), feels: String(w.feels_like_c) })}\n${t('sasquatchChat.weatherSkateability', { label: String(w.skateability_label), score: String(w.skateability_score) })}`;
} catch {
return `Couldn't read the weather right now. Try again in a sec. 🌧️`;
return t('sasquatchChat.weatherError');
}
}
@ -417,13 +433,7 @@ function formatLocalResults(intent: LocalIntent, result: unknown): string {
intent.tool === 'find_shops' ? 'shops' :
intent.tool === 'find_tours' ? 'tours' : 'listings';
const items = (r[listKey] as Array<Record<string, unknown>>) ?? [];
const label =
intent.tool === 'find_spots' ? 'spots' :
intent.tool === 'find_events' ? 'events' :
intent.tool === 'find_coaches' ? 'Lab pros' :
intent.tool === 'find_crews' ? 'crews' :
intent.tool === 'find_shops' ? 'skate shops' :
intent.tool === 'find_tours' ? 'tours' : 'gear listings';
const label = t(LOCAL_RESULT_LABEL_KEYS[intent.tool] ?? 'sasquatchChat.labelGear');
if (!items.length) {
// Be honest: an empty result can mean "none published" OR that the relays
@ -432,8 +442,8 @@ function formatLocalResults(intent: LocalIntent, result: unknown): string {
const place =
'location' in intent ? intent.location :
'search' in intent ? intent.search : '';
const where = place ? ` in **${place}**` : '';
return `Didn't find any ${label}${where} right now 🦧\n\nThat could mean none are listed yet — or the relays were slow to answer. Try again in a moment, double-check the spelling, or [add the first one on the map](https://skate-spots.org/map). 🛹`;
const where = place ? t('sasquatchChat.emptyWhere', { place }) : '';
return t('sasquatchChat.emptyResult', { label, where });
}
// Show a generous slice, then honestly summarise the rest so the headline
@ -441,7 +451,7 @@ function formatLocalResults(intent: LocalIntent, result: unknown): string {
const MAX_SHOWN = 10;
const shown = items.slice(0, MAX_SHOWN);
const lines = shown.map((it) => {
const name = (it.name ?? it.title ?? it.role ?? 'Unnamed') as string;
const name = (it.name ?? it.title ?? it.role ?? t('sasquatchChat.unnamed')) as string;
const url = it.url as string | undefined;
const sub = (it.location ?? it.city ?? it.start ?? '') as string;
const linked = url ? `[${name}](${url})` : name;
@ -458,10 +468,10 @@ function formatLocalResults(intent: LocalIntent, result: unknown): string {
const remaining = items.length - shown.length;
const more = remaining > 0
? `\n\n…and **${remaining}** more — [see them all on the map](https://skate-spots.org/map) 🗺️`
? t('sasquatchChat.moreResults', { count: remaining })
: '';
return `${emoji} Found **${items.length}** ${label}:\n${lines}${more}`;
return t('sasquatchChat.foundTitle', { emoji, count: items.length, label, lines, more });
}
/**
@ -507,71 +517,74 @@ function toolCallToIntent(
// ─── Static FAQ fallback (for protocol/app questions without AI) ──────────────
const STATIC_FAQ: Record<string, string> = {
'hi': "Hey! I'm The Sasquatch 🦧 Your AI skate guide. Ask me about spots, events, coaches, gear — or anything about the app. What do you need?",
'hello': "Wassup! 🛹 I'm The Sasquatch. Ask me about skate spots, events, coaches, or how the app works. What's good?",
'hey': "Hey skater! 🤙 Ask me about spots near you, upcoming events, coaches, or anything SkateSpots.",
'who are you': "I'm The Sasquatch 🦧 — the AI guide for SkateSpots. I can find skate spots worldwide, look up events, coaches, filmers, and gear listings. All powered by live Nostr data. Ask away!",
'what can you do': "I can find skate spots by city or country, look up upcoming events, find coaches and filmers in The Lab, search gear listings, and answer questions about how the app works. Try: 'spots in Bangkok' or 'find a coach'! 🛹",
'what is nostr': "Nostr is a simple, open protocol for decentralized social networks. Think Twitter, but no single company controls it — you use cryptographic keys instead of usernames, choose which relays store your data (or run your own), and one account works across every Nostr app! 🔑",
// ── Privacy — answered locally so it's always accurate ──
'is this private': "Here's the honest rundown 🛡️\n\n• Your **GPS location stays on your device** — 'Near Me' just centers the map. It's never sent to us or published.\n• **Spots you add are public** (that's the point of a shared map) — so don't drop a pin on your own front door.\n• **DMs & crew chats are end-to-end encrypted** — only members can read them.\n• Like any website, servers you connect to (map tiles, relays) can see your **IP**. Want to hide that too? Use a VPN or Tor.\n\nFull details on the [Privacy page](/privacy). 🛹",
'privacy': "Quick version 🛡️: your GPS never leaves your device, spots you publish are public by design, and DMs/crew chats are end-to-end encrypted. Servers can still see your IP (normal for any website) — a VPN or Tor hides that if you want. Full breakdown on the [Privacy page](/privacy).",
'am i tracked': "SkateSpots doesn't track you — no ads, no analytics profiling, no data selling, and there's no SkateSpots server collecting your location. Your GPS stays on your device. The one thing servers see (map tiles, relays) is your **IP** — same as any website. Use a VPN or Tor to hide that. More on the [Privacy page](/privacy). 🛡️",
'do you track': "Nope — no tracking, no ads, no data harvesting. Your GPS stays on your device and there's no central server logging you. Servers you connect to can see your IP (like any site); a VPN/Tor hides that. See the [Privacy page](/privacy). 🛹",
'vpn': "Totally optional, but solid if you want extra privacy 🛡️. A VPN hides your **IP** (rough location) from the servers SkateSpots talks to — map tiles, relays, etc. Pick a no-logs provider you trust (e.g. Mullvad); avoid sketchy free ones.\n\nHeads up: a VPN does **not** hide the spots you publish (those are public), and don't turn on browser GPS if you're trying to stay anonymous. Full details on the [Privacy page](/privacy).",
'tor': "Tor Browser gives you the strongest IP privacy — it routes you through several relays so servers can't see your real IP 🧅. Trade-off: it's slower, and maps + some Nostr relays may lag or block Tor exits. Great for the extra-cautious.\n\nRemember: Tor hides your IP, not the spots you publish, and don't enable browser GPS if anonymity is the goal. More on the [Privacy page](/privacy).",
'hide my ip': "Two free options 🛡️: a **reputable VPN** (easy, fast — hides your IP behind their servers) or **Tor Browser** (max privacy, slower). Either one stops the servers SkateSpots connects to from seeing your real IP. Note they don't hide spots you publish. See the [Privacy page](/privacy).",
'is my location': "Your location is safe 📍 — 'Near Me' reads your GPS on your device only, to center the map and sort nearby spots. It's never published to Nostr, never sent to us, never shared. Only spots you deliberately add are public. Details on the [Privacy page](/privacy).",
'what are zaps': "Zaps are instant Bitcoin Lightning tips sent over Nostr! Click ⚡ on any content, send sats directly to the creator's wallet. Like hitting 'like' but with real money. Instant, peer-to-peer, no middlemen. ⚡",
'how to login': "Two ways:\n1. **Browser extension** (Desktop): Install Alby or nos2x, then click 'Log in'\n2. **New user**: Click 'Sign up' to generate keys. Download your nsec (secret key) and SAVE IT — this is your account forever!",
'what is cashu': "Built-in Bitcoin wallet! Cashu ecash tokens backed by Lightning — works offline at skate spots, privacy-focused (mints can't track payments). Go to Wallet in Settings to use it. 🥜",
'what is nwc': "Nostr Wallet Connect — like USB-C for Lightning wallets. Connect Alby, Minibits, Zeus, or Coinos once, and zap with one click forever. Encrypted, you control spending limits. 🔌⚡",
'is it free': "100% free. No ads, no subscriptions, no corporate BS. Built by skaters for skaters. Community-funded via Angor — check the Supporters page. You can also earn Bitcoin through community zaps for contributing quality spots. 🛹",
'what can i do': "Discover spots worldwide on the map, hire pros in The Lab (coaches, filmers, photographers, editors, ramp builders), roll with a crew, join events, take tours, buy/sell gear, track your private Stash, zap creators, check spot weather, and build the decentralized skate community!",
'what is the lab': "The Lab is where you hire skate-scene pros: Coaches, Filmers, Photographers, Video Editors, and ramp/park/DIY Builders. Each can be a solo person or a collective (crew/studio) — members confirm with their own keys so it's legit. All on Nostr, no fake accounts. 🎓",
'what is a crew': "Crews are your map-anchored skate family — who you skate WITH, not who you hire. Each crew has home turf on the map (teal pins), home spots, members, vibes and a manifesto. Members confirm from their own key. Create one at Crews → 'Create Crew'. 🤘",
'what are crews': "Crews are your map-anchored skate family — who you skate WITH, not who you hire. Each crew has home turf on the map (teal pins), home spots, members and a manifesto. Members confirm from their own key. Head to the Crews tab to start or join one! 🤘",
'how to start a crew': "Go to the Crews tab → 'Create Crew'. Name it, drop your home turf on the map, add vibes (or your own custom #hashtag), link your home spots, and add members by npub. Each member confirms from their own key so the roster is legit. 🤘",
'what is my stash': "My Stash is your PRIVATE, encrypted gear locker (NIP-44 encryption). Track your decks, trucks, wheels, shoes — with storage location, condition, rarity and price. Only YOU can read it unless you mark items public. Great for insurance or planning a swap. 🎒",
'what is stash': "My Stash is your PRIVATE, encrypted gear inventory. Log everything you own — condition, where it's stored, value — all encrypted so only you can see it. Mark items public if you want to sell or show them off. 🎒",
'what is a builder': "Builders are ramp/park/DIY crews in The Lab you can hire to build transitions, bowls, rails, or a whole park. They list what they build, materials, project scale, and can be licensed/insured. Often collectives — and they can link the spots they've built to the map. 🏗️",
'what is a nutzap': "Nutzaps (NIP-61) are ecash tips sent over Nostr — private Cashu tokens zapped straight to someone. You can set up nutzap RECEIVING in the Wallet. (Sending full nutzaps is rolling out — for now use Lightning zaps.) 🥜⚡",
'what is clink': "CLINK is a Nostr-native payment code (an 'noffer'). Shops, crews, and Lab pros can add one so you can pay them without needing a Lightning address — you'll see a 'Pay via CLINK' button with a QR. Scan it with a CLINK wallet like ShockWallet. 🔗⚡ [specs](https://clinkme.dev/specs.html) [apps](https://clinkme.dev/apps.html)",
'what is noffer': "An noffer is a CLINK payment code — a reusable, Nostr-native way to get paid without a web server or Lightning address. Add yours on your shop/crew/Lab profile and people can pay you directly. 🔗",
'what are tours': "Tours are guided skate trips — local sessions, national road trips, or world destruction tours. Find them on the map or the Tours section, meet up, and skate new terrain with locals. 🗺️🛹",
'what is a shop': "Skate Shops are local shops listed on the map. Discover and support real brick-and-mortar (or online) skate shops in your area — some even accept Bitcoin. 🏪",
'how to sell gear': "Head to Gear Swap / Marketplace and create a listing (NIP-99). Add photos, price in sats, condition, and optionally pin it to a spot so it shows on the map. Buyers zap or message you directly. 🛹💸",
'supporters': "SkateSpots is community-funded via Angor (decentralized Bitcoin crowdfunding) — no ads, no investors, no data selling. Check the Supporters page to back the project and keep the streets free. ❤️⚡",
'trust score': "Trust scores (0-100) powered by Relatr. Analyzes your Nostr network: followers, activity, engagement, zaps. Higher = more trusted. Helps filter bots and spam. Check any profile to see their score. 🛡️",
'what relay': "SkateSpots uses: 🛹 goskatespots.nostr1.com (primary, allowlisted for quality) + relay.ditto.pub (global) + purplepag.es (profile aggregator). Your data is replicated across all of them for redundancy.",
'trip planner': "Tell me a city and I'll build a day-by-day skate trip 🗺️ — spots, events, coaches and local crews, weather-aware (rainy day = crew tip for covered spots). Try 'plan me 3 days in Lisbon'. I can save it all to a Collection, and hand you off to flights/stays on the real sites (no KYC, no middleman). 🛹",
'plan a trip': "Just say 'plan me 3 days in Barcelona' (or any city) and I'll map out spots, sessions, coaches and local crews for each day. Save it as a Collection and go shred. 🗺️🛹",
// ── Sasquatch Pro — answered locally so it never depends on the AI brain ──
'sasquatch pro': "**Sasquatch Pro** 🦧⚡ is a Lightning supporter pass that unlocks the personal side of me:\n\n• 🧠 **Personal assistant** — I read your own Stash, crews, spots & listings\n• ✨ **Action drafts** — list gear, post your skate status, plan trips (you always confirm before anything publishes)\n• 💬 **Personal context** — my answers use your own data, not just public results\n• ⚡ **Support the project** — you keep SkateSpots free and ad-free\n\nFree Sasquatch stays free: public spot/event/coach/crew search, the glossary & trip planning all work without Pro. Pro just adds the *personal* stuff. Tap **⚡ Go Pro** up top. Tiers: 7 days ~2k sats, 1 month ~5k, 3 months ~12k, 1 year ~36k.",
'pro perks': "Pro perks 🦧⚡: personal assistant (reads your Stash/crews/spots/listings), action drafts (list gear, post status, plan trips — you confirm), and answers that use your own personal context. Plus you're directly supporting SkateSpots and keeping it free + ad-free. Free stays free — Pro adds the personal layer. Tap **⚡ Go Pro** up top.",
'pro version': "The Pro version of me 🦧⚡ adds a **personal assistant**: I can read your own Stash, crews, spots & listings, draft actions for you (list gear, post a status, plan a trip — you always confirm first), and use your personal context in my answers. It also directly supports the project. Free Sasquatch (spot/event/coach/crew search, glossary, trip planner) stays free. Tap **⚡ Go Pro** up top.",
'perks': "Pro perks 🦧⚡: personal assistant (your Stash/crews/spots/listings), action drafts you confirm, and answers that use your own context — plus you keep the app free & ad-free. Tap **⚡ Go Pro** up top.",
'go pro': "Tap the **⚡ Go Pro** button up top to open the upgrade sheet. Pro unlocks my personal assistant (Stash/crews/spots/listings) and action drafts, and directly supports the project. Pay with Lightning — 7 days ~2k sats up to 1 year ~36k sats. Free features stay free. 🦧",
'pro pricing': "Pro tiers (pay with Lightning ⚡): **7 days** ~2,000 sats (~$1.20), **1 month** ~5,000 sats (~$3), **3 months** ~12,000 sats (~$7, 20% off), **1 year** ~36,000 sats (~$21, best value). Tap **⚡ Go Pro** up top.",
'how much is pro': "Pro tiers: 7 days ~2,000 sats, 1 month ~5,000 sats, 3 months ~12,000 sats (20% off), 1 year ~36,000 sats (best value). Pay with Lightning ⚡. Tap **⚡ Go Pro** up top.",
'upgrade': "To upgrade to Sasquatch Pro, tap the **⚡ Go Pro** button up top. Pay with Lightning (7 days ~2k sats up to 1 year ~36k). Pro unlocks my personal assistant and action drafts, and directly supports the project. 🦧⚡",
const STATIC_FAQ_KEYS: Record<string, string> = {
// The English matcher text is the key; the value is the `sasquatchChat.faq.*`
// translation key whose answer gets rendered. Matching still works exactly as
// before (exact match first, then longest partial match), but the displayed
// answer is localized.
'hi': 'sasquatchChat.faq.hi',
'hello': 'sasquatchChat.faq.hello',
'hey': 'sasquatchChat.faq.hey',
'who are you': 'sasquatchChat.faq.whoAreYou',
'what can you do': 'sasquatchChat.faq.whatCanYouDo',
'what is nostr': 'sasquatchChat.faq.whatIsNostr',
'is this private': 'sasquatchChat.faq.isThisPrivate',
'privacy': 'sasquatchChat.faq.privacy',
'am i tracked': 'sasquatchChat.faq.amITracked',
'do you track': 'sasquatchChat.faq.doYouTrack',
'vpn': 'sasquatchChat.faq.vpn',
'tor': 'sasquatchChat.faq.tor',
'hide my ip': 'sasquatchChat.faq.hideMyIp',
'is my location': 'sasquatchChat.faq.isMyLocation',
'what are zaps': 'sasquatchChat.faq.whatAreZaps',
'how to login': 'sasquatchChat.faq.howToLogin',
'what is cashu': 'sasquatchChat.faq.whatIsCashu',
'what is nwc': 'sasquatchChat.faq.whatIsNwc',
'is it free': 'sasquatchChat.faq.isItFree',
'what can i do': 'sasquatchChat.faq.whatCanIDo',
'what is the lab': 'sasquatchChat.faq.whatIsTheLab',
'what is a crew': 'sasquatchChat.faq.whatIsACrew',
'what are crews': 'sasquatchChat.faq.whatAreCrews',
'how to start a crew': 'sasquatchChat.faq.howToStartACrew',
'what is my stash': 'sasquatchChat.faq.whatIsMyStash',
'what is stash': 'sasquatchChat.faq.whatIsStash',
'what is a builder': 'sasquatchChat.faq.whatIsABuilder',
'what is a nutzap': 'sasquatchChat.faq.whatIsANutzap',
'what is clink': 'sasquatchChat.faq.whatIsClink',
'what is noffer': 'sasquatchChat.faq.whatIsNoffer',
'what are tours': 'sasquatchChat.faq.whatAreTours',
'what is a shop': 'sasquatchChat.faq.whatIsAShop',
'how to sell gear': 'sasquatchChat.faq.howToSellGear',
'supporters': 'sasquatchChat.faq.supporters',
'trust score': 'sasquatchChat.faq.trustScore',
'what relay': 'sasquatchChat.faq.whatRelay',
'trip planner': 'sasquatchChat.faq.tripPlanner',
'plan a trip': 'sasquatchChat.faq.planATrip',
'sasquatch pro': 'sasquatchChat.faq.sasquatchPro',
'pro perks': 'sasquatchChat.faq.proPerks',
'pro version': 'sasquatchChat.faq.proVersion',
'perks': 'sasquatchChat.faq.perks',
'go pro': 'sasquatchChat.faq.goPro',
'pro pricing': 'sasquatchChat.faq.proPricing',
'how much is pro': 'sasquatchChat.faq.howMuchIsPro',
'upgrade': 'sasquatchChat.faq.upgrade',
};
function getStaticAnswer(question: string): string | null {
// Returns the `sasquatchChat.faq.*` translation key for the best match, or null.
function getStaticAnswerKey(question: string): string | null {
const q = question.toLowerCase().trim();
// Exact match first (for greetings like "hi", "hey")
if (STATIC_FAQ[q]) return STATIC_FAQ[q];
if (STATIC_FAQ_KEYS[q]) return STATIC_FAQ_KEYS[q];
// Partial match — prefer the LONGEST matching key so specific phrases
// ("sasquatch pro", "pro pricing") win over broad ones ("pro", "perks").
let best: { key: string; answer: string } | null = null;
for (const [key, answer] of Object.entries(STATIC_FAQ)) {
let best: { key: string; answerKey: string } | null = null;
for (const [key, answerKey] of Object.entries(STATIC_FAQ_KEYS)) {
if (q.includes(key) && (!best || key.length > best.key.length)) {
best = { key, answer };
best = { key, answerKey };
}
}
return best?.answer ?? null;
return best?.answerKey ?? null;
}
// ─── CVM connection status indicator ─────────────────────────────────────────
@ -808,13 +821,13 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
} catch (err) {
appendMessage({
role: 'sasquatch',
text: `Couldn't publish that: ${err instanceof Error ? err.message : 'unknown error'}. Try again in a sec.`,
text: t('sasquatchChat.publishError', { error: err instanceof Error ? err.message : t('sasquatchChat.unknownError') }),
timestamp: Date.now(),
});
setDraftStatus(ts, 'pending');
}
},
[publishDraft, setDraftStatus, appendMessage],
[publishDraft, setDraftStatus, appendMessage, t],
);
// ── Trip Planner — orchestrate several CVM tools + weather into an itinerary ─
@ -863,7 +876,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
if (!loc) {
appendMessage({
role: 'sasquatch',
text: "I can't see your location — nothing ever leaves your device; I read it locally only when you ask, and the browser just declined. Tap 🎯 **Near Me** on the map, or tell me a city: \"spots in Bangkok\". 🛹",
text: t('sasquatchChat.locationDenied'),
timestamp: Date.now(),
});
return true;
@ -891,7 +904,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
if (!nearest.length) {
appendMessage({
role: 'sasquatch',
text: 'No mapped spots near you yet 🦧 — [be the first to drop one](https://skate-spots.org/create-spot). 🛹',
text: t('sasquatchChat.noNearbySpots'),
timestamp: Date.now(),
isLive: true,
});
@ -903,7 +916,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
.join('\n');
appendMessage({
role: 'sasquatch',
text: `Closest to you right now:\n\n${list}\n\n_Computed on your device — your location never left it._`,
text: `${t('sasquatchChat.closestTitle')}\n\n${list}\n\n${t('sasquatchChat.closestFooter')}`,
timestamp: Date.now(),
isLive: true,
});
@ -928,11 +941,11 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
try {
// 1. Check static FAQ first (fast, no API call needed)
const staticAnswer = getStaticAnswer(question);
const staticAnswer = getStaticAnswerKey(question);
if (staticAnswer && !needsLiveData(question)) {
appendMessage({
role: 'sasquatch',
text: staticAnswer,
text: t(staticAnswer),
timestamp: Date.now(),
});
return;
@ -958,7 +971,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
if (personal.available && !isPro && /\bmy (stash|gear|crew|crews|spots?|listings?|inventory)\b|what am i selling|what do i own/i.test(question)) {
appendMessage({
role: 'sasquatch',
text: "That's a **Sasquatch Pro** thing 🦧⚡ — with Pro I can read your Stash, crews, spots and listings, draft gear listings, wanted ads and skate statuses, and plan your trips. It's a Lightning supporter pass that keeps SkateSpots free & ad-free. Tap the **⚡ Go Pro** button up top to unlock it. 🛹",
text: t('sasquatchChat.proNudge'),
timestamp: Date.now(),
});
return;
@ -1039,7 +1052,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
setStreamingText('');
appendMessage({
role: 'sasquatch',
text: formatLocalResults(fastIntent, fastResult),
text: formatLocalResults(fastIntent, fastResult, t),
timestamp: Date.now(),
isLive: true,
});
@ -1140,7 +1153,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
if (!isPro) {
appendMessage({
role: 'sasquatch',
text: "Drafting actions (posting a status, listing gear) is a **Sasquatch Pro** perk 🦧⚡ — tap **⚡ Go Pro** up top to unlock it.",
text: t('sasquatchChat.draftingProPerk'),
timestamp: Date.now(),
});
aiSucceeded = true;
@ -1180,7 +1193,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
if (draft) {
appendMessage({
role: 'sasquatch',
text: "Here's a draft — review it and tap to publish. Nothing goes live until you confirm. 🛹",
text: t('sasquatchChat.draftIntro'),
timestamp: Date.now(),
draft,
draftStatus: 'pending',
@ -1188,7 +1201,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
} else {
appendMessage({
role: 'sasquatch',
text: "I couldn't quite build that draft — try telling me the details again (e.g. \"list my Spitfire wheels for 8000 sats\").",
text: t('sasquatchChat.draftFailed'),
timestamp: Date.now(),
});
}
@ -1206,7 +1219,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
if (!destination) {
appendMessage({
role: 'sasquatch',
text: "Where to? Tell me a city (e.g. \"plan me 3 days in Lisbon\") and I'll build a trip. 🗺️",
text: t('sasquatchChat.tripAskCity'),
timestamp: Date.now(),
});
} else {
@ -1217,13 +1230,13 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
if (itinerary.empty) {
appendMessage({
role: 'sasquatch',
text: `Nothing on the map for **${destination}** yet 🦧 — be the first to drop spots at [skate-spots.org](https://skate-spots.org). Once locals add spots, crews & events, I'll build you a full trip.`,
text: t('sasquatchChat.tripEmptyMap', { destination }),
timestamp: Date.now(),
});
} else {
appendMessage({
role: 'sasquatch',
text: `Locked in a **${destination}** mission for you 🛹 — spots, sessions and locals below. Tweak it or save it.`,
text: t('sasquatchChat.tripLocked', { destination }),
timestamp: Date.now(),
isLive: true,
itinerary,
@ -1234,7 +1247,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
setStreamingText('');
appendMessage({
role: 'sasquatch',
text: "Couldn't reach the live data to plan that right now. Try again in a moment! 🛹",
text: t('sasquatchChat.tripFailed'),
timestamp: Date.now(),
});
}
@ -1321,7 +1334,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
// This shaves a full model call (~2–4s) off the most common queries.
const localIntent = toolCallToIntent(toolName, toolArgs);
if (localIntent) {
accumulated = formatLocalResults(localIntent, toolResult);
accumulated = formatLocalResults(localIntent, toolResult, t);
} else {
// Personal/Pro tools still need AI synthesis (e.g. summarising a
// Stash, or explaining a pro_required error) — keep the second pass.
@ -1350,7 +1363,7 @@ Instructions:
} catch (toolErr) {
setCvmConnected(false);
console.warn('[Sasquatch] relay query failed:', toolErr);
accumulated = "The relays didn't answer in time right now 🛰️ — that's usually a slow connection, not a missing spot. Give it another go in a moment. 🛹";
accumulated = t('sasquatchChat.relayFailed');
}
} else {
// No tool call — AI answered directly from knowledge
@ -1361,7 +1374,7 @@ Instructions:
if (liveTs) {
// Finalise the streamed bubble in place instead of adding a duplicate.
const ts = liveTs;
const finalText = accumulated || aiText || "Hmm, couldn't process that. Try rephrasing!";
const finalText = accumulated || aiText || t('sasquatchChat.couldNotProcess');
setChatMessages((prev) =>
prev.map((m) =>
m.timestamp === ts ? { ...m, text: finalText, isStreaming: false, isLive: usedCVM } : m,
@ -1370,7 +1383,7 @@ Instructions:
} else {
appendMessage({
role: 'sasquatch',
text: accumulated || "Hmm, couldn't process that. Try rephrasing!",
text: accumulated || t('sasquatchChat.couldNotProcess'),
timestamp: Date.now(),
isLive: usedCVM,
});
@ -1402,13 +1415,13 @@ Instructions:
if (itinerary.empty) {
appendMessage({
role: 'sasquatch',
text: `Nothing on the map for **${intent.destination}** yet 🦧 — be the first at [skate-spots.org](https://skate-spots.org).`,
text: t('sasquatchChat.tripEmptyMapLocal', { destination: intent.destination }),
timestamp: Date.now(),
});
} else {
appendMessage({
role: 'sasquatch',
text: `Here's a **${intent.destination}** mission 🛹 — spots, sessions & locals below.`,
text: t('sasquatchChat.tripMissionLocal', { destination: intent.destination }),
timestamp: Date.now(),
isLive: true,
itinerary,
@ -1458,7 +1471,7 @@ Instructions:
setStreamingText('');
appendMessage({
role: 'sasquatch',
text: formatLocalResults(intent, localResult),
text: formatLocalResults(intent, localResult, t),
timestamp: Date.now(),
isLive: true,
});
@ -1471,19 +1484,19 @@ Instructions:
// ── No-AI fallback #2: static FAQ / glossary / friendly menu ────────
const fallback =
staticAnswer ??
(staticAnswer ? t(staticAnswer) : null) ??
answerFromGlossary(question) ??
(!isPro
? // Free tier: no AI. Nudge to Pro, but point at what still works.
"That one needs AI reasoning — a **Sasquatch Pro** feature ⚡🦧\n\nI can still find **spots, events, coaches, crews, shops, tours and gear** straight from the relays — ask me e.g. \"spots in Barcelona\" — and the [Glossary](/glossary) covers 150+ tricks & terms. Tap **⚡ Go Pro** up top to unlock AI answers."
: `My AI brain's catching some air right now 🦧 — but I've still got you:\n\n• 🗺️ [Map](/) — spots, shops, events near you\n• 🧪 [The Lab](/lab) — coaches, filmers, editors\n• 📖 [Glossary](/glossary) — 150+ tricks & terms\n• 👥 [Crews](/crews) — find your people\n\nOr just tell me a **city** ("spots in Barcelona") or a **trick** ("what's a nollie") and I'll answer straight up. 🛹`);
t('sasquatchChat.freeTierFallback')
: t('sasquatchChat.aiDownFallback'));
appendMessage({ role: 'sasquatch', text: fallback, timestamp: Date.now() });
}
} catch (err) {
console.error('[Sasquatch] Error:', err);
appendMessage({
role: 'sasquatch',
text: "Something went sideways on my end. Try again in a sec! 🦧",
text: t('sasquatchChat.wentWrong'),
timestamp: Date.now(),
});
} finally {

View file

@ -18,7 +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 { TOUR_CATEGORY_KEYS, TOUR_SKILL_KEYS } from '@/lib/labelKeys';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
@ -154,6 +154,7 @@ export function TourCard({ tour, compact = false }: TourCardProps) {
const categoryInfo = TOUR_CATEGORIES[tour.category];
const skillInfo = tour.skillLevel ? TOUR_SKILL_LEVELS[tour.skillLevel as keyof typeof TOUR_SKILL_LEVELS] : null;
const skillKey = tour.skillLevel ? TOUR_SKILL_KEYS[tour.skillLevel] : undefined;
const tourStatus = getTourStatus(tour, t);
const countdown = getCountdown(tour, t);
const isOwner = user?.pubkey === tour.pubkey;
@ -406,7 +407,7 @@ export function TourCard({ tour, compact = false }: TourCardProps) {
{skillInfo && (
<span className="flex items-center gap-1">
<span>{skillInfo.emoji}</span>
{skillInfo.label}
{skillKey ? t(skillKey) : skillInfo.label}
</span>
)}
</div>

View file

@ -66,21 +66,21 @@ export function usePurchasePro() {
year: 'numeric', month: 'long', day: 'numeric',
});
const content = [
`🦧⚡ Sasquatch Pro — you're in!`,
t('feesUi.proReceiptTitle'),
``,
`Pass: ${t(tier.labelKey)} (${tier.sats.toLocaleString()} sats)`,
`Active until: ${until}`,
t('feesUi.proReceiptPass', { pass: t(tier.labelKey), sats: tier.sats.toLocaleString() }),
t('feesUi.proReceiptActiveUntil', { until }),
``,
`What you unlocked:`,
`• Personal assistant — I read your Stash, crews, spots & listings`,
`• Action drafts — list gear, post a spot status, plan trips (you confirm)`,
`• Answers that use your personal context, not just public data`,
`• ⚡ You're directly supporting SkateSpots & keeping it free + ad-free`,
t('feesUi.proReceiptUnlocked'),
t('feesUi.proReceiptBullet1'),
t('feesUi.proReceiptBullet2'),
t('feesUi.proReceiptBullet3'),
t('feesUi.proReceiptBullet4'),
``,
`Thanks for keeping SkateSpots free & ad-free. Now go shred. 🛹`,
t('feesUi.proReceiptThanks'),
].join('\n');
try {
await sendDM({ recipients: user.pubkey, content, subject: 'Sasquatch Pro receipt' });
await sendDM({ recipients: user.pubkey, content, subject: t('feesUi.proReceiptSubject') });
} catch {
/* best-effort — don't fail the purchase over a receipt DM */
}
@ -95,14 +95,14 @@ export function usePurchasePro() {
const now = Math.floor(Date.now() / 1000);
const event = await user.signer.signEvent({
kind: SASQUATCH_PRO_KIND,
content: `Sasquatch Pro — ${t(tier.labelKey)} supporter pass 🦧⚡`,
content: t('feesUi.receiptContent', { tier: t(tier.labelKey) }),
tags: [
['d', 'sasquatch-pro'],
['tier', tier.id],
['expiry', String(expiry)],
['amount', String(tier.sats)],
['p', PLATFORM_PUBKEY],
['alt', `Sasquatch Pro supporter pass (${t(tier.labelKey)}), valid until ${new Date(expiry * 1000).toISOString()}`],
['alt', t('feesUi.receiptAlt', { tier: t(tier.labelKey), until: new Date(expiry * 1000).toISOString() })],
],
created_at: now,
});
@ -114,7 +114,7 @@ export function usePurchasePro() {
const purchase = useCallback(
async (tier: ProTier) => {
if (!user?.signer) {
setResult({ invoice: null, state: 'error', error: 'Log in to upgrade to Pro.' });
setResult({ invoice: null, state: 'error', error: t('feesUi.errLogin') });
return;
}
@ -125,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 — ${t(tier.labelKey)} 🦧`;
const comment = t('feesUi.purchaseComment', { tier: 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.
@ -197,7 +197,7 @@ export function usePurchasePro() {
setResult({
invoice: null,
state: 'error',
error: err instanceof Error ? err.message : 'Could not start the purchase.',
error: err instanceof Error ? err.message : t('feesUi.errStart'),
});
}
},
@ -216,11 +216,11 @@ export function usePurchasePro() {
setResult({
invoice: null,
state: 'error',
error: err instanceof Error ? err.message : 'Could not record your pass.',
error: err instanceof Error ? err.message : t('feesUi.errRecord'),
});
}
},
[publishReceipt, sendConfirmationDM],
[publishReceipt, sendConfirmationDM, t],
);
const reset = useCallback(() => setResult({ invoice: null, state: 'idle', error: null }), []);

142
src/i18n/batch4/feesUi.ts Normal file
View file

@ -0,0 +1,142 @@
/**
* Batch 4 — fee / upgrade tier labels (`feesUi.*`).
*
* `PlatformFeePayment`, `LabProUpgrade`, `FeaturedEventUpgrade`, and the
* `usePurchasePro` receipt prose. Keys must exist in BOTH languages.
*/
const feesUi = {
en: {
// ── Fee type metadata (usePlatformFee FEE_META) ──
feeFeaturedEventLabel: 'Featured Event',
feeFeaturedEventDesc: 'Feature your event on SkateSpots',
feeFeaturedEventBenefit: 'Your event appears at the top of the events page',
feeLabProfileLabel: 'Lab Pro Profile',
feeLabProfileDesc: 'Feature your services in The Lab',
feeLabProfileBenefit: 'Pro badge + featured placement when skaters browse The Lab',
feeFeaturedShopLabel: 'Featured Shop',
feeFeaturedShopDesc: 'Feature your skate shop on the map and directory',
feeFeaturedShopBenefit: 'Verified badge + featured pin on the map',
feeFeaturedTourLabel: 'Featured Tour',
feeFeaturedTourDesc: 'Feature your tour on SkateSpots',
feeFeaturedTourBenefit: 'Pro badge + top placement when skaters browse tours',
// ── Duration tiers (DURATION_TIERS) ──
duration7d: '7 days',
duration30d: '1 month',
duration90d: '3 months',
duration365d: '12 months',
// ── PlatformFeePayment ──
metaActive: '{{label}} — Active',
daysRemainingOne: '{{count}} day remaining',
daysRemainingMany: '{{count}} days remaining',
zapRequestContent: 'SkateSpots {{label}} ({{tier}})',
errGenerate: 'Failed to generate invoice',
paymentReceived: '⚡ Payment received!',
paymentReceivedDesc: '{{amount}} sats — {{label}} is now active.',
autoPayFailed: 'Auto-pay failed',
autoPayFailedDesc: 'Pay manually using the QR or invoice below.',
invoiceCopied: 'Invoice copied!',
activeBang: '{{label}} Active!',
chooseDuration: 'Choose duration',
bestValue: 'Best value',
perDay: '{{price}}/day',
validFor: 'Valid for',
amountLine: '~${{usd}} USD · to {{name}} · {{tier}}',
payToContinue: 'Pay {{amount}} sats to Continue',
generating: 'Generating invoice…',
processing: 'Processing payment…',
invoiceQrAlt: 'Lightning invoice QR',
copyInvoice: 'Copy Invoice',
openWallet: 'Open Wallet',
payConnected: 'Pay with Connected Wallet',
scanAny: 'Scan with any Lightning wallet',
tryAgain: 'Try Again',
// ── usePurchasePro — receipt DM + zap content ──
proReceiptTitle: "🦧⚡ Sasquatch Pro — you're in!",
proReceiptPass: 'Pass: {{pass}} ({{sats}} sats)',
proReceiptActiveUntil: 'Active until: {{until}}',
proReceiptUnlocked: 'What you unlocked:',
proReceiptBullet1: '• Personal assistant — I read your Stash, crews, spots & listings',
proReceiptBullet2: '• Action drafts — list gear, post a spot status, plan trips (you confirm)',
proReceiptBullet3: '• Answers that use your personal context, not just public data',
proReceiptBullet4: "• ⚡ You're directly supporting SkateSpots & keeping it free + ad-free",
proReceiptThanks: 'Thanks for keeping SkateSpots free & ad-free. Now go shred. 🛹',
proReceiptSubject: 'Sasquatch Pro receipt',
receiptContent: 'Sasquatch Pro — {{tier}} supporter pass 🦧⚡',
receiptAlt: 'Sasquatch Pro supporter pass ({{tier}}), valid until {{until}}',
purchaseComment: 'Sasquatch Pro — {{tier}} 🦧',
errLogin: 'Log in to upgrade to Pro.',
errStart: 'Could not start the purchase.',
errRecord: 'Could not record your pass.',
},
es: {
// ── Fee type metadata (usePlatformFee FEE_META) ──
feeFeaturedEventLabel: 'Evento destacado',
feeFeaturedEventDesc: 'Destaca tu evento en SkateSpots',
feeFeaturedEventBenefit: 'Tu evento aparece arriba del todo en la página de eventos',
feeLabProfileLabel: 'Perfil Pro de Lab',
feeLabProfileDesc: 'Destaca tus servicios en The Lab',
feeLabProfileBenefit: 'Insignia Pro + posición destacada cuando los skaters exploran The Lab',
feeFeaturedShopLabel: 'Tienda destacada',
feeFeaturedShopDesc: 'Destaca tu tienda de skate en el mapa y el directorio',
feeFeaturedShopBenefit: 'Insignia verificada + pin destacado en el mapa',
feeFeaturedTourLabel: 'Tour destacado',
feeFeaturedTourDesc: 'Destaca tu tour en SkateSpots',
feeFeaturedTourBenefit: 'Insignia Pro + posición superior cuando los skaters exploran los tours',
// ── Duration tiers (DURATION_TIERS) ──
duration7d: '7 días',
duration30d: '1 mes',
duration90d: '3 meses',
duration365d: '12 meses',
// ── PlatformFeePayment ──
metaActive: '{{label}} — Activo',
daysRemainingOne: 'Queda {{count}} día',
daysRemainingMany: 'Quedan {{count}} días',
zapRequestContent: 'SkateSpots {{label}} ({{tier}})',
errGenerate: 'No se pudo generar la factura',
paymentReceived: '⚡ ¡Pago recibido!',
paymentReceivedDesc: '{{amount}} sats — {{label}} ya está activo.',
autoPayFailed: 'Falló el pago automático',
autoPayFailedDesc: 'Paga manualmente con el QR o la factura de abajo.',
invoiceCopied: '¡Factura copiada!',
activeBang: '¡{{label}} activo!',
chooseDuration: 'Elige duración',
bestValue: 'Mejor precio',
perDay: '{{price}}/día',
validFor: 'Válido durante',
amountLine: '~${{usd}} USD · a {{name}} · {{tier}}',
payToContinue: 'Paga {{amount}} sats para continuar',
generating: 'Generando factura…',
processing: 'Procesando el pago…',
invoiceQrAlt: 'QR de la factura Lightning',
copyInvoice: 'Copiar factura',
openWallet: 'Abrir cartera',
payConnected: 'Pagar con la cartera conectada',
scanAny: 'Escanea con cualquier cartera Lightning',
tryAgain: 'Intentar de nuevo',
// ── usePurchasePro — receipt DM + zap content ──
proReceiptTitle: '🦧⚡ Sasquatch Pro — ¡ya estás dentro!',
proReceiptPass: 'Pase: {{pass}} ({{sats}} sats)',
proReceiptActiveUntil: 'Activo hasta: {{until}}',
proReceiptUnlocked: 'Lo que has desbloqueado:',
proReceiptBullet1: '• Asistente personal — leo tu Stash, crews, spots y anuncios',
proReceiptBullet2: '• Borradores de acciones — publica material, comparte un estado de spot, planifica viajes (tú confirmas)',
proReceiptBullet3: '• Respuestas que usan tu contexto personal, no solo datos públicos',
proReceiptBullet4: '• ⚡ Apoyas directamente a SkateSpots y ayudas a mantenerla gratis y sin anuncios',
proReceiptThanks: 'Gracias por mantener SkateSpots gratis y sin anuncios. Ahora a rodar. 🛹',
proReceiptSubject: 'Recibo de Sasquatch Pro',
receiptContent: 'Sasquatch Pro — pase de colaborador {{tier}} 🦧⚡',
receiptAlt: 'Pase de colaborador Sasquatch Pro ({{tier}}), válido hasta {{until}}',
purchaseComment: 'Sasquatch Pro — {{tier}} 🦧',
errLogin: 'Inicia sesión para mejorar a Pro.',
errStart: 'No se pudo iniciar la compra.',
errRecord: 'No se pudo registrar tu pase.',
},
};
export default feesUi;

17
src/i18n/batch4/index.ts Normal file
View file

@ -0,0 +1,17 @@
import labOptions from './labOptions';
import sasquatchChat from './sasquatchChat';
import feesUi from './feesUi';
/** Namespaces added by the batch-4 translation pass, spread into `en`. */
export const batch4En = {
labOptions: labOptions.en,
sasquatchChat: sasquatchChat.en,
feesUi: feesUi.en,
};
/** Namespaces added by the batch-4 translation pass, spread into `es`. */
export const batch4Es = {
labOptions: labOptions.es,
sasquatchChat: sasquatchChat.es,
feesUi: feesUi.es,
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,210 @@
/**
* Batch 4 — Sasquatch static FAQ + local-result chat bodies (`sasquatchChat.*`).
*
* The live AI already replies in the app language; these are the client-side
* canned answers and locally-formatted relay summaries. Keys must exist in
* BOTH languages.
*
* NOT translated here (deliberately): the LLM system prompt / language
* directive, the tool-result instruction messages sent to the model, and the
* examples that are functional parser commands (keep them in the form the
* local intent parser understands).
*/
const sasquatchChat = {
en: {
faq: {
hi: "Hey! I'm The Sasquatch 🦧 Your AI skate guide. Ask me about spots, events, coaches, gear — or anything about the app. What do you need?",
hello: "Wassup! 🛹 I'm The Sasquatch. Ask me about skate spots, events, coaches, or how the app works. What's good?",
hey: "Hey skater! 🤙 Ask me about spots near you, upcoming events, coaches, or anything SkateSpots.",
whoAreYou: "I'm The Sasquatch 🦧 — the AI guide for SkateSpots. I can find skate spots worldwide, look up events, coaches, filmers, and gear listings. All powered by live Nostr data. Ask away!",
whatCanYouDo: "I can find skate spots by city or country, look up upcoming events, find coaches and filmers in The Lab, search gear listings, and answer questions about how the app works. Try: 'spots in Bangkok' or 'find a coach'! 🛹",
whatIsNostr: "Nostr is a simple, open protocol for decentralized social networks. Think Twitter, but no single company controls it — you use cryptographic keys instead of usernames, choose which relays store your data (or run your own), and one account works across every Nostr app! 🔑",
isThisPrivate: "Here's the honest rundown 🛡️\n\n• Your **GPS location stays on your device** — 'Near Me' just centers the map. It's never sent to us or published.\n• **Spots you add are public** (that's the point of a shared map) — so don't drop a pin on your own front door.\n• **DMs & crew chats are end-to-end encrypted** — only members can read them.\n• Like any website, servers you connect to (map tiles, relays) can see your **IP**. Want to hide that too? Use a VPN or Tor.\n\nFull details on the [Privacy page](/privacy). 🛹",
privacy: "Quick version 🛡️: your GPS never leaves your device, spots you publish are public by design, and DMs/crew chats are end-to-end encrypted. Servers can still see your IP (normal for any website) — a VPN or Tor hides that if you want. Full breakdown on the [Privacy page](/privacy).",
amITracked: "SkateSpots doesn't track you — no ads, no analytics profiling, no data selling, and there's no SkateSpots server collecting your location. Your GPS stays on your device. The one thing servers see (map tiles, relays) is your **IP** — same as any website. Use a VPN or Tor to hide that. More on the [Privacy page](/privacy). 🛡️",
doYouTrack: "Nope — no tracking, no ads, no data harvesting. Your GPS stays on your device and there's no central server logging you. Servers you connect to can see your IP (like any site); a VPN/Tor hides that. See the [Privacy page](/privacy). 🛹",
vpn: "Totally optional, but solid if you want extra privacy 🛡️. A VPN hides your **IP** (rough location) from the servers SkateSpots talks to — map tiles, relays, etc. Pick a no-logs provider you trust (e.g. Mullvad); avoid sketchy free ones.\n\nHeads up: a VPN does **not** hide the spots you publish (those are public), and don't turn on browser GPS if you're trying to stay anonymous. Full details on the [Privacy page](/privacy).",
tor: "Tor Browser gives you the strongest IP privacy — it routes you through several relays so servers can't see your real IP 🧅. Trade-off: it's slower, and maps + some Nostr relays may lag or block Tor exits. Great for the extra-cautious.\n\nRemember: Tor hides your IP, not the spots you publish, and don't enable browser GPS if anonymity is the goal. More on the [Privacy page](/privacy).",
hideMyIp: "Two free options 🛡️: a **reputable VPN** (easy, fast — hides your IP behind their servers) or **Tor Browser** (max privacy, slower). Either one stops the servers SkateSpots connects to from seeing your real IP. Note they don't hide spots you publish. See the [Privacy page](/privacy).",
isMyLocation: "Your location is safe 📍 — 'Near Me' reads your GPS on your device only, to center the map and sort nearby spots. It's never published to Nostr, never sent to us, never shared. Only spots you deliberately add are public. Details on the [Privacy page](/privacy).",
whatAreZaps: "Zaps are instant Bitcoin Lightning tips sent over Nostr! Click ⚡ on any content, send sats directly to the creator's wallet. Like hitting 'like' but with real money. Instant, peer-to-peer, no middlemen. ⚡",
howToLogin: "Two ways:\n1. **Browser extension** (Desktop): Install Alby or nos2x, then click 'Log in'\n2. **New user**: Click 'Sign up' to generate keys. Download your nsec (secret key) and SAVE IT — this is your account forever!",
whatIsCashu: "Built-in Bitcoin wallet! Cashu ecash tokens backed by Lightning — works offline at skate spots, privacy-focused (mints can't track payments). Go to Wallet in Settings to use it. 🥜",
whatIsNwc: "Nostr Wallet Connect — like USB-C for Lightning wallets. Connect Alby, Minibits, Zeus, or Coinos once, and zap with one click forever. Encrypted, you control spending limits. 🔌⚡",
isItFree: "100% free. No ads, no subscriptions, no corporate BS. Built by skaters for skaters. Community-funded via Angor — check the Supporters page. You can also earn Bitcoin through community zaps for contributing quality spots. 🛹",
whatCanIDo: "Discover spots worldwide on the map, hire pros in The Lab (coaches, filmers, photographers, editors, ramp builders), roll with a crew, join events, take tours, buy/sell gear, track your private Stash, zap creators, check spot weather, and build the decentralized skate community!",
whatIsTheLab: "The Lab is where you hire skate-scene pros: Coaches, Filmers, Photographers, Video Editors, and ramp/park/DIY Builders. Each can be a solo person or a collective (crew/studio) — members confirm with their own keys so it's legit. All on Nostr, no fake accounts. 🎓",
whatIsACrew: "Crews are your map-anchored skate family — who you skate WITH, not who you hire. Each crew has home turf on the map (teal pins), home spots, members, vibes and a manifesto. Members confirm from their own key. Create one at Crews → 'Create Crew'. 🤘",
whatAreCrews: "Crews are your map-anchored skate family — who you skate WITH, not who you hire. Each crew has home turf on the map (teal pins), home spots, members and a manifesto. Members confirm from their own key. Head to the Crews tab to start or join one! 🤘",
howToStartACrew: "Go to the Crews tab → 'Create Crew'. Name it, drop your home turf on the map, add vibes (or your own custom #hashtag), link your home spots, and add members by npub. Each member confirms from their own key so the roster is legit. 🤘",
whatIsMyStash: "My Stash is your PRIVATE, encrypted gear locker (NIP-44 encryption). Track your decks, trucks, wheels, shoes — with storage location, condition, rarity and price. Only YOU can read it unless you mark items public. Great for insurance or planning a swap. 🎒",
whatIsStash: "My Stash is your PRIVATE, encrypted gear inventory. Log everything you own — condition, where it's stored, value — all encrypted so only you can see it. Mark items public if you want to sell or show them off. 🎒",
whatIsABuilder: "Builders are ramp/park/DIY crews in The Lab you can hire to build transitions, bowls, rails, or a whole park. They list what they build, materials, project scale, and can be licensed/insured. Often collectives — and they can link the spots they've built to the map. 🏗️",
whatIsANutzap: "Nutzaps (NIP-61) are ecash tips sent over Nostr — private Cashu tokens zapped straight to someone. You can set up nutzap RECEIVING in the Wallet. (Sending full nutzaps is rolling out — for now use Lightning zaps.) 🥜⚡",
whatIsClink: "CLINK is a Nostr-native payment code (an 'noffer'). Shops, crews, and Lab pros can add one so you can pay them without needing a Lightning address — you'll see a 'Pay via CLINK' button with a QR. Scan it with a CLINK wallet like ShockWallet. 🔗⚡ [specs](https://clinkme.dev/specs.html) [apps](https://clinkme.dev/apps.html)",
whatIsNoffer: "An noffer is a CLINK payment code — a reusable, Nostr-native way to get paid without a web server or Lightning address. Add yours on your shop/crew/Lab profile and people can pay you directly. 🔗",
whatAreTours: "Tours are guided skate trips — local sessions, national road trips, or world destruction tours. Find them on the map or the Tours section, meet up, and skate new terrain with locals. 🗺️🛹",
whatIsAShop: "Skate Shops are local shops listed on the map. Discover and support real brick-and-mortar (or online) skate shops in your area — some even accept Bitcoin. 🏪",
howToSellGear: "Head to Gear Swap / Marketplace and create a listing (NIP-99). Add photos, price in sats, condition, and optionally pin it to a spot so it shows on the map. Buyers zap or message you directly. 🛹💸",
supporters: "SkateSpots is community-funded via Angor (decentralized Bitcoin crowdfunding) — no ads, no investors, no data selling. Check the Supporters page to back the project and keep the streets free. ❤️⚡",
trustScore: "Trust scores (0-100) powered by Relatr. Analyzes your Nostr network: followers, activity, engagement, zaps. Higher = more trusted. Helps filter bots and spam. Check any profile to see their score. 🛡️",
whatRelay: "SkateSpots uses: 🛹 goskatespots.nostr1.com (primary, allowlisted for quality) + relay.ditto.pub (global) + purplepag.es (profile aggregator). Your data is replicated across all of them for redundancy.",
tripPlanner: "Tell me a city and I'll build a day-by-day skate trip 🗺️ — spots, events, coaches and local crews, weather-aware (rainy day = crew tip for covered spots). Try 'plan me 3 days in Lisbon'. I can save it all to a Collection, and hand you off to flights/stays on the real sites (no KYC, no middleman). 🛹",
planATrip: "Just say 'plan me 3 days in Barcelona' (or any city) and I'll map out spots, sessions, coaches and local crews for each day. Save it as a Collection and go shred. 🗺️🛹",
sasquatchPro: "**Sasquatch Pro** 🦧⚡ is a Lightning supporter pass that unlocks the personal side of me:\n\n• 🧠 **Personal assistant** — I read your own Stash, crews, spots & listings\n• ✨ **Action drafts** — list gear, post your skate status, plan trips (you always confirm before anything publishes)\n• 💬 **Personal context** — my answers use your own data, not just public results\n• ⚡ **Support the project** — you keep SkateSpots free and ad-free\n\nFree Sasquatch stays free: public spot/event/coach/crew search, the glossary & trip planning all work without Pro. Pro just adds the *personal* stuff. Tap **⚡ Go Pro** up top. Tiers: 7 days ~2k sats, 1 month ~5k, 3 months ~12k, 1 year ~36k.",
proPerks: "Pro perks 🦧⚡: personal assistant (reads your Stash/crews/spots/listings), action drafts (list gear, post status, plan trips — you confirm), and answers that use your own personal context. Plus you're directly supporting SkateSpots and keeping it free + ad-free. Free stays free — Pro adds the personal layer. Tap **⚡ Go Pro** up top.",
proVersion: "The Pro version of me 🦧⚡ adds a **personal assistant**: I can read your own Stash, crews, spots & listings, draft actions for you (list gear, post a status, plan a trip — you always confirm first), and use your personal context in my answers. It also directly supports the project. Free Sasquatch (spot/event/coach/crew search, glossary, trip planner) stays free. Tap **⚡ Go Pro** up top.",
perks: "Pro perks 🦧⚡: personal assistant (your Stash/crews/spots/listings), action drafts you confirm, and answers that use your own context — plus you keep the app free & ad-free. Tap **⚡ Go Pro** up top.",
goPro: "Tap the **⚡ Go Pro** button up top to open the upgrade sheet. Pro unlocks my personal assistant (Stash/crews/spots/listings) and action drafts, and directly supports the project. Pay with Lightning — 7 days ~2k sats up to 1 year ~36k sats. Free features stay free. 🦧",
proPricing: "Pro tiers (pay with Lightning ⚡): **7 days** ~2,000 sats (~$1.20), **1 month** ~5,000 sats (~$3), **3 months** ~12,000 sats (~$7, 20% off), **1 year** ~36,000 sats (~$21, best value). Tap **⚡ Go Pro** up top.",
howMuchIsPro: "Pro tiers: 7 days ~2,000 sats, 1 month ~5,000 sats, 3 months ~12,000 sats (20% off), 1 year ~36,000 sats (best value). Pay with Lightning ⚡. Tap **⚡ Go Pro** up top.",
upgrade: "To upgrade to Sasquatch Pro, tap the **⚡ Go Pro** button up top. Pay with Lightning (7 days ~2k sats up to 1 year ~36k). Pro unlocks my personal assistant and action drafts, and directly supports the project. 🦧⚡",
},
// ── Local result formatting ──
statsTitle: '📊 **SkateSpots by the numbers**',
weatherTemp: '• {{temp}}°C (feels {{feels}}°C)',
weatherSkateability: '• Skateability: **{{label}}** ({{score}}/100)',
weatherError: "Couldn't read the weather right now. Try again in a sec. 🌧️",
labelSpots: 'spots',
labelEvents: 'events',
labelLabPros: 'Lab pros',
labelCrews: 'crews',
labelShops: 'skate shops',
labelTours: 'tours',
labelGear: 'gear listings',
unnamed: 'Unnamed',
emptyWhere: ' in **{{place}}**',
emptyResult: "Didn't find any {{label}}{{where}} right now 🦧\n\nThat could mean none are listed yet — or the relays were slow to answer. Try again in a moment, double-check the spelling, or [add the first one on the map](https://skate-spots.org/map). 🛹",
foundTitle: '{{emoji}} Found **{{count}}** {{label}}:\n{{lines}}{{more}}',
moreResults: '\n\n…and **{{count}}** more — [see them all on the map](https://skate-spots.org/map) 🗺️',
// ── Proximity ──
locationDenied: "I can't see your location — nothing ever leaves your device; I read it locally only when you ask, and the browser just declined. Tap 🎯 **Near Me** on the map, or tell me a city: \"spots in Bangkok\". 🛹",
noNearbySpots: 'No mapped spots near you yet 🦧 — [be the first to drop one](https://skate-spots.org/create-spot). 🛹',
closestTitle: 'Closest to you right now:',
closestFooter: '_Computed on your device — your location never left it._',
// ── Pro nudge + action drafts ──
proNudge: "That's a **Sasquatch Pro** thing 🦧⚡ — with Pro I can read your Stash, crews, spots and listings, draft gear listings, wanted ads and skate statuses, and plan your trips. It's a Lightning supporter pass that keeps SkateSpots free & ad-free. Tap the **⚡ Go Pro** button up top to unlock it. 🛹",
draftingProPerk: "Drafting actions (posting a status, listing gear) is a **Sasquatch Pro** perk 🦧⚡ — tap **⚡ Go Pro** up top to unlock it.",
draftIntro: "Here's a draft — review it and tap to publish. Nothing goes live until you confirm. 🛹",
draftFailed: "I couldn't quite build that draft — try telling me the details again (e.g. \"list my Spitfire wheels for 8000 sats\").",
// ── Trip planner ──
tripAskCity: "Where to? Tell me a city (e.g. \"plan me 3 days in Lisbon\") and I'll build a trip. 🗺️",
tripEmptyMap: "Nothing on the map for **{{destination}}** yet 🦧 — be the first to drop spots at [skate-spots.org](https://skate-spots.org). Once locals add spots, crews & events, I'll build you a full trip.",
tripLocked: "Locked in a **{{destination}}** mission for you 🛹 — spots, sessions and locals below. Tweak it or save it.",
tripFailed: "Couldn't reach the live data to plan that right now. Try again in a moment! 🛹",
tripEmptyMapLocal: "Nothing on the map for **{{destination}}** yet 🦧 — be the first at [skate-spots.org](https://skate-spots.org).",
tripMissionLocal: "Here's a **{{destination}}** mission 🛹 — spots, sessions & locals below.",
// ── AI / no-AI fallbacks ──
relayFailed: "The relays didn't answer in time right now 🛰️ — that's usually a slow connection, not a missing spot. Give it another go in a moment. 🛹",
couldNotProcess: "Hmm, couldn't process that. Try rephrasing!",
freeTierFallback: "That one needs AI reasoning — a **Sasquatch Pro** feature ⚡🦧\n\nI can still find **spots, events, coaches, crews, shops, tours and gear** straight from the relays — ask me e.g. \"spots in Barcelona\" — and the [Glossary](/glossary) covers 150+ tricks & terms. Tap **⚡ Go Pro** up top to unlock AI answers.",
aiDownFallback: "My AI brain's catching some air right now 🦧 — but I've still got you:\n\n• 🗺️ [Map](/) — spots, shops, events near you\n• 🧪 [The Lab](/lab) — coaches, filmers, editors\n• 📖 [Glossary](/glossary) — 150+ tricks & terms\n• 👥 [Crews](/crews) — find your people\n\nOr just tell me a **city** (\"spots in Barcelona\") or a **trick** (\"what's a nollie\") and I'll answer straight up. 🛹",
wentWrong: "Something went sideways on my end. Try again in a sec! 🦧",
publishError: "Couldn't publish that: {{error}}. Try again in a sec.",
unknownError: 'unknown error',
},
es: {
faq: {
hi: '¡Hey! Soy el Sasquatch 🦧 Tu guía de skate con IA. Pregúntame por spots, eventos, entrenadores, material o lo que sea de la app. ¿Qué necesitas?',
hello: '¡Qué pasa! 🛹 Soy el Sasquatch. Pregúntame por spots de skate, eventos, entrenadores o cómo funciona la app. ¿Qué tal?',
hey: '¡Hey, skater! 🤙 Pregúntame por spots cerca de ti, próximos eventos, entrenadores o lo que sea de SkateSpots.',
whoAreYou: 'Soy el Sasquatch 🦧 — el guía con IA de SkateSpots. Puedo encontrar spots de skate en todo el mundo, buscar eventos, entrenadores, filmers y anuncios de material. Todo con datos en vivo de Nostr. ¡Pregunta lo que quieras!',
whatCanYouDo: "Puedo encontrar spots por ciudad o país, buscar próximos eventos, encontrar entrenadores y filmers en The Lab, buscar anuncios de material y responder dudas sobre cómo funciona la app. Prueba: 'spots in Bangkok' o 'find a coach'! 🛹",
whatIsNostr: 'Nostr es un protocolo sencillo y abierto para redes sociales descentralizadas. Piensa en Twitter, pero sin una sola empresa que lo controle: usas claves criptográficas en lugar de nombres de usuario, eliges qué relés guardan tus datos (o montas los tuyos) y una misma cuenta funciona en todas las apps de Nostr. 🔑',
isThisPrivate: "Aquí va el resumen honesto 🛡️\n\n• Tu **ubicación GPS se queda en tu dispositivo** — 'Cerca de mí' solo centra el mapa. Nunca se nos envía ni se publica.\n• **Los spots que añades son públicos** (es la gracia de un mapa compartido) — así que no pongas un pin en la puerta de tu casa.\n• **Los MD y los chats de crew están cifrados de extremo a extremo** — solo los miembros pueden leerlos.\n• Como en cualquier web, los servidores a los que te conectas (tiles del mapa, relés) pueden ver tu **IP**. ¿Quieres ocultarla también? Usa una VPN o Tor.\n\nTodos los detalles en la [página de Privacidad](/privacy). 🛹",
privacy: 'Versión rápida 🛡️: tu GPS nunca sale de tu dispositivo, los spots que publicas son públicos por diseño, y los MD y chats de crew están cifrados de extremo a extremo. Aun así los servidores pueden ver tu IP (normal en cualquier web) — una VPN o Tor lo oculta si quieres. Desglose completo en la [página de Privacidad](/privacy).',
amITracked: "SkateSpots no te rastrea — sin anuncios, sin perfiles de analítica, sin vender datos, y no hay ningún servidor de SkateSpots recogiendo tu ubicación. Tu GPS se queda en tu dispositivo. Lo único que ven los servidores (tiles del mapa, relés) es tu **IP** — igual que en cualquier web. Usa una VPN o Tor para ocultarla. Más info en la [página de Privacidad](/privacy). 🛡️",
doYouTrack: 'No — sin rastreo, sin anuncios, sin recolección de datos. Tu GPS se queda en tu dispositivo y no hay un servidor central que te registre. Los servidores a los que te conectas pueden ver tu IP (como en cualquier sitio); una VPN o Tor lo oculta. Mira la [página de Privacidad](/privacy). 🛹',
vpn: "Totalmente opcional, pero una gran idea si quieres privacidad extra 🛡️. Una VPN oculta tu **IP** (ubicación aproximada) a los servidores con los que habla SkateSpots — tiles del mapa, relés, etc. Elige un proveedor sin registros en el que confíes (p. ej. Mullvad); evita los gratuitos dudosos.\n\nAviso: una VPN **no** oculta los spots que publicas (esos son públicos), y no actives el GPS del navegador si intentas ser anónimo. Todos los detalles en la [página de Privacidad](/privacy).",
tor: "Tor Browser te da la mayor privacidad de IP — te enruta por varios relés para que los servidores no vean tu IP real 🧅. Contra: es más lento, y los mapas y algunos relés de Nostr pueden ir lentos o bloquear las salidas de Tor. Genial para los más precavidos.\n\nRecuerda: Tor oculta tu IP, no los spots que publicas, y no actives el GPS del navegador si buscas anonimato. Más info en la [página de Privacidad](/privacy).",
hideMyIp: 'Dos opciones gratis 🛡️: una **VPN de confianza** (fácil y rápida — oculta tu IP detrás de sus servidores) o **Tor Browser** (máxima privacidad, más lento). Cualquiera de las dos impide que los servidores a los que se conecta SkateSpots vean tu IP real. Ojo: no ocultan los spots que publicas. Mira la [página de Privacidad](/privacy).',
isMyLocation: "Tu ubicación está a salvo 📍 — 'Cerca de mí' lee tu GPS solo en tu dispositivo, para centrar el mapa y ordenar los spots cercanos. Nunca se publica en Nostr, nunca se nos envía, nunca se comparte. Solo los spots que decides añadir son públicos. Detalles en la [página de Privacidad](/privacy).",
whatAreZaps: "¡Los zaps son propinas instantáneas de Bitcoin Lightning enviadas por Nostr! Haz clic en ⚡ en cualquier contenido y manda sats directamente a la cartera del creador. Como darle a 'me gusta', pero con dinero real. Instantáneo, entre iguales, sin intermediarios. ⚡",
howToLogin: "Dos formas:\n1. **Extensión del navegador** (escritorio): instala Alby o nos2x y haz clic en 'Iniciar sesión'\n2. **Usuario nuevo**: haz clic en 'Registrarse' para generar tus claves. Descarga tu nsec (clave secreta) y GUÁRDALA — ¡esta es tu cuenta para siempre!",
whatIsCashu: '¡Cartera de Bitcoin integrada! Fichas de ecash Cashu respaldadas por Lightning — funciona sin conexión en los spots, centrada en la privacidad (las casas de moneda no pueden rastrear los pagos). Ve a Cartera en Ajustes para usarla. 🥜',
whatIsNwc: 'Nostr Wallet Connect — como el USB-C de las carteras Lightning. Conecta Alby, Minibits, Zeus o Coinos una vez y zaps con un clic para siempre. Cifrado, tú controlas los límites de gasto. 🔌⚡',
isItFree: '100 % gratis. Sin anuncios, sin suscripciones, sin tonterías corporativas. Hecho por skaters para skaters. Financiado por la comunidad vía Angor — mira la página de Colaboradores. También puedes ganar Bitcoin con los zaps de la comunidad por aportar spots de calidad. 🛹',
whatCanIDo: 'Descubre spots en todo el mundo en el mapa, contrata profesionales en The Lab (entrenadores, filmers, fotógrafos, editores, constructores de rampas), rueda con un crew, únete a eventos, haz tours, compra y vende material, gestiona tu Stash privado, haz zaps a creadores, consulta el tiempo de los spots y construye la comunidad de skate descentralizada.',
whatIsTheLab: 'The Lab es donde contratas a profesionales del mundo del skate: entrenadores, filmers, fotógrafos, editores de vídeo y constructores de rampas, parques y DIY. Cada uno puede ser una persona o un colectivo (crew/estudio) — los miembros confirman con sus propias claves, así que es legítimo. Todo en Nostr, sin cuentas falsas. 🎓',
whatIsACrew: "Los crews son tu familia de skate anclada al mapa — con quien patinas, no a quien contratas. Cada crew tiene su territorio en el mapa (pines turquesa), spots de casa, miembros, rollos y un manifiesto. Los miembros confirman con su propia clave. Crea uno en Crews → 'Crear crew'. 🤘",
whatAreCrews: 'Los crews son tu familia de skate anclada al mapa — con quien patinas, no a quien contratas. Cada crew tiene su territorio en el mapa (pines turquesa), spots de casa, miembros y un manifiesto. Los miembros confirman con su propia clave. ¡Ve a la pestaña Crews para empezar o unirte a uno! 🤘',
howToStartACrew: "Ve a la pestaña Crews → 'Crear crew'. Ponle nombre, marca tu territorio en el mapa, añade rollos (o tu propio #hashtag), enlaza tus spots de casa y añade miembros por npub. Cada miembro confirma con su propia clave para que la lista sea legítima. 🤘",
whatIsMyStash: 'Mi Stash es tu taquilla de material PRIVADA y cifrada (cifrado NIP-44). Registra tus tablas, ejes, ruedas y zapatillas — con lugar de almacenamiento, estado, rareza y precio. Solo TÚ puedes leerlo, salvo que marques objetos como públicos. Genial para seguros o para planear un cambio. 🎒',
whatIsStash: 'Mi Stash es tu inventario de material PRIVADO y cifrado. Registra todo lo que tienes — estado, dónde lo guardas, valor — todo cifrado para que solo tú lo veas. Marca objetos como públicos si quieres venderlos o presumir de ellos. 🎒',
whatIsABuilder: 'Los constructores son crews de rampas, parques y DIY en The Lab a las que puedes contratar para construir transiciones, bowls, barandillas o un parque entero. Publican lo que construyen, materiales, escala del proyecto, y pueden estar licenciados y asegurados. A menudo son colectivos — y pueden enlazar al mapa los spots que han construido. 🏗️',
whatIsANutzap: 'Los nutzaps (NIP-61) son propinas de ecash enviadas por Nostr — fichas Cashu privadas enviadas directamente a alguien. Puedes configurar la RECEPCIÓN de nutzaps en la Cartera. (El envío de nutzaps completos está saliendo — por ahora usa zaps Lightning.) 🥜⚡',
whatIsClink: "CLINK es un código de pago nativo de Nostr (un 'noffer'). Tiendas, crews y profesionales de The Lab pueden añadir uno para que les pagues sin necesitar una dirección Lightning — verás un botón 'Pagar vía CLINK' con un QR. Escanéalo con una cartera CLINK como ShockWallet. 🔗⚡ [specs](https://clinkme.dev/specs.html) [apps](https://clinkme.dev/apps.html)",
whatIsNoffer: "Un noffer es un código de pago CLINK — una forma reutilizable y nativa de Nostr de recibir pagos sin servidor web ni dirección Lightning. Añade el tuyo en tu perfil de tienda, crew o Lab y la gente podrá pagarte directamente. 🔗",
whatAreTours: 'Los tours son viajes de skate guiados — sesiones locales, rutas nacionales o giras mundiales. Encuéntralos en el mapa o en la sección Tours, queda con la gente y patina terrenos nuevos con los locales. 🗺️🛹',
whatIsAShop: 'Las tiendas de skate son tiendas locales listadas en el mapa. Descubre y apoya tiendas de skate reales (físicas u online) de tu zona — algunas incluso aceptan Bitcoin. 🏪',
howToSellGear: 'Ve a Intercambio / Marketplace y crea un anuncio (NIP-99). Añade fotos, precio en sats, estado y, si quieres, fíjalo a un spot para que aparezca en el mapa. Los compradores te hacen un zap o te escriben directamente. 🛹💸',
supporters: 'SkateSpots se financia a través de la comunidad vía Angor (financiación colectiva descentralizada en Bitcoin) — sin anuncios, sin inversores, sin vender datos. Mira la página de Colaboradores para apoyar el proyecto y mantener las calles libres. ❤️⚡',
trustScore: 'Puntuaciones de confianza (0-100) con tecnología de Relatr. Analiza tu red de Nostr: seguidores, actividad, interacción, zaps. Más alta = más confianza. Ayuda a filtrar bots y spam. Consulta cualquier perfil para ver su puntuación. 🛡️',
whatRelay: 'SkateSpots usa: 🛹 goskatespots.nostr1.com (principal, en lista blanca por calidad) + relay.ditto.pub (global) + purplepag.es (agregador de perfiles). Tus datos se replican en todos ellos por redundancia.',
tripPlanner: "Dime una ciudad y te monto un viaje de skate día a día 🗺️ — spots, eventos, entrenadores y crews locales, teniendo en cuenta el tiempo (día de lluvia = consejo de crew para spots cubiertos). Prueba 'plan me 3 days in Lisbon'. Puedo guardarlo todo en una Colección y pasarte a vuelos y alojamiento en las webs reales (sin KYC, sin intermediarios). 🛹",
planATrip: "Solo di 'plan me 3 days in Barcelona' (o cualquier ciudad) y te mapeo spots, sesiones, entrenadores y crews locales para cada día. Guárdalo como Colección y a rodar. 🗺️🛹",
sasquatchPro: '**Sasquatch Pro** 🦧⚡ es un pase de colaborador con Lightning que desbloquea mi lado personal:\n\n• 🧠 **Asistente personal** — leo tu propio Stash, crews, spots y anuncios\n• ✨ **Borradores de acciones** — publica material, comparte tu estado de skate, planifica viajes (tú siempre confirmas antes de que se publique nada)\n• 💬 **Contexto personal** — mis respuestas usan tus propios datos, no solo resultados públicos\n• ⚡ **Apoya el proyecto** — ayudas a mantener SkateSpots gratis y sin anuncios\n\nEl Sasquatch gratis sigue siendo gratis: búsqueda pública de spots, eventos, entrenadores y crews, el glosario y la planificación de viajes funcionan sin Pro. Pro solo añade lo *personal*. Toca **⚡ Hazte Pro** arriba. Tarifas: 7 días ~2k sats, 1 mes ~5k, 3 meses ~12k, 1 año ~36k.',
proPerks: 'Ventajas de Pro 🦧⚡: asistente personal (lee tu Stash, crews, spots y anuncios), borradores de acciones (publica material, comparte estado, planifica viajes — tú confirmas) y respuestas que usan tu propio contexto personal. Además apoyas directamente a SkateSpots y ayudas a mantenerla gratis y sin anuncios. Lo gratis sigue gratis — Pro añade la capa personal. Toca **⚡ Hazte Pro** arriba.',
proVersion: 'La versión Pro de mí 🦧⚡ añade un **asistente personal**: puedo leer tu propio Stash, crews, spots y anuncios, redactar acciones por ti (publica material, comparte un estado, planifica un viaje — siempre confirmas antes) y usar tu contexto personal en mis respuestas. También apoya directamente al proyecto. El Sasquatch gratis (búsqueda de spots, eventos, entrenadores y crews, glosario, planificador de viajes) sigue gratis. Toca **⚡ Hazte Pro** arriba.',
perks: 'Ventajas de Pro 🦧⚡: asistente personal (tu Stash, crews, spots y anuncios), borradores de acciones que tú confirmas y respuestas que usan tu propio contexto — además de mantener la app gratis y sin anuncios. Toca **⚡ Hazte Pro** arriba.',
goPro: 'Toca el botón **⚡ Hazte Pro** de arriba para abrir la hoja de mejora. Pro desbloquea mi asistente personal (Stash, crews, spots y anuncios) y los borradores de acciones, y apoya directamente al proyecto. Paga con Lightning — 7 días ~2k sats hasta 1 año ~36k sats. Las funciones gratis siguen gratis. 🦧',
proPricing: 'Tarifas de Pro (paga con Lightning ⚡): **7 días** ~2.000 sats (~1,20 $), **1 mes** ~5.000 sats (~3 $), **3 meses** ~12.000 sats (~7 $, 20 % dto.), **1 año** ~36.000 sats (~21 $, mejor precio). Toca **⚡ Hazte Pro** arriba.',
howMuchIsPro: 'Tarifas de Pro: 7 días ~2.000 sats, 1 mes ~5.000 sats, 3 meses ~12.000 sats (20 % dto.), 1 año ~36.000 sats (mejor precio). Paga con Lightning ⚡. Toca **⚡ Hazte Pro** arriba.',
upgrade: 'Para mejorar a Sasquatch Pro, toca el botón **⚡ Hazte Pro** de arriba. Paga con Lightning (7 días ~2k sats hasta 1 año ~36k). Pro desbloquea mi asistente personal y los borradores de acciones, y apoya directamente al proyecto. 🦧⚡',
},
// ── Local result formatting ──
statsTitle: '📊 **SkateSpots en cifras**',
weatherTemp: '• {{temp}}°C (sensación {{feels}}°C)',
weatherSkateability: '• Patinabilidad: **{{label}}** ({{score}}/100)',
weatherError: 'No pude leer el tiempo ahora mismo. Inténtalo de nuevo en un momento. 🌧️',
labelSpots: 'spots',
labelEvents: 'eventos',
labelLabPros: 'profesionales del Lab',
labelCrews: 'crews',
labelShops: 'tiendas de skate',
labelTours: 'tours',
labelGear: 'anuncios de material',
unnamed: 'Sin nombre',
emptyWhere: ' por **{{place}}**',
emptyResult: 'No encontré {{label}}{{where}} ahora mismo 🦧\n\nPuede que aún no haya ninguno listado — o que los relés tardaran en responder. Inténtalo de nuevo en un momento, comprueba la ortografía o [añade el primero en el mapa](https://skate-spots.org/map). 🛹',
foundTitle: '{{emoji}} Encontré **{{count}}** {{label}}:\n{{lines}}{{more}}',
moreResults: '\n\n…y **{{count}}** más — [míralos todos en el mapa](https://skate-spots.org/map) 🗺️',
// ── Proximity ──
locationDenied: "No puedo ver tu ubicación — nada sale nunca de tu dispositivo; solo la leo en local cuando tú lo pides, y el navegador acaba de denegarlo. Toca 🎯 **Cerca de mí** en el mapa o dime una ciudad: \"spots in Bangkok\". 🛹",
noNearbySpots: 'Aún no hay spots mapeados cerca de ti 🦧 — [sé el primero en añadir uno](https://skate-spots.org/create-spot). 🛹',
closestTitle: 'Lo más cercano a ti ahora mismo:',
closestFooter: '_Calculado en tu dispositivo — tu ubicación nunca salió de él._',
// ── Pro nudge + action drafts ──
proNudge: 'Eso es cosa de **Sasquatch Pro** 🦧⚡ — con Pro puedo leer tu Stash, crews, spots y anuncios, redactar anuncios de material, peticiones de búsqueda y estados de skate, y planificar tus viajes. Es un pase de colaborador con Lightning que mantiene SkateSpots gratis y sin anuncios. Toca el botón **⚡ Hazte Pro** de arriba para desbloquearlo. 🛹',
draftingProPerk: 'Redactar acciones (publicar un estado, listar material) es una ventaja de **Sasquatch Pro** 🦧⚡ — toca **⚡ Hazte Pro** arriba para desbloquearlo.',
draftIntro: 'Aquí tienes un borrador — revísalo y toca para publicar. Nada se hace público hasta que confirmes. 🛹',
draftFailed: "No pude construir ese borrador — cuéntame los detalles otra vez (p. ej. \"list my Spitfire wheels for 8000 sats\").",
// ── Trip planner ──
tripAskCity: "¿A dónde? Dime una ciudad (p. ej. \"plan me 3 days in Lisbon\") y te monto un viaje. 🗺️",
tripEmptyMap: "Todavía no hay nada en el mapa para **{{destination}}** 🦧 — sé el primero en añadir spots en [skate-spots.org](https://skate-spots.org). Cuando los locales añadan spots, crews y eventos, te montaré un viaje completo.",
tripLocked: 'Te he preparado una misión en **{{destination}}** 🛹 — spots, sesiones y locales abajo. Ajústala o guárdala.',
tripFailed: 'No pude acceder a los datos en vivo para planificar eso ahora mismo. ¡Inténtalo de nuevo en un momento! 🛹',
tripEmptyMapLocal: 'Todavía no hay nada en el mapa para **{{destination}}** 🦧 — sé el primero en [skate-spots.org](https://skate-spots.org).',
tripMissionLocal: 'Aquí tienes una misión en **{{destination}}** 🛹 — spots, sesiones y locales abajo.',
// ── AI / no-AI fallbacks ──
relayFailed: 'Los relés no respondieron a tiempo ahora mismo 🛰️ — normalmente es una conexión lenta, no un spot que falte. Dale otra oportunidad en un momento. 🛹',
couldNotProcess: 'Mmm, no pude procesar eso. ¡Prueba a reformularlo!',
freeTierFallback: "Eso necesita razonamiento de IA — una función de **Sasquatch Pro** ⚡🦧\n\nAun así puedo encontrar **spots, eventos, entrenadores, crews, tiendas, tours y material** directamente de los relés — pídemelo, p. ej. \"spots in Barcelona\" — y el [Glosario](/glossary) cubre más de 150 trucos y términos. Toca **⚡ Hazte Pro** arriba para desbloquear las respuestas con IA.",
aiDownFallback: "Mi cerebro de IA está cogiendo aire ahora mismo 🦧 — pero sigo teniéndote cubierto:\n\n• 🗺️ [Mapa](/) — spots, tiendas y eventos cerca de ti\n• 🧪 [The Lab](/lab) — entrenadores, filmers, editores\n• 📖 [Glosario](/glossary) — más de 150 trucos y términos\n• 👥 [Crews](/crews) — encuentra a tu gente\n\nO simplemente dime una **ciudad** (\"spots in Barcelona\") o un **truco** (\"what's a nollie\") y te respondo al momento. 🛹",
wentWrong: 'Algo se ha torcido por mi parte. ¡Inténtalo de nuevo en un momento! 🦧',
publishError: 'No pude publicar eso: {{error}}. Inténtalo de nuevo en un momento.',
unknownError: 'error desconocido',
},
};
export default sasquatchChat;

View file

@ -3,6 +3,7 @@
* Note: Skateboarding trick names and terminology stay in English
*/
import { batch3En } from './batch3';
import { batch4En } from './batch4';
const en = {
// Common
@ -6529,4 +6530,4 @@ walletUi: {
},
};
export default { ...en, ...batch3En };
export default { ...en, ...batch3En, ...batch4En };

View file

@ -4,6 +4,7 @@
* (kickflip, ollie, grind, etc. are universal in skate culture)
*/
import { batch3Es } from './batch3';
import { batch4Es } from './batch4';
const es = {
// Common
@ -6529,4 +6530,4 @@ walletUi: {
},
};
export default { ...es, ...batch3Es };
export default { ...es, ...batch3Es, ...batch4Es };

View file

@ -13,6 +13,16 @@ import {
SKATE_TRAINER_KIND, TRAINER_FOCUS_LABELS, TRAINER_INJURY_LABELS, TRAINER_CREDENTIAL_LABELS,
SKATE_BARBER_KIND, BARBER_SERVICE_LABELS, BARBER_STYLE_LABELS, BARBER_SETTING_LABELS,
} from '@/lib/types';
import {
ARTIST_SERVICE_KEYS, ARTIST_MEDIUM_KEYS, ARTIST_TOOL_KEYS,
GRAFFITI_STYLE_KEYS, GRAFFITI_MEDIUM_KEYS, GRAFFITI_CONTEXT_KEYS,
MUSICIAN_GENRE_KEYS, MUSICIAN_INSTRUMENT_KEYS,
JUDGE_FORMAT_KEYS, JUDGE_SPECIALTY_KEYS, JUDGE_LEVEL_KEYS,
ORGANIZER_EVENT_KEYS, ORGANIZER_SERVICE_KEYS, ORGANIZER_SCALE_KEYS,
TATTOO_STYLE_KEYS, TATTOO_METHOD_KEYS,
TRAINER_FOCUS_KEYS, TRAINER_INJURY_KEYS, TRAINER_CREDENTIAL_KEYS,
BARBER_SERVICE_KEYS, BARBER_STYLE_KEYS, BARBER_SETTING_KEYS,
} from '@/lib/labelKeys';
/**
* Single source of truth for every "become a …" role.
@ -74,9 +84,9 @@ export const BECOME_ROLES: Record<string, BecomeRole> = {
rateLabel: 'Rate',
portfolioLabel: 'Portfolio / Shop link',
skillSections: [
{ title: 'What You Make', entries: Object.entries(ARTIST_SERVICE_LABELS), required: true },
{ title: 'Medium', entries: Object.entries(ARTIST_MEDIUM_LABELS) },
{ title: 'Software & Tools', entries: Object.entries(ARTIST_TOOL_LABELS) },
{ title: 'What You Make', entries: Object.entries(ARTIST_SERVICE_LABELS), keyMap: ARTIST_SERVICE_KEYS, required: true },
{ title: 'Medium', entries: Object.entries(ARTIST_MEDIUM_LABELS), keyMap: ARTIST_MEDIUM_KEYS },
{ title: 'Software & Tools', entries: Object.entries(ARTIST_TOOL_LABELS), keyMap: ARTIST_TOOL_KEYS },
],
},
},
@ -99,9 +109,9 @@ export const BECOME_ROLES: Record<string, BecomeRole> = {
rateLabel: 'Rate',
portfolioLabel: 'Portfolio link (IG, site)',
skillSections: [
{ title: 'Style', entries: Object.entries(GRAFFITI_STYLE_LABELS), required: true },
{ title: 'Medium', entries: Object.entries(GRAFFITI_MEDIUM_LABELS) },
{ title: 'Work Context', entries: Object.entries(GRAFFITI_CONTEXT_LABELS) },
{ title: 'Style', entries: Object.entries(GRAFFITI_STYLE_LABELS), keyMap: GRAFFITI_STYLE_KEYS, required: true },
{ title: 'Medium', entries: Object.entries(GRAFFITI_MEDIUM_LABELS), keyMap: GRAFFITI_MEDIUM_KEYS },
{ title: 'Work Context', entries: Object.entries(GRAFFITI_CONTEXT_LABELS), keyMap: GRAFFITI_CONTEXT_KEYS },
],
extraFields: [
{ tag: 'crew', label: 'Graffiti Crew', placeholder: 'Your writing crew (optional)', helper: 'Separate from your skate crew — the writers you paint with.' },
@ -127,8 +137,8 @@ export const BECOME_ROLES: Record<string, BecomeRole> = {
rateLabel: 'Booking Rate',
portfolioLabel: 'Website / EPK link',
skillSections: [
{ title: 'Genre', entries: Object.entries(MUSICIAN_GENRE_LABELS), required: true },
{ title: 'Instruments & Role', entries: Object.entries(MUSICIAN_INSTRUMENT_LABELS) },
{ title: 'Genre', entries: Object.entries(MUSICIAN_GENRE_LABELS), keyMap: MUSICIAN_GENRE_KEYS, required: true },
{ title: 'Instruments & Role', entries: Object.entries(MUSICIAN_INSTRUMENT_LABELS), keyMap: MUSICIAN_INSTRUMENT_KEYS },
],
extraFields: [
{ tag: 'act', label: 'Act Type', placeholder: 'solo / band / dj / producer' },
@ -158,9 +168,9 @@ export const BECOME_ROLES: Record<string, BecomeRole> = {
rateLabel: 'Day Rate / Fee',
portfolioLabel: 'Results archive / profile link',
skillSections: [
{ title: 'Event Formats', entries: Object.entries(JUDGE_FORMAT_LABELS), required: true },
{ title: 'Specialty', entries: Object.entries(JUDGE_SPECIALTY_LABELS) },
{ title: 'Level / Circuit', entries: Object.entries(JUDGE_LEVEL_LABELS) },
{ title: 'Event Formats', entries: Object.entries(JUDGE_FORMAT_LABELS), keyMap: JUDGE_FORMAT_KEYS, required: true },
{ title: 'Specialty', entries: Object.entries(JUDGE_SPECIALTY_LABELS), keyMap: JUDGE_SPECIALTY_KEYS },
{ title: 'Level / Circuit', entries: Object.entries(JUDGE_LEVEL_LABELS), keyMap: JUDGE_LEVEL_KEYS },
],
extraFields: [
{ tag: 'events_judged', label: 'Events Judged', placeholder: 'e.g. 40+ contests, 3 World Tour stops', helper: 'Notable events, comps or tours you have officiated.' },
@ -186,9 +196,9 @@ export const BECOME_ROLES: Record<string, BecomeRole> = {
rateLabel: 'Event Fee / Budget',
portfolioLabel: 'Past events / results link',
skillSections: [
{ title: 'Event Types', entries: Object.entries(ORGANIZER_EVENT_LABELS), required: true },
{ title: 'What You Handle', entries: Object.entries(ORGANIZER_SERVICE_LABELS) },
{ title: 'Scale', entries: Object.entries(ORGANIZER_SCALE_LABELS) },
{ title: 'Event Types', entries: Object.entries(ORGANIZER_EVENT_LABELS), keyMap: ORGANIZER_EVENT_KEYS, required: true },
{ title: 'What You Handle', entries: Object.entries(ORGANIZER_SERVICE_LABELS), keyMap: ORGANIZER_SERVICE_KEYS },
{ title: 'Scale', entries: Object.entries(ORGANIZER_SCALE_LABELS), keyMap: ORGANIZER_SCALE_KEYS },
],
extraFields: [
{ tag: 'events_hosted', label: 'Events Organized', placeholder: 'e.g. 25 jams, 3 city comps', helper: 'Notable events, comps or meetups you have run.' },
@ -214,8 +224,8 @@ export const BECOME_ROLES: Record<string, BecomeRole> = {
rateLabel: 'Rate / Hourly',
portfolioLabel: 'Portfolio link (IG, site)',
skillSections: [
{ title: 'Styles', entries: Object.entries(TATTOO_STYLE_LABELS), required: true },
{ title: 'Method', entries: Object.entries(TATTOO_METHOD_LABELS) },
{ title: 'Styles', entries: Object.entries(TATTOO_STYLE_LABELS), keyMap: TATTOO_STYLE_KEYS, required: true },
{ title: 'Method', entries: Object.entries(TATTOO_METHOD_LABELS), keyMap: TATTOO_METHOD_KEYS },
],
extraFields: [
{ tag: 'studio', label: 'Studio / Shop', placeholder: 'Studio name (optional)' },
@ -242,9 +252,9 @@ export const BECOME_ROLES: Record<string, BecomeRole> = {
rateLabel: 'Rate / Session',
portfolioLabel: 'Profile / booking link',
skillSections: [
{ title: 'Focus', entries: Object.entries(TRAINER_FOCUS_LABELS), required: true },
{ title: 'Injuries You Work With', entries: Object.entries(TRAINER_INJURY_LABELS) },
{ title: 'Credentials & Setting', entries: Object.entries(TRAINER_CREDENTIAL_LABELS) },
{ title: 'Focus', entries: Object.entries(TRAINER_FOCUS_LABELS), keyMap: TRAINER_FOCUS_KEYS, required: true },
{ title: 'Injuries You Work With', entries: Object.entries(TRAINER_INJURY_LABELS), keyMap: TRAINER_INJURY_KEYS },
{ title: 'Credentials & Setting', entries: Object.entries(TRAINER_CREDENTIAL_LABELS), keyMap: TRAINER_CREDENTIAL_KEYS },
],
extraFields: [
{ tag: 'credentials', label: 'Certifications', placeholder: 'e.g. BSc Physiotherapy, CSCS…', helper: 'List real qualifications — skaters trust verifiable credentials.' },
@ -271,9 +281,9 @@ export const BECOME_ROLES: Record<string, BecomeRole> = {
rateLabel: 'Price / Cut',
portfolioLabel: 'Cuts gallery / IG link',
skillSections: [
{ title: 'What You Offer', entries: Object.entries(BARBER_SERVICE_LABELS), required: true },
{ title: 'Signature Styles', entries: Object.entries(BARBER_STYLE_LABELS) },
{ title: 'Setting', entries: Object.entries(BARBER_SETTING_LABELS) },
{ title: 'What You Offer', entries: Object.entries(BARBER_SERVICE_LABELS), keyMap: BARBER_SERVICE_KEYS, required: true },
{ title: 'Signature Styles', entries: Object.entries(BARBER_STYLE_LABELS), keyMap: BARBER_STYLE_KEYS },
{ title: 'Setting', entries: Object.entries(BARBER_SETTING_LABELS), keyMap: BARBER_SETTING_KEYS },
],
extraFields: [
{ tag: 'studio', label: 'Shop / Studio', placeholder: 'Shop or studio name (optional)' },

View file

@ -328,3 +328,588 @@ export const STATUS_DESC_KEYS: Record<string, string> = {
closed: 'labelsUi.status.closed.description',
gone: 'labelsUi.status.gone.description',
};
// ─── Batch 4: Lab role option labels (`labOptions.*`) ────────────────────────
export const COACH_SKILL_KEYS: Record<string, string> = {
basics: 'labOptions.coach.skill.basics',
ollies: 'labOptions.coach.skill.ollies',
kickflips: 'labOptions.coach.skill.kickflips',
grinds: 'labOptions.coach.skill.grinds',
manuals: 'labOptions.coach.skill.manuals',
inverts: 'labOptions.coach.skill.inverts',
vert: 'labOptions.coach.skill.vert',
bowl: 'labOptions.coach.skill.bowl',
street: 'labOptions.coach.skill.street',
freestyle: 'labOptions.coach.skill.freestyle',
safety: 'labOptions.coach.skill.safety',
diy: 'labOptions.coach.skill.diy',
};
export const COACH_SKILL_DESC_KEYS: Record<string, string> = {
basics: 'labOptions.coach.skillDesc.basics',
ollies: 'labOptions.coach.skillDesc.ollies',
kickflips: 'labOptions.coach.skillDesc.kickflips',
grinds: 'labOptions.coach.skillDesc.grinds',
manuals: 'labOptions.coach.skillDesc.manuals',
inverts: 'labOptions.coach.skillDesc.inverts',
vert: 'labOptions.coach.skillDesc.vert',
bowl: 'labOptions.coach.skillDesc.bowl',
street: 'labOptions.coach.skillDesc.street',
freestyle: 'labOptions.coach.skillDesc.freestyle',
safety: 'labOptions.coach.skillDesc.safety',
diy: 'labOptions.coach.skillDesc.diy',
};
export const FILMING_SERVICE_KEYS: Record<string, string> = {
miniclips: 'labOptions.filmer.service.miniclips',
longparts: 'labOptions.filmer.service.longparts',
koth: 'labOptions.filmer.service.koth',
documentaries: 'labOptions.filmer.service.documentaries',
contests: 'labOptions.filmer.service.contests',
raw: 'labOptions.filmer.service.raw',
followlines: 'labOptions.filmer.service.followlines',
lines: 'labOptions.filmer.service.lines',
};
export const FILMING_SERVICE_DESC_KEYS: Record<string, string> = {
miniclips: 'labOptions.filmer.serviceDesc.miniclips',
longparts: 'labOptions.filmer.serviceDesc.longparts',
koth: 'labOptions.filmer.serviceDesc.koth',
documentaries: 'labOptions.filmer.serviceDesc.documentaries',
contests: 'labOptions.filmer.serviceDesc.contests',
raw: 'labOptions.filmer.serviceDesc.raw',
followlines: 'labOptions.filmer.serviceDesc.followlines',
lines: 'labOptions.filmer.serviceDesc.lines',
};
export const FILMING_SKILL_KEYS: Record<string, string> = {
vx1000: 'labOptions.filmer.skill.vx1000',
hvx: 'labOptions.filmer.skill.hvx',
cinema: 'labOptions.filmer.skill.cinema',
dslr: 'labOptions.filmer.skill.dslr',
actioncam: 'labOptions.filmer.skill.actioncam',
phone: 'labOptions.filmer.skill.phone',
drone: 'labOptions.filmer.skill.drone',
gimbal: 'labOptions.filmer.skill.gimbal',
fisheye: 'labOptions.filmer.skill.fisheye',
longlens: 'labOptions.filmer.skill.longlens',
'4k': 'labOptions.filmer.skill.4k',
editing: 'labOptions.filmer.skill.editing',
color: 'labOptions.filmer.skill.color',
};
export const FILMING_SKILL_DESC_KEYS: Record<string, string> = {
vx1000: 'labOptions.filmer.skillDesc.vx1000',
hvx: 'labOptions.filmer.skillDesc.hvx',
cinema: 'labOptions.filmer.skillDesc.cinema',
dslr: 'labOptions.filmer.skillDesc.dslr',
actioncam: 'labOptions.filmer.skillDesc.actioncam',
phone: 'labOptions.filmer.skillDesc.phone',
drone: 'labOptions.filmer.skillDesc.drone',
gimbal: 'labOptions.filmer.skillDesc.gimbal',
fisheye: 'labOptions.filmer.skillDesc.fisheye',
longlens: 'labOptions.filmer.skillDesc.longlens',
'4k': 'labOptions.filmer.skillDesc.4k',
editing: 'labOptions.filmer.skillDesc.editing',
color: 'labOptions.filmer.skillDesc.color',
};
export const EDITOR_SERVICE_KEYS: Record<string, string> = {
parts: 'labOptions.editor.service.parts',
montages: 'labOptions.editor.service.montages',
reels: 'labOptions.editor.service.reels',
documentaries: 'labOptions.editor.service.documentaries',
contest: 'labOptions.editor.service.contest',
brand: 'labOptions.editor.service.brand',
motion: 'labOptions.editor.service.motion',
colorgrade: 'labOptions.editor.service.colorgrade',
};
export const EDITOR_SERVICE_DESC_KEYS: Record<string, string> = {
parts: 'labOptions.editor.serviceDesc.parts',
montages: 'labOptions.editor.serviceDesc.montages',
reels: 'labOptions.editor.serviceDesc.reels',
documentaries: 'labOptions.editor.serviceDesc.documentaries',
contest: 'labOptions.editor.serviceDesc.contest',
brand: 'labOptions.editor.serviceDesc.brand',
motion: 'labOptions.editor.serviceDesc.motion',
colorgrade: 'labOptions.editor.serviceDesc.colorgrade',
};
export const EDITOR_SKILL_KEYS: Record<string, string> = {
premiere: 'labOptions.editor.skill.premiere',
resolve: 'labOptions.editor.skill.resolve',
finalcut: 'labOptions.editor.skill.finalcut',
aftereffects: 'labOptions.editor.skill.aftereffects',
luts: 'labOptions.editor.skill.luts',
vhs: 'labOptions.editor.skill.vhs',
'4k': 'labOptions.editor.skill.4k',
sound: 'labOptions.editor.skill.sound',
};
export const EDITOR_SKILL_DESC_KEYS: Record<string, string> = {
premiere: 'labOptions.editor.skillDesc.premiere',
resolve: 'labOptions.editor.skillDesc.resolve',
finalcut: 'labOptions.editor.skillDesc.finalcut',
aftereffects: 'labOptions.editor.skillDesc.aftereffects',
luts: 'labOptions.editor.skillDesc.luts',
vhs: 'labOptions.editor.skillDesc.vhs',
'4k': 'labOptions.editor.skillDesc.4k',
sound: 'labOptions.editor.skillDesc.sound',
};
export const PHOTO_SERVICE_KEYS: Record<string, string> = {
action: 'labOptions.photo.service.action',
sequence: 'labOptions.photo.service.sequence',
portraits: 'labOptions.photo.service.portraits',
editorial: 'labOptions.photo.service.editorial',
product: 'labOptions.photo.service.product',
events: 'labOptions.photo.service.events',
lifestyle: 'labOptions.photo.service.lifestyle',
prints: 'labOptions.photo.service.prints',
};
export const PHOTO_SERVICE_DESC_KEYS: Record<string, string> = {
action: 'labOptions.photo.serviceDesc.action',
sequence: 'labOptions.photo.serviceDesc.sequence',
portraits: 'labOptions.photo.serviceDesc.portraits',
editorial: 'labOptions.photo.serviceDesc.editorial',
product: 'labOptions.photo.serviceDesc.product',
events: 'labOptions.photo.serviceDesc.events',
lifestyle: 'labOptions.photo.serviceDesc.lifestyle',
prints: 'labOptions.photo.serviceDesc.prints',
};
export const PHOTO_CAMERA_KEYS: Record<string, string> = {
dslr: 'labOptions.photo.camera.dslr',
mirrorless: 'labOptions.photo.camera.mirrorless',
medium: 'labOptions.photo.camera.medium',
film35: 'labOptions.photo.camera.film35',
filmmedium: 'labOptions.photo.camera.filmmedium',
point: 'labOptions.photo.camera.point',
phone: 'labOptions.photo.camera.phone',
};
export const PHOTO_LENS_KEYS: Record<string, string> = {
fisheye: 'labOptions.photo.lens.fisheye',
wide: 'labOptions.photo.lens.wide',
standard: 'labOptions.photo.lens.standard',
telephoto: 'labOptions.photo.lens.telephoto',
macro: 'labOptions.photo.lens.macro',
zoom: 'labOptions.photo.lens.zoom',
};
export const PHOTO_LIGHTING_KEYS: Record<string, string> = {
strobes: 'labOptions.photo.lighting.strobes',
speedlights: 'labOptions.photo.lighting.speedlights',
profoto: 'labOptions.photo.lighting.profoto',
elinchrom: 'labOptions.photo.lighting.elinchrom',
godox: 'labOptions.photo.lighting.godox',
triggers: 'labOptions.photo.lighting.triggers',
continuous: 'labOptions.photo.lighting.continuous',
reflectors: 'labOptions.photo.lighting.reflectors',
naturallight: 'labOptions.photo.lighting.naturallight',
};
export const PHOTO_SUPPORT_KEYS: Record<string, string> = {
tripod: 'labOptions.photo.support.tripod',
monopod: 'labOptions.photo.support.monopod',
lightstands: 'labOptions.photo.support.lightstands',
gimbal: 'labOptions.photo.support.gimbal',
bag: 'labOptions.photo.support.bag',
laptop: 'labOptions.photo.support.laptop',
};
export const PHOTO_STYLE_KEYS: Record<string, string> = {
flashfisheye: 'labOptions.photo.style.flashfisheye',
natural: 'labOptions.photo.style.natural',
bw: 'labOptions.photo.style.bw',
film: 'labOptions.photo.style.film',
longlens: 'labOptions.photo.style.longlens',
studio: 'labOptions.photo.style.studio',
street: 'labOptions.photo.style.street',
};
export const BUILDER_SERVICE_KEYS: Record<string, string> = {
diy: 'labOptions.builder.service.diy',
concrete: 'labOptions.builder.service.concrete',
wood: 'labOptions.builder.service.wood',
bowls: 'labOptions.builder.service.bowls',
transitions: 'labOptions.builder.service.transitions',
rails: 'labOptions.builder.service.rails',
boxes: 'labOptions.builder.service.boxes',
minis: 'labOptions.builder.service.minis',
pumptrack: 'labOptions.builder.service.pumptrack',
foam: 'labOptions.builder.service.foam',
repairs: 'labOptions.builder.service.repairs',
design: 'labOptions.builder.service.design',
};
export const BUILDER_SERVICE_DESC_KEYS: Record<string, string> = {
diy: 'labOptions.builder.serviceDesc.diy',
concrete: 'labOptions.builder.serviceDesc.concrete',
wood: 'labOptions.builder.serviceDesc.wood',
bowls: 'labOptions.builder.serviceDesc.bowls',
transitions: 'labOptions.builder.serviceDesc.transitions',
rails: 'labOptions.builder.serviceDesc.rails',
boxes: 'labOptions.builder.serviceDesc.boxes',
minis: 'labOptions.builder.serviceDesc.minis',
pumptrack: 'labOptions.builder.serviceDesc.pumptrack',
foam: 'labOptions.builder.serviceDesc.foam',
repairs: 'labOptions.builder.serviceDesc.repairs',
design: 'labOptions.builder.serviceDesc.design',
};
export const BUILDER_SKILL_KEYS: Record<string, string> = {
concretework: 'labOptions.builder.skill.concretework',
welding: 'labOptions.builder.skill.welding',
carpentry: 'labOptions.builder.skill.carpentry',
formwork: 'labOptions.builder.skill.formwork',
masonry: 'labOptions.builder.skill.masonry',
coping: 'labOptions.builder.skill.coping',
surfacing: 'labOptions.builder.skill.surfacing',
earthworks: 'labOptions.builder.skill.earthworks',
};
export const BUILDER_SCALE_KEYS: Record<string, string> = {
backyard: 'labOptions.builder.scale.backyard',
diycommunity: 'labOptions.builder.scale.diycommunity',
publicpark: 'labOptions.builder.scale.publicpark',
indoor: 'labOptions.builder.scale.indoor',
event: 'labOptions.builder.scale.event',
};
export const ARTIST_SERVICE_KEYS: Record<string, string> = {
boardart: 'labOptions.artist.service.boardart',
logos: 'labOptions.artist.service.logos',
posters: 'labOptions.artist.service.posters',
merch: 'labOptions.artist.service.merch',
zines: 'labOptions.artist.service.zines',
illustration: 'labOptions.artist.service.illustration',
stickers: 'labOptions.artist.service.stickers',
webdesign: 'labOptions.artist.service.webdesign',
typography: 'labOptions.artist.service.typography',
animation: 'labOptions.artist.service.animation',
};
export const ARTIST_SERVICE_DESC_KEYS: Record<string, string> = {
boardart: 'labOptions.artist.serviceDesc.boardart',
logos: 'labOptions.artist.serviceDesc.logos',
posters: 'labOptions.artist.serviceDesc.posters',
merch: 'labOptions.artist.serviceDesc.merch',
zines: 'labOptions.artist.serviceDesc.zines',
illustration: 'labOptions.artist.serviceDesc.illustration',
stickers: 'labOptions.artist.serviceDesc.stickers',
webdesign: 'labOptions.artist.serviceDesc.webdesign',
typography: 'labOptions.artist.serviceDesc.typography',
animation: 'labOptions.artist.serviceDesc.animation',
};
export const ARTIST_MEDIUM_KEYS: Record<string, string> = {
digital: 'labOptions.artist.medium.digital',
handdrawn: 'labOptions.artist.medium.handdrawn',
screenprint: 'labOptions.artist.medium.screenprint',
paint: 'labOptions.artist.medium.paint',
collage: 'labOptions.artist.medium.collage',
photo: 'labOptions.artist.medium.photo',
'3d': 'labOptions.artist.medium.3d',
};
export const ARTIST_TOOL_KEYS: Record<string, string> = {
illustrator: 'labOptions.artist.tool.illustrator',
photoshop: 'labOptions.artist.tool.photoshop',
procreate: 'labOptions.artist.tool.procreate',
affinity: 'labOptions.artist.tool.affinity',
figma: 'labOptions.artist.tool.figma',
blender: 'labOptions.artist.tool.blender',
aftereffects: 'labOptions.artist.tool.aftereffects',
clipstudio: 'labOptions.artist.tool.clipstudio',
};
export const GRAFFITI_STYLE_KEYS: Record<string, string> = {
throwups: 'labOptions.graffiti.style.throwups',
pieces: 'labOptions.graffiti.style.pieces',
murals: 'labOptions.graffiti.style.murals',
characters: 'labOptions.graffiti.style.characters',
lettering: 'labOptions.graffiti.style.lettering',
stencil: 'labOptions.graffiti.style.stencil',
handstyles: 'labOptions.graffiti.style.handstyles',
'3dstyle': 'labOptions.graffiti.style.3dstyle',
abstract: 'labOptions.graffiti.style.abstract',
};
export const GRAFFITI_STYLE_DESC_KEYS: Record<string, string> = {
throwups: 'labOptions.graffiti.styleDesc.throwups',
pieces: 'labOptions.graffiti.styleDesc.pieces',
murals: 'labOptions.graffiti.styleDesc.murals',
characters: 'labOptions.graffiti.styleDesc.characters',
lettering: 'labOptions.graffiti.styleDesc.lettering',
stencil: 'labOptions.graffiti.styleDesc.stencil',
handstyles: 'labOptions.graffiti.styleDesc.handstyles',
'3dstyle': 'labOptions.graffiti.styleDesc.3dstyle',
abstract: 'labOptions.graffiti.styleDesc.abstract',
};
export const GRAFFITI_MEDIUM_KEYS: Record<string, string> = {
spray: 'labOptions.graffiti.medium.spray',
rollers: 'labOptions.graffiti.medium.rollers',
markers: 'labOptions.graffiti.medium.markers',
brush: 'labOptions.graffiti.medium.brush',
digital: 'labOptions.graffiti.medium.digital',
};
export const GRAFFITI_CONTEXT_KEYS: Record<string, string> = {
legalwalls: 'labOptions.graffiti.context.legalwalls',
commissions: 'labOptions.graffiti.context.commissions',
liveart: 'labOptions.graffiti.context.liveart',
skateparks: 'labOptions.graffiti.context.skateparks',
interiors: 'labOptions.graffiti.context.interiors',
workshops: 'labOptions.graffiti.context.workshops',
};
export const TATTOO_STYLE_KEYS: Record<string, string> = {
traditional: 'labOptions.tattoo.style.traditional',
neotraditional: 'labOptions.tattoo.style.neotraditional',
fineline: 'labOptions.tattoo.style.fineline',
blackwork: 'labOptions.tattoo.style.blackwork',
realism: 'labOptions.tattoo.style.realism',
lettering: 'labOptions.tattoo.style.lettering',
illustrative: 'labOptions.tattoo.style.illustrative',
skateflash: 'labOptions.tattoo.style.skateflash',
ignorant: 'labOptions.tattoo.style.ignorant',
japanese: 'labOptions.tattoo.style.japanese',
};
export const TATTOO_STYLE_DESC_KEYS: Record<string, string> = {
traditional: 'labOptions.tattoo.styleDesc.traditional',
neotraditional: 'labOptions.tattoo.styleDesc.neotraditional',
fineline: 'labOptions.tattoo.styleDesc.fineline',
blackwork: 'labOptions.tattoo.styleDesc.blackwork',
realism: 'labOptions.tattoo.styleDesc.realism',
lettering: 'labOptions.tattoo.styleDesc.lettering',
illustrative: 'labOptions.tattoo.styleDesc.illustrative',
skateflash: 'labOptions.tattoo.styleDesc.skateflash',
ignorant: 'labOptions.tattoo.styleDesc.ignorant',
japanese: 'labOptions.tattoo.styleDesc.japanese',
};
export const TATTOO_METHOD_KEYS: Record<string, string> = {
machine: 'labOptions.tattoo.method.machine',
handpoke: 'labOptions.tattoo.method.handpoke',
flash: 'labOptions.tattoo.method.flash',
custom: 'labOptions.tattoo.method.custom',
coverup: 'labOptions.tattoo.method.coverup',
};
export const TRAINER_FOCUS_KEYS: Record<string, string> = {
strength: 'labOptions.trainer.focus.strength',
mobility: 'labOptions.trainer.focus.mobility',
injuryprevention: 'labOptions.trainer.focus.injuryprevention',
rehab: 'labOptions.trainer.focus.rehab',
recovery: 'labOptions.trainer.focus.recovery',
balance: 'labOptions.trainer.focus.balance',
cardio: 'labOptions.trainer.focus.cardio',
nutrition: 'labOptions.trainer.focus.nutrition',
};
export const TRAINER_FOCUS_DESC_KEYS: Record<string, string> = {
strength: 'labOptions.trainer.focusDesc.strength',
mobility: 'labOptions.trainer.focusDesc.mobility',
injuryprevention: 'labOptions.trainer.focusDesc.injuryprevention',
rehab: 'labOptions.trainer.focusDesc.rehab',
recovery: 'labOptions.trainer.focusDesc.recovery',
balance: 'labOptions.trainer.focusDesc.balance',
cardio: 'labOptions.trainer.focusDesc.cardio',
nutrition: 'labOptions.trainer.focusDesc.nutrition',
};
export const TRAINER_INJURY_KEYS: Record<string, string> = {
ankles: 'labOptions.trainer.injury.ankles',
knees: 'labOptions.trainer.injury.knees',
wrists: 'labOptions.trainer.injury.wrists',
hips: 'labOptions.trainer.injury.hips',
back: 'labOptions.trainer.injury.back',
shoulders: 'labOptions.trainer.injury.shoulders',
heelbruise: 'labOptions.trainer.injury.heelbruise',
concussion: 'labOptions.trainer.injury.concussion',
};
export const TRAINER_CREDENTIAL_KEYS: Record<string, string> = {
physio: 'labOptions.trainer.credential.physio',
osteo: 'labOptions.trainer.credential.osteo',
certtrainer: 'labOptions.trainer.credential.certtrainer',
sportstherapist: 'labOptions.trainer.credential.sportstherapist',
massage: 'labOptions.trainer.credential.massage',
inperson: 'labOptions.trainer.credential.inperson',
remote: 'labOptions.trainer.credential.remote',
};
export const MUSICIAN_GENRE_KEYS: Record<string, string> = {
punk: 'labOptions.musician.genre.punk',
hardcore: 'labOptions.musician.genre.hardcore',
metal: 'labOptions.musician.genre.metal',
rock: 'labOptions.musician.genre.rock',
indie: 'labOptions.musician.genre.indie',
hiphop: 'labOptions.musician.genre.hiphop',
electronic: 'labOptions.musician.genre.electronic',
lofi: 'labOptions.musician.genre.lofi',
reggae: 'labOptions.musician.genre.reggae',
ska: 'labOptions.musician.genre.ska',
jazz: 'labOptions.musician.genre.jazz',
funk: 'labOptions.musician.genre.funk',
flamenco: 'labOptions.musician.genre.flamenco',
folk: 'labOptions.musician.genre.folk',
experimental: 'labOptions.musician.genre.experimental',
grunge: 'labOptions.musician.genre.grunge',
};
export const MUSICIAN_INSTRUMENT_KEYS: Record<string, string> = {
vocals: 'labOptions.musician.instrument.vocals',
guitar: 'labOptions.musician.instrument.guitar',
bass: 'labOptions.musician.instrument.bass',
drums: 'labOptions.musician.instrument.drums',
keys: 'labOptions.musician.instrument.keys',
dj: 'labOptions.musician.instrument.dj',
producer: 'labOptions.musician.instrument.producer',
sax: 'labOptions.musician.instrument.sax',
strings: 'labOptions.musician.instrument.strings',
other: 'labOptions.musician.instrument.other',
};
export const MUSICIAN_ACT_KEYS: Record<string, string> = {
solo: 'labOptions.musician.act.solo',
band: 'labOptions.musician.act.band',
dj: 'labOptions.musician.act.dj',
producer: 'labOptions.musician.act.producer',
collective: 'labOptions.musician.act.collective',
};
export const JUDGE_FORMAT_KEYS: Record<string, string> = {
game_of_skate: 'labOptions.judge.format.game_of_skate',
best_trick: 'labOptions.judge.format.best_trick',
jam: 'labOptions.judge.format.jam',
contest: 'labOptions.judge.format.contest',
street_league: 'labOptions.judge.format.street_league',
bowl: 'labOptions.judge.format.bowl',
mini_ramp: 'labOptions.judge.format.mini_ramp',
park: 'labOptions.judge.format.park',
street: 'labOptions.judge.format.street',
freestyle: 'labOptions.judge.format.freestyle',
world_tour: 'labOptions.judge.format.world_tour',
am_series: 'labOptions.judge.format.am_series',
local_jam: 'labOptions.judge.format.local_jam',
online: 'labOptions.judge.format.online',
};
export const JUDGE_SPECIALTY_KEYS: Record<string, string> = {
head_judge: 'labOptions.judge.specialty.head_judge',
scoring: 'labOptions.judge.specialty.scoring',
mc: 'labOptions.judge.specialty.mc',
format_design: 'labOptions.judge.specialty.format_design',
rules: 'labOptions.judge.specialty.rules',
brackets: 'labOptions.judge.specialty.brackets',
livestream: 'labOptions.judge.specialty.livestream',
am_scout: 'labOptions.judge.specialty.am_scout',
};
export const JUDGE_LEVEL_KEYS: Record<string, string> = {
local: 'labOptions.judge.level.local',
regional: 'labOptions.judge.level.regional',
national: 'labOptions.judge.level.national',
international: 'labOptions.judge.level.international',
pro: 'labOptions.judge.level.pro',
};
export const ORGANIZER_EVENT_KEYS: Record<string, string> = {
street_jam: 'labOptions.organizer.event.street_jam',
best_trick: 'labOptions.organizer.event.best_trick',
game_of_skate: 'labOptions.organizer.event.game_of_skate',
contest: 'labOptions.organizer.event.contest',
bowl: 'labOptions.organizer.event.bowl',
mini_ramp: 'labOptions.organizer.event.mini_ramp',
park: 'labOptions.organizer.event.park',
demo: 'labOptions.organizer.event.demo',
clinic: 'labOptions.organizer.event.clinic',
meetup: 'labOptions.organizer.event.meetup',
downhill: 'labOptions.organizer.event.downhill',
slalom: 'labOptions.organizer.event.slalom',
kids: 'labOptions.organizer.event.kids',
charity: 'labOptions.organizer.event.charity',
tour_stop: 'labOptions.organizer.event.tour_stop',
premiere: 'labOptions.organizer.event.premiere',
market: 'labOptions.organizer.event.market',
};
export const ORGANIZER_SERVICE_KEYS: Record<string, string> = {
organizing: 'labOptions.organizer.service.organizing',
hosting: 'labOptions.organizer.service.hosting',
permits: 'labOptions.organizer.service.permits',
sponsorship: 'labOptions.organizer.service.sponsorship',
promotion: 'labOptions.organizer.service.promotion',
livestream: 'labOptions.organizer.service.livestream',
sound: 'labOptions.organizer.service.sound',
ramps: 'labOptions.organizer.service.ramps',
safety: 'labOptions.organizer.service.safety',
vendors: 'labOptions.organizer.service.vendors',
media: 'labOptions.organizer.service.media',
registration: 'labOptions.organizer.service.registration',
};
export const ORGANIZER_SCALE_KEYS: Record<string, string> = {
grassroots: 'labOptions.organizer.scale.grassroots',
local: 'labOptions.organizer.scale.local',
regional: 'labOptions.organizer.scale.regional',
national: 'labOptions.organizer.scale.national',
international: 'labOptions.organizer.scale.international',
pro: 'labOptions.organizer.scale.pro',
};
export const BARBER_SERVICE_KEYS: Record<string, string> = {
haircut: 'labOptions.barber.service.haircut',
fade: 'labOptions.barber.service.fade',
beard: 'labOptions.barber.service.beard',
lineup: 'labOptions.barber.service.lineup',
colour: 'labOptions.barber.service.colour',
kids: 'labOptions.barber.service.kids',
};
export const BARBER_SERVICE_DESC_KEYS: Record<string, string> = {
haircut: 'labOptions.barber.serviceDesc.haircut',
fade: 'labOptions.barber.serviceDesc.fade',
beard: 'labOptions.barber.serviceDesc.beard',
lineup: 'labOptions.barber.serviceDesc.lineup',
colour: 'labOptions.barber.serviceDesc.colour',
kids: 'labOptions.barber.serviceDesc.kids',
};
export const BARBER_STYLE_KEYS: Record<string, string> = {
skin_fade: 'labOptions.barber.style.skin_fade',
taper: 'labOptions.barber.style.taper',
buzz: 'labOptions.barber.style.buzz',
mohawk: 'labOptions.barber.style.mohawk',
mullet: 'labOptions.barber.style.mullet',
curly: 'labOptions.barber.style.curly',
long: 'labOptions.barber.style.long',
classic: 'labOptions.barber.style.classic',
};
export const BARBER_SETTING_KEYS: Record<string, string> = {
shop: 'labOptions.barber.setting.shop',
mobile: 'labOptions.barber.setting.mobile',
event: 'labOptions.barber.setting.event',
};
export const TOUR_SKILL_KEYS: Record<string, string> = {
beginner: 'labOptions.tour.skill.beginner.label',
intermediate: 'labOptions.tour.skill.intermediate.label',
advanced: 'labOptions.tour.skill.advanced.label',
all: 'labOptions.tour.skill.all.label',
};
export const TOUR_SKILL_DESC_KEYS: Record<string, string> = {
beginner: 'labOptions.tour.skill.beginner.description',
intermediate: 'labOptions.tour.skill.intermediate.description',
advanced: 'labOptions.tour.skill.advanced.description',
all: 'labOptions.tour.skill.all.description',
};

View file

@ -33,6 +33,12 @@ import {
isValidNoffer,
type EntityType,
} from '@/lib/types';
import {
BUILDER_SERVICE_KEYS,
BUILDER_SERVICE_DESC_KEYS,
BUILDER_SKILL_KEYS,
BUILDER_SCALE_KEYS,
} from '@/lib/labelKeys';
import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils';
@ -49,11 +55,14 @@ function ChipGrid({
entries,
selected,
onToggle,
keys,
}: {
entries: [string, string][];
selected: string[];
onToggle: (key: string) => void;
keys?: Record<string, string>;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-wrap gap-2">
{entries.map(([key, label]) => (
@ -68,7 +77,7 @@ function ChipGrid({
: 'bg-gray-800 hover:bg-gray-700 border-gray-700 text-gray-300'
)}
>
{label}
{keys?.[key] ? t(keys[key]) : label}
</button>
))}
</div>
@ -472,9 +481,9 @@ export default function BecomeBuilderPage() {
: 'bg-gray-800 hover:bg-gray-700 border-gray-700'
)}
>
<div className="font-bold">{label}</div>
<div className="font-bold">{BUILDER_SERVICE_KEYS[service] ? t(BUILDER_SERVICE_KEYS[service]) : label}</div>
<div className={cn('text-xs mt-1 line-clamp-1', selectedServices.includes(service) ? 'text-black/70' : 'text-gray-400')}>
{BUILDER_SERVICE_DESCRIPTIONS[service]}
{BUILDER_SERVICE_DESC_KEYS[service] ? t(BUILDER_SERVICE_DESC_KEYS[service]) : BUILDER_SERVICE_DESCRIPTIONS[service]}
</div>
</button>
))}
@ -484,13 +493,13 @@ export default function BecomeBuilderPage() {
{/* Materials / trade skills */}
<div className="space-y-3">
<Label className="flex items-center gap-2"><Hammer className="h-4 w-4 text-amber-400" /> {t('labUi.builderForm.materialsLabel')}</Label>
<ChipGrid entries={SKILL_ENTRIES} selected={selectedSkills} onToggle={toggle(setSelectedSkills)} />
<ChipGrid entries={SKILL_ENTRIES} keys={BUILDER_SKILL_KEYS} selected={selectedSkills} onToggle={toggle(setSelectedSkills)} />
</div>
{/* Scale */}
<div className="space-y-3">
<Label className="flex items-center gap-2">📐 {t('labUi.builderForm.scaleLabel')}</Label>
<ChipGrid entries={SCALE_ENTRIES} selected={selectedScale} onToggle={toggle(setSelectedScale)} />
<ChipGrid entries={SCALE_ENTRIES} keys={BUILDER_SCALE_KEYS} selected={selectedScale} onToggle={toggle(setSelectedScale)} />
</div>
{/* Freeform "how we work" */}

View file

@ -23,6 +23,7 @@ import { PaymentReadinessNote } from '@/components/PaymentReadinessNote';
import { EntityTypeField } from '@/components/EntityTypeField';
import { CrewLinkField } from '@/components/CrewLinkField';
import { SKATE_COACH_KIND, SKATE_SPOT_KIND, COACH_SKILL_LABELS, COACH_SKILL_DESCRIPTIONS, isValidNoffer, type EntityType } from '@/lib/types';
import { COACH_SKILL_KEYS, COACH_SKILL_DESC_KEYS } from '@/lib/labelKeys';
import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils';
@ -416,9 +417,9 @@ export default function BecomeCoachPage() {
: 'bg-gray-800 hover:bg-gray-700 border-gray-700'
)}
>
<div className="font-bold">{label}</div>
<div className="font-bold">{COACH_SKILL_KEYS[skill] ? t(COACH_SKILL_KEYS[skill]) : label}</div>
<div className={cn('text-xs mt-1 line-clamp-1', selectedSkills.includes(skill) ? 'text-white/80' : 'text-gray-400')}>
{COACH_SKILL_DESCRIPTIONS[skill]}
{COACH_SKILL_DESC_KEYS[skill] ? t(COACH_SKILL_DESC_KEYS[skill]) : COACH_SKILL_DESCRIPTIONS[skill]}
</div>
</button>
))}

View file

@ -27,6 +27,7 @@ import {
isValidNoffer,
type EntityType,
} from '@/lib/types';
import { EDITOR_SERVICE_KEYS, EDITOR_SERVICE_DESC_KEYS, EDITOR_SKILL_KEYS, EDITOR_SKILL_DESC_KEYS } from '@/lib/labelKeys';
import { EntityTypeField } from '@/components/EntityTypeField';
import { CrewLinkField } from '@/components/CrewLinkField';
import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl';
@ -419,8 +420,8 @@ export default function BecomeEditorPage() {
: 'bg-gray-800 hover:bg-gray-700 border-gray-700'
)}
>
<div className="font-bold">{label}</div>
<div className="text-xs opacity-70 mt-1">{EDITOR_SERVICE_DESCRIPTIONS[service]}</div>
<div className="font-bold">{EDITOR_SERVICE_KEYS[service] ? t(EDITOR_SERVICE_KEYS[service]) : label}</div>
<div className="text-xs opacity-70 mt-1">{EDITOR_SERVICE_DESC_KEYS[service] ? t(EDITOR_SERVICE_DESC_KEYS[service]) : EDITOR_SERVICE_DESCRIPTIONS[service]}</div>
</button>
))}
</div>
@ -442,8 +443,8 @@ export default function BecomeEditorPage() {
: 'bg-gray-800 hover:bg-gray-700 border-gray-700'
)}
>
<div className="font-bold text-xs uppercase">{label}</div>
<div className="text-[10px] opacity-60 mt-0.5 line-clamp-1">{EDITOR_SKILL_DESCRIPTIONS[skill]}</div>
<div className="font-bold text-xs uppercase">{EDITOR_SKILL_KEYS[skill] ? t(EDITOR_SKILL_KEYS[skill]) : label}</div>
<div className="text-[10px] opacity-60 mt-0.5 line-clamp-1">{EDITOR_SKILL_DESC_KEYS[skill] ? t(EDITOR_SKILL_DESC_KEYS[skill]) : EDITOR_SKILL_DESCRIPTIONS[skill]}</div>
</button>
))}
</div>

View file

@ -23,6 +23,7 @@ import { PaymentReadinessNote } from '@/components/PaymentReadinessNote';
import { EntityTypeField } from '@/components/EntityTypeField';
import { CrewLinkField } from '@/components/CrewLinkField';
import { SKATE_FILMER_KIND, SKATE_SPOT_KIND, FILMING_SERVICE_LABELS, FILMING_SERVICE_DESCRIPTIONS, FILMING_SKILL_LABELS, FILMING_SKILL_DESCRIPTIONS, isValidNoffer, type EntityType } from '@/lib/types';
import { FILMING_SERVICE_KEYS, FILMING_SERVICE_DESC_KEYS, FILMING_SKILL_KEYS, FILMING_SKILL_DESC_KEYS } from '@/lib/labelKeys';
import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils';
@ -440,9 +441,9 @@ export default function BecomeFilmerPage() {
: 'bg-gray-800 hover:bg-gray-700 border-gray-700'
)}
>
<div className="font-bold">{label}</div>
<div className="font-bold">{FILMING_SERVICE_KEYS[service] ? t(FILMING_SERVICE_KEYS[service]) : label}</div>
<div className={cn('text-xs mt-1 line-clamp-1', selectedServices.includes(service) ? 'text-white/80' : 'text-gray-400')}>
{FILMING_SERVICE_DESCRIPTIONS[service]}
{FILMING_SERVICE_DESC_KEYS[service] ? t(FILMING_SERVICE_DESC_KEYS[service]) : FILMING_SERVICE_DESCRIPTIONS[service]}
</div>
</button>
))}
@ -464,9 +465,9 @@ export default function BecomeFilmerPage() {
: 'bg-gray-800 hover:bg-gray-700 border-gray-700'
)}
>
<div className="font-bold">{label}</div>
<div className="font-bold">{FILMING_SKILL_KEYS[skill] ? t(FILMING_SKILL_KEYS[skill]) : label}</div>
<div className={cn('text-xs mt-1 line-clamp-1', selectedSkills.includes(skill) ? 'text-white/80' : 'text-gray-400')}>
{FILMING_SKILL_DESCRIPTIONS[skill]}
{FILMING_SKILL_DESC_KEYS[skill] ? t(FILMING_SKILL_DESC_KEYS[skill]) : FILMING_SKILL_DESCRIPTIONS[skill]}
</div>
</button>
))}

View file

@ -35,6 +35,15 @@ import {
PHOTO_STYLE_LABELS,
isValidNoffer,
} from '@/lib/types';
import {
PHOTO_SERVICE_KEYS,
PHOTO_SERVICE_DESC_KEYS,
PHOTO_CAMERA_KEYS,
PHOTO_LENS_KEYS,
PHOTO_LIGHTING_KEYS,
PHOTO_SUPPORT_KEYS,
PHOTO_STYLE_KEYS,
} from '@/lib/labelKeys';
import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils';
@ -55,11 +64,14 @@ function ChipGrid({
entries,
selected,
onToggle,
keys,
}: {
entries: [string, string][];
selected: string[];
onToggle: (key: string) => void;
keys?: Record<string, string>;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-wrap gap-2">
{entries.map(([key, label]) => (
@ -74,7 +86,7 @@ function ChipGrid({
: 'bg-gray-800 hover:bg-gray-700 border-gray-700 text-gray-300'
)}
>
{label}
{keys?.[key] ? t(keys[key]) : label}
</button>
))}
</div>
@ -493,9 +505,9 @@ export default function BecomePhotographerPage() {
: 'bg-gray-800 hover:bg-gray-700 border-gray-700'
)}
>
<div className="font-bold">{label}</div>
<div className="font-bold">{PHOTO_SERVICE_KEYS[service] ? t(PHOTO_SERVICE_KEYS[service]) : label}</div>
<div className={cn('text-xs mt-1 line-clamp-1', selectedServices.includes(service) ? 'text-white/80' : 'text-gray-400')}>
{PHOTO_SERVICE_DESCRIPTIONS[service]}
{PHOTO_SERVICE_DESC_KEYS[service] ? t(PHOTO_SERVICE_DESC_KEYS[service]) : PHOTO_SERVICE_DESCRIPTIONS[service]}
</div>
</button>
))}
@ -505,31 +517,31 @@ export default function BecomePhotographerPage() {
{/* Camera */}
<div className="space-y-3">
<Label className="flex items-center gap-2"><Camera className="h-4 w-4 text-indigo-400" /> {t('labUi.photoForm.cameraLabel')}</Label>
<ChipGrid entries={CAMERA_ENTRIES} selected={selectedCameras} onToggle={toggle(setSelectedCameras)} />
<ChipGrid entries={CAMERA_ENTRIES} keys={PHOTO_CAMERA_KEYS} selected={selectedCameras} onToggle={toggle(setSelectedCameras)} />
</div>
{/* Lenses */}
<div className="space-y-3">
<Label className="flex items-center gap-2"><Aperture className="h-4 w-4 text-indigo-400" /> {t('labUi.photoForm.lensesLabel')}</Label>
<ChipGrid entries={LENS_ENTRIES} selected={selectedLenses} onToggle={toggle(setSelectedLenses)} />
<ChipGrid entries={LENS_ENTRIES} keys={PHOTO_LENS_KEYS} selected={selectedLenses} onToggle={toggle(setSelectedLenses)} />
</div>
{/* Lighting */}
<div className="space-y-3">
<Label className="flex items-center gap-2">💡 {t('labUi.photoForm.lightingLabel')}</Label>
<ChipGrid entries={LIGHTING_ENTRIES} selected={selectedLighting} onToggle={toggle(setSelectedLighting)} />
<ChipGrid entries={LIGHTING_ENTRIES} keys={PHOTO_LIGHTING_KEYS} selected={selectedLighting} onToggle={toggle(setSelectedLighting)} />
</div>
{/* Support */}
<div className="space-y-3">
<Label className="flex items-center gap-2">🎒 {t('labUi.photoForm.supportLabel')}</Label>
<ChipGrid entries={SUPPORT_ENTRIES} selected={selectedSupport} onToggle={toggle(setSelectedSupport)} />
<ChipGrid entries={SUPPORT_ENTRIES} keys={PHOTO_SUPPORT_KEYS} selected={selectedSupport} onToggle={toggle(setSelectedSupport)} />
</div>
{/* Style */}
<div className="space-y-3">
<Label className="flex items-center gap-2">🎨 {t('labUi.photoForm.styleLabel')}</Label>
<ChipGrid entries={STYLE_ENTRIES} selected={selectedStyles} onToggle={toggle(setSelectedStyles)} />
<ChipGrid entries={STYLE_ENTRIES} keys={PHOTO_STYLE_KEYS} selected={selectedStyles} onToggle={toggle(setSelectedStyles)} />
</div>
{/* Freeform gear list */}

View file

@ -22,6 +22,21 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
import { isAdminPubkey } from '@/lib/admins';
import { COACH_SKILL_LABELS, COACH_SKILL_DESCRIPTIONS, FILMING_SERVICE_LABELS, FILMING_SERVICE_DESCRIPTIONS, FILMING_SKILL_LABELS, FILMING_SKILL_DESCRIPTIONS, EDITOR_SERVICE_LABELS, EDITOR_SERVICE_DESCRIPTIONS, EDITOR_SKILL_LABELS, EDITOR_SKILL_DESCRIPTIONS, PHOTO_SERVICE_LABELS, PHOTO_SERVICE_DESCRIPTIONS, PHOTO_CAMERA_LABELS, PHOTO_LENS_LABELS, PHOTO_LIGHTING_LABELS, PHOTO_SUPPORT_LABELS, PHOTO_STYLE_LABELS, BUILDER_SERVICE_LABELS, BUILDER_SERVICE_DESCRIPTIONS, BUILDER_SKILL_LABELS, BUILDER_SCALE_LABELS, ARTIST_SERVICE_LABELS, ARTIST_SERVICE_DESCRIPTIONS, ARTIST_MEDIUM_LABELS, ARTIST_TOOL_LABELS, GRAFFITI_STYLE_LABELS, GRAFFITI_STYLE_DESCRIPTIONS, GRAFFITI_MEDIUM_LABELS, GRAFFITI_CONTEXT_LABELS, TATTOO_STYLE_LABELS, TATTOO_STYLE_DESCRIPTIONS, TATTOO_METHOD_LABELS, TRAINER_FOCUS_LABELS, TRAINER_FOCUS_DESCRIPTIONS, TRAINER_INJURY_LABELS, TRAINER_CREDENTIAL_LABELS, MUSICIAN_GENRE_LABELS, MUSICIAN_INSTRUMENT_LABELS, JUDGE_FORMAT_LABELS, JUDGE_SPECIALTY_LABELS, JUDGE_LEVEL_LABELS, SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND, SKATE_PHOTOGRAPHER_KIND, SKATE_BUILDER_KIND, SKATE_ARTIST_KIND, SKATE_GRAFFITI_KIND, SKATE_TATTOO_KIND, SKATE_TRAINER_KIND, SKATE_MUSICIAN_KIND, SKATE_JUDGE_KIND, SKATE_ORGANIZER_KIND, ORGANIZER_EVENT_LABELS, ORGANIZER_SERVICE_LABELS, ORGANIZER_SCALE_LABELS, BARBER_SERVICE_LABELS, BARBER_SERVICE_DESCRIPTIONS, BARBER_STYLE_LABELS, BARBER_SETTING_LABELS, SKATE_BARBER_KIND } from '@/lib/types';
import {
COACH_SKILL_KEYS, COACH_SKILL_DESC_KEYS,
FILMING_SERVICE_KEYS, FILMING_SERVICE_DESC_KEYS, FILMING_SKILL_KEYS, FILMING_SKILL_DESC_KEYS,
EDITOR_SERVICE_KEYS, EDITOR_SERVICE_DESC_KEYS, EDITOR_SKILL_KEYS, EDITOR_SKILL_DESC_KEYS,
PHOTO_SERVICE_KEYS, PHOTO_SERVICE_DESC_KEYS, PHOTO_CAMERA_KEYS, PHOTO_LENS_KEYS, PHOTO_LIGHTING_KEYS, PHOTO_SUPPORT_KEYS, PHOTO_STYLE_KEYS,
BUILDER_SERVICE_KEYS, BUILDER_SERVICE_DESC_KEYS, BUILDER_SKILL_KEYS, BUILDER_SCALE_KEYS,
ARTIST_SERVICE_KEYS, ARTIST_SERVICE_DESC_KEYS, ARTIST_MEDIUM_KEYS, ARTIST_TOOL_KEYS,
GRAFFITI_STYLE_KEYS, GRAFFITI_STYLE_DESC_KEYS, GRAFFITI_MEDIUM_KEYS, GRAFFITI_CONTEXT_KEYS,
TATTOO_STYLE_KEYS, TATTOO_STYLE_DESC_KEYS, TATTOO_METHOD_KEYS,
TRAINER_FOCUS_KEYS, TRAINER_FOCUS_DESC_KEYS, TRAINER_INJURY_KEYS, TRAINER_CREDENTIAL_KEYS,
MUSICIAN_GENRE_KEYS, MUSICIAN_INSTRUMENT_KEYS,
JUDGE_FORMAT_KEYS, JUDGE_SPECIALTY_KEYS, JUDGE_LEVEL_KEYS,
ORGANIZER_EVENT_KEYS, ORGANIZER_SERVICE_KEYS, ORGANIZER_SCALE_KEYS,
BARBER_SERVICE_KEYS, BARBER_SERVICE_DESC_KEYS, BARBER_STYLE_KEYS, BARBER_SETTING_KEYS,
} from '@/lib/labelKeys';
import { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/sanitizeUrl';
import { useTranslation } from '@/contexts/language';
import { OfficialBadge } from '@/components/OfficialBadge';
@ -212,8 +227,42 @@ export default function CoachDetailPage() {
barber: BARBER_SERVICE_DESCRIPTIONS,
};
const getSkillLabel = (skill: string) => LABELS_BY_TYPE[profile.profileType]?.[skill] || skill;
const getSkillDescription = (skill: string): string | undefined => DESCRIPTIONS_BY_TYPE[profile.profileType]?.[skill];
const LABEL_KEYS_BY_TYPE: Record<string, Record<string, string>> = {
coach: COACH_SKILL_KEYS,
filmer: { ...FILMING_SERVICE_KEYS, ...FILMING_SKILL_KEYS },
editor: { ...EDITOR_SERVICE_KEYS, ...EDITOR_SKILL_KEYS },
photographer: { ...PHOTO_SERVICE_KEYS, ...PHOTO_CAMERA_KEYS, ...PHOTO_LENS_KEYS, ...PHOTO_LIGHTING_KEYS, ...PHOTO_SUPPORT_KEYS, ...PHOTO_STYLE_KEYS },
builder: { ...BUILDER_SERVICE_KEYS, ...BUILDER_SKILL_KEYS, ...BUILDER_SCALE_KEYS },
artist: { ...ARTIST_SERVICE_KEYS, ...ARTIST_MEDIUM_KEYS, ...ARTIST_TOOL_KEYS },
graffiti: { ...GRAFFITI_STYLE_KEYS, ...GRAFFITI_MEDIUM_KEYS, ...GRAFFITI_CONTEXT_KEYS },
tattoo: { ...TATTOO_STYLE_KEYS, ...TATTOO_METHOD_KEYS },
trainer: { ...TRAINER_FOCUS_KEYS, ...TRAINER_INJURY_KEYS, ...TRAINER_CREDENTIAL_KEYS },
musician: { ...MUSICIAN_GENRE_KEYS, ...MUSICIAN_INSTRUMENT_KEYS },
judge: { ...JUDGE_FORMAT_KEYS, ...JUDGE_SPECIALTY_KEYS, ...JUDGE_LEVEL_KEYS },
organizer: { ...ORGANIZER_EVENT_KEYS, ...ORGANIZER_SERVICE_KEYS, ...ORGANIZER_SCALE_KEYS },
barber: { ...BARBER_SERVICE_KEYS, ...BARBER_STYLE_KEYS, ...BARBER_SETTING_KEYS },
};
const DESC_KEYS_BY_TYPE: Record<string, Record<string, string>> = {
coach: COACH_SKILL_DESC_KEYS,
filmer: { ...FILMING_SERVICE_DESC_KEYS, ...FILMING_SKILL_DESC_KEYS },
editor: { ...EDITOR_SERVICE_DESC_KEYS, ...EDITOR_SKILL_DESC_KEYS },
photographer: PHOTO_SERVICE_DESC_KEYS,
builder: BUILDER_SERVICE_DESC_KEYS,
artist: ARTIST_SERVICE_DESC_KEYS,
graffiti: GRAFFITI_STYLE_DESC_KEYS,
tattoo: TATTOO_STYLE_DESC_KEYS,
trainer: TRAINER_FOCUS_DESC_KEYS,
barber: BARBER_SERVICE_DESC_KEYS,
};
const getSkillLabel = (skill: string) => {
const key = LABEL_KEYS_BY_TYPE[profile.profileType]?.[skill];
return key ? t(key) : (LABELS_BY_TYPE[profile.profileType]?.[skill] || skill);
};
const getSkillDescription = (skill: string): string | undefined => {
const key = DESC_KEYS_BY_TYPE[profile.profileType]?.[skill];
return key ? t(key) : DESCRIPTIONS_BY_TYPE[profile.profileType]?.[skill];
};
const TYPE_NOUNS: Record<string, string> = {
coach: t('labUi.detail.noun.coach'), filmer: t('labUi.detail.noun.filmer'), editor: t('labUi.detail.noun.editor'), photographer: t('labUi.detail.noun.photographer'), builder: t('labUi.detail.noun.builder'),

View file

@ -21,7 +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 { TOUR_CATEGORY_KEYS, TOUR_CATEGORY_DESC_KEYS, TOUR_SPOT_TYPE_KEYS, TOUR_SKILL_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';
@ -1398,7 +1398,7 @@ export default function CreateTourPage({ existing }: { existing?: SkateTour } =
style={skillLevel === key ? { backgroundColor: TOUR_CATEGORIES[tourCategory].color } : undefined}
>
<span className="text-lg mb-1">{level.emoji}</span>
<span className="text-xs">{level.label}</span>
<span className="text-xs">{TOUR_SKILL_KEYS[key] ? t(TOUR_SKILL_KEYS[key]) : level.label}</span>
</Button>
))}
</div>

View file

@ -101,7 +101,7 @@ const DATE_WINDOW_LABELS_SHORT: Record<DateWindow, string> = {
month: 'placesUi.dateWindow.monthShort',
};
import { EVENT_TYPE_KEYS, TOUR_CATEGORY_KEYS } from '@/lib/labelKeys';
import { EVENT_TYPE_KEYS, TOUR_CATEGORY_KEYS, TOUR_SKILL_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);
@ -666,7 +666,7 @@ function FilterDrawer({
: `bg-white/5 border-white/10 text-gray-400 ${accent.chipHover}`
)}
>
{info.emoji} {info.label}
{info.emoji} {TOUR_SKILL_KEYS[key] ? t(TOUR_SKILL_KEYS[key]) : info.label}
</button>
);
})}

View file

@ -50,6 +50,44 @@ import {
BARBER_STYLE_LABELS,
BARBER_SETTING_LABELS,
} from '@/lib/types';
import {
COACH_SKILL_KEYS,
FILMING_SERVICE_KEYS,
FILMING_SKILL_KEYS,
EDITOR_SERVICE_KEYS,
EDITOR_SKILL_KEYS,
PHOTO_SERVICE_KEYS,
PHOTO_CAMERA_KEYS,
PHOTO_LENS_KEYS,
PHOTO_LIGHTING_KEYS,
PHOTO_SUPPORT_KEYS,
PHOTO_STYLE_KEYS,
BUILDER_SERVICE_KEYS,
BUILDER_SKILL_KEYS,
BUILDER_SCALE_KEYS,
ARTIST_SERVICE_KEYS,
ARTIST_MEDIUM_KEYS,
ARTIST_TOOL_KEYS,
GRAFFITI_STYLE_KEYS,
GRAFFITI_MEDIUM_KEYS,
GRAFFITI_CONTEXT_KEYS,
TATTOO_STYLE_KEYS,
TATTOO_METHOD_KEYS,
TRAINER_FOCUS_KEYS,
TRAINER_INJURY_KEYS,
TRAINER_CREDENTIAL_KEYS,
MUSICIAN_GENRE_KEYS,
MUSICIAN_INSTRUMENT_KEYS,
JUDGE_FORMAT_KEYS,
JUDGE_SPECIALTY_KEYS,
JUDGE_LEVEL_KEYS,
ORGANIZER_EVENT_KEYS,
ORGANIZER_SERVICE_KEYS,
ORGANIZER_SCALE_KEYS,
BARBER_SERVICE_KEYS,
BARBER_STYLE_KEYS,
BARBER_SETTING_KEYS,
} from '@/lib/labelKeys';
import {
GraduationCap,
Search,
@ -193,6 +231,23 @@ const SERVICE_TO_PROFILE_TYPE: Record<ServiceType, LabProfile['profileType']> =
barbering: 'barber',
};
/** Lab option translation keys per service type (merged across each craft's maps). */
const KEYS_BY_TYPE: Record<ServiceType, Record<string, string>> = {
coaching: COACH_SKILL_KEYS,
filming: { ...FILMING_SERVICE_KEYS, ...FILMING_SKILL_KEYS },
editing: { ...EDITOR_SERVICE_KEYS, ...EDITOR_SKILL_KEYS },
photography: { ...PHOTO_SERVICE_KEYS, ...PHOTO_CAMERA_KEYS, ...PHOTO_LENS_KEYS, ...PHOTO_LIGHTING_KEYS, ...PHOTO_SUPPORT_KEYS, ...PHOTO_STYLE_KEYS },
building: { ...BUILDER_SERVICE_KEYS, ...BUILDER_SKILL_KEYS, ...BUILDER_SCALE_KEYS },
art: { ...ARTIST_SERVICE_KEYS, ...ARTIST_MEDIUM_KEYS, ...ARTIST_TOOL_KEYS },
graffiti: { ...GRAFFITI_STYLE_KEYS, ...GRAFFITI_MEDIUM_KEYS, ...GRAFFITI_CONTEXT_KEYS },
tattoo: { ...TATTOO_STYLE_KEYS, ...TATTOO_METHOD_KEYS },
training: { ...TRAINER_FOCUS_KEYS, ...TRAINER_INJURY_KEYS, ...TRAINER_CREDENTIAL_KEYS },
music: { ...MUSICIAN_GENRE_KEYS, ...MUSICIAN_INSTRUMENT_KEYS },
judging: { ...JUDGE_FORMAT_KEYS, ...JUDGE_SPECIALTY_KEYS, ...JUDGE_LEVEL_KEYS },
organizing: { ...ORGANIZER_EVENT_KEYS, ...ORGANIZER_SERVICE_KEYS, ...ORGANIZER_SCALE_KEYS },
barbering: { ...BARBER_SERVICE_KEYS, ...BARBER_STYLE_KEYS, ...BARBER_SETTING_KEYS },
};
// ─── Per-category accent colours ─────────────────────────────────────────────
const ACCENT = {
@ -438,7 +493,9 @@ interface ToolbarProps {
onClearAll: () => void;
}
function getSkillLabelForType(skill: string, serviceType: ServiceType): string {
function getSkillLabelForType(skill: string, serviceType: ServiceType, t: (key: string) => string): string {
const key = KEYS_BY_TYPE[serviceType]?.[skill];
if (key) return t(key);
if (serviceType === 'coaching') return COACH_SKILL_LABELS[skill] || skill;
if (serviceType === 'filming') return FILMING_SERVICE_LABELS[skill] || FILMING_SKILL_LABELS[skill] || skill;
if (serviceType === 'photography') return PHOTO_ALL_LABELS[skill] || skill;
@ -494,7 +551,7 @@ function LabToolbar({
// Build active filter chip labels
const activeChips: { key: string; label: string }[] = [];
selectedSkills.forEach((s) => {
activeChips.push({ key: s, label: getSkillLabelForType(s, serviceType) });
activeChips.push({ key: s, label: getSkillLabelForType(s, serviceType, t) });
});
const hasNonSkillFilters = trustFilter !== 'all' || sortBy !== 'recent';
@ -865,7 +922,7 @@ function FilterDrawer({
: `bg-white/5 border-white/10 text-gray-400 ${accent.chipHover}`
)}
>
{label}
{getSkillLabelForType(key, serviceType, t) || label}
</button>
);
})}
@ -945,7 +1002,7 @@ function ProfileCard({ profile }: { profile: LabProfile }) {
};
const TypeIcon = TYPE_ICONS[serviceType];
const getSkillLabel = (skill: string) => getSkillLabelForType(skill, serviceType);
const getSkillLabel = (skill: string) => getSkillLabelForType(skill, serviceType, t);
return (
<Link to={`/coach/${profile.pubkey}/${profile.identifier}`}>

View file

@ -12,7 +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 { TOUR_CATEGORY_KEYS, TOUR_SPOT_TYPE_KEYS, TOUR_SKILL_KEYS } from '@/lib/labelKeys';
import { ZapButton } from '@/components/ZapButton';
import { ProBadge } from '@/components/ProBadge';
import { AddToCalendar } from '@/components/AddToCalendar';
@ -130,6 +130,7 @@ export default function TourDetailPage() {
}
const skillInfo = tour.skillLevel ? TOUR_SKILL_LEVELS[tour.skillLevel as keyof typeof TOUR_SKILL_LEVELS] : null;
const skillKey = tour.skillLevel ? TOUR_SKILL_KEYS[tour.skillLevel] : undefined;
const categoryInfo = TOUR_CATEGORIES[tour.category];
const accent = categoryInfo.color;
@ -396,7 +397,7 @@ export default function TourDetailPage() {
</div>
<p className="font-bold text-white flex items-center gap-1">
<span>{skillInfo.emoji}</span>
{skillInfo.label}
{skillKey ? t(skillKey) : skillInfo.label}
</p>
</div>
)}