From 7347f874ad639fac5ed9f4540b30a3ee88bd85dc Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Fri, 2 Oct 2026 15:57:44 +0700 Subject: [PATCH] =?UTF-8?q?i18n(es):=20batch=204=20=E2=80=94=20Lab=20optio?= =?UTF-8?q?n=20labels,=20Sasquatch=20static=20FAQ,=20fee=20tiers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- src/components/CraftProfileForm.tsx | 5 +- src/components/PlatformFeePayment.tsx | 106 ++- src/components/SasquatchGuide.tsx | 209 ++--- src/components/TourCard.tsx | 5 +- src/hooks/usePurchasePro.ts | 34 +- src/i18n/batch4/feesUi.ts | 142 ++++ src/i18n/batch4/index.ts | 17 + src/i18n/batch4/labOptions.ts | 1124 +++++++++++++++++++++++++ src/i18n/batch4/sasquatchChat.ts | 210 +++++ src/i18n/en.ts | 3 +- src/i18n/es.ts | 3 +- src/lib/becomeRoles.tsx | 54 +- src/lib/labelKeys.ts | 585 +++++++++++++ src/pages/BecomeBuilderPage.tsx | 19 +- src/pages/BecomeCoachPage.tsx | 5 +- src/pages/BecomeEditorPage.tsx | 9 +- src/pages/BecomeFilmerPage.tsx | 9 +- src/pages/BecomePhotographerPage.tsx | 28 +- src/pages/CoachDetailPage.tsx | 53 +- src/pages/CreateTourPage.tsx | 4 +- src/pages/EventsPage.tsx | 4 +- src/pages/LabPage.tsx | 65 +- src/pages/TourDetailPage.tsx | 5 +- 23 files changed, 2487 insertions(+), 211 deletions(-) create mode 100644 src/i18n/batch4/feesUi.ts create mode 100644 src/i18n/batch4/index.ts create mode 100644 src/i18n/batch4/labOptions.ts create mode 100644 src/i18n/batch4/sasquatchChat.ts diff --git a/src/components/CraftProfileForm.tsx b/src/components/CraftProfileForm.tsx index 0d8a752..dd2dd98 100644 --- a/src/components/CraftProfileForm.tsx +++ b/src/components/CraftProfileForm.tsx @@ -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; /** If true, at least one must be selected before publishing. */ required?: boolean; } @@ -433,6 +435,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
{section.entries.map(([key, label]) => { const active = selected.includes(key); + const labelKey = section.keyMap?.[key]; return ( ); })} diff --git a/src/components/PlatformFeePayment.tsx b/src/components/PlatformFeePayment.tsx index fdf878d..db22b3e 100644 --- a/src/components/PlatformFeePayment.tsx +++ b/src/components/PlatformFeePayment.tsx @@ -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 = { + featuredEvent: 'feesUi.feeFeaturedEventLabel', + labProfile: 'feesUi.feeLabProfileLabel', + featuredShop: 'feesUi.feeFeaturedShopLabel', + featuredTour: 'feesUi.feeFeaturedTourLabel', +}; +const FEE_DESC_KEYS: Record = { + featuredEvent: 'feesUi.feeFeaturedEventDesc', + labProfile: 'feesUi.feeLabProfileDesc', + featuredShop: 'feesUi.feeFeaturedShopDesc', + featuredTour: 'feesUi.feeFeaturedTourDesc', +}; +const FEE_BENEFIT_KEYS: Record = { + featuredEvent: 'feesUi.feeFeaturedEventBenefit', + labProfile: 'feesUi.feeLabProfileBenefit', + featuredShop: 'feesUi.feeFeaturedShopBenefit', + featuredTour: 'feesUi.feeFeaturedTourBenefit', +}; +const DURATION_LABEL_KEYS: Record = { + '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('idle'); const [invoice, setInvoice] = useState(null); @@ -76,8 +110,12 @@ export function PlatformFeePayment({ type, onSuccess, context, entityCoord }: Pl
-

{meta.label} — Active

-

{daysRemaining} day{daysRemaining !== 1 ? 's' : ''} remaining

+

{t('feesUi.metaActive', { label: metaLabel })}

+

+ {daysRemaining === 1 + ? t('feesUi.daysRemainingOne', { count: daysRemaining }) + : t('feesUi.daysRemainingMany', { count: daysRemaining })} +

); @@ -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
-

{meta.emoji} {meta.label} Active!

-

{meta.benefit}

+

{meta.emoji} {t('feesUi.activeBang', { label: metaLabel })}

+

{metaBenefit}

); @@ -203,8 +241,8 @@ export function PlatformFeePayment({ type, onSuccess, context, entityCoord }: Pl
-

{meta.emoji} {meta.label}

-

{meta.description}

+

{meta.emoji} {metaLabel}

+

{metaDesc}

@@ -214,18 +252,18 @@ export function PlatformFeePayment({ type, onSuccess, context, entityCoord }: Pl {paymentState === 'idle' && (

- Choose duration + {t('feesUi.chooseDuration')}

- {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 ( ); })} @@ -256,11 +294,11 @@ export function PlatformFeePayment({ type, onSuccess, context, entityCoord }: Pl
- {meta.benefit} + {metaBenefit}
- Valid for {tier.label} + {t('feesUi.validFor')} {tierLabel}
@@ -272,7 +310,7 @@ export function PlatformFeePayment({ type, onSuccess, context, entityCoord }: Pl

⚡ {amountSats.toLocaleString()} sats

- ~${(amountSats * 0.0006).toFixed(2)} USD · to {recipientName} · {tier.label} + {t('feesUi.amountLine', { usd: (amountSats * 0.0006).toFixed(2), name: recipientName, tier: tierLabel })}

@@ -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" > - Pay {amountSats.toLocaleString()} sats to Continue + {t('feesUi.payToContinue', { amount: amountSats.toLocaleString() })} )} @@ -291,7 +329,7 @@ export function PlatformFeePayment({ type, onSuccess, context, entityCoord }: Pl

- {paymentState === 'generating' ? 'Generating invoice…' : 'Processing payment…'} + {paymentState === 'generating' ? t('feesUi.generating') : t('feesUi.processing')}

)} @@ -300,23 +338,23 @@ export function PlatformFeePayment({ type, onSuccess, context, entityCoord }: Pl
- Lightning invoice QR + {t('feesUi.invoiceQrAlt')}
{(webln || activeNWC) && ( )} -

Scan with any Lightning wallet

+

{t('feesUi.scanAny')}

)} @@ -324,7 +362,7 @@ export function PlatformFeePayment({ type, onSuccess, context, entityCoord }: Pl

{error}

)} diff --git a/src/components/SasquatchGuide.tsx b/src/components/SasquatchGuide.tsx index f1c0214..f8a456a 100644 --- a/src/components/SasquatchGuide.tsx +++ b/src/components/SasquatchGuide.tsx @@ -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; + +// 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 = { + 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; 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; - 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>) ?? []; - 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 = { - '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 = { + // 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 { diff --git a/src/components/TourCard.tsx b/src/components/TourCard.tsx index 15a5eb2..69483fd 100644 --- a/src/components/TourCard.tsx +++ b/src/components/TourCard.tsx @@ -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 && ( {skillInfo.emoji} - {skillInfo.label} + {skillKey ? t(skillKey) : skillInfo.label} )}
diff --git a/src/hooks/usePurchasePro.ts b/src/hooks/usePurchasePro.ts index 00fff31..e698e93 100644 --- a/src/hooks/usePurchasePro.ts +++ b/src/hooks/usePurchasePro.ts @@ -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 }), []); diff --git a/src/i18n/batch4/feesUi.ts b/src/i18n/batch4/feesUi.ts new file mode 100644 index 0000000..d987fe9 --- /dev/null +++ b/src/i18n/batch4/feesUi.ts @@ -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; diff --git a/src/i18n/batch4/index.ts b/src/i18n/batch4/index.ts new file mode 100644 index 0000000..4085e86 --- /dev/null +++ b/src/i18n/batch4/index.ts @@ -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, +}; diff --git a/src/i18n/batch4/labOptions.ts b/src/i18n/batch4/labOptions.ts new file mode 100644 index 0000000..1bc6272 --- /dev/null +++ b/src/i18n/batch4/labOptions.ts @@ -0,0 +1,1124 @@ +/** + * Batch 4 — Lab role option labels (`labOptions.*`). + * + * Translates the Lab `*_LABELS` / `*_DESCRIPTIONS` maps in `src/lib/types.ts` + * (coach/filmer/editor/photo/builder/artist/graffiti/tattoo/trainer/musician/ + * judge/organizer/barber) and `TOUR_SKILL_LEVELS`. Keys must exist in BOTH + * languages. Skate trick names stay English; brand/tool proper nouns stay as-is. + */ +const labOptions = { + en: { + coach: { + skill: { + basics: 'Basics', + ollies: 'Ollies', + kickflips: 'Kickflips', + grinds: 'Grinds & Slides', + manuals: 'Manuals', + inverts: 'Inverts', + vert: 'Vert/Ramp', + bowl: 'Bowl', + street: 'Street', + freestyle: 'Freestyle', + safety: 'Safety', + diy: 'DIY Skate Modules', + }, + skillDesc: { + basics: 'Pushing, stance, balance, turning', + ollies: 'Ollie and variations (nollie, fakie)', + kickflips: 'Kickflip, heelflip, and variations', + grinds: '50-50, 5-0, boardslide, and more', + manuals: 'Manual, nose manual, combos', + inverts: 'Invert, handplant, eggplant, andrecht', + vert: 'Half-pipe, quarter-pipe, drop-ins', + bowl: 'Bowl carving, pumping, lines', + street: 'Street obstacles and creativity', + freestyle: 'Flatground tricks and flow', + safety: 'How to fall, protective gear, injury prevention', + diy: 'Building ramps, rails, ledges, and other skate obstacles', + }, + }, + filmer: { + service: { + miniclips: 'Mini Clips', + longparts: 'Long Video Parts', + koth: 'King of the Road Style', + documentaries: 'Documentaries', + contests: 'Filming for Contests', + raw: 'Raw Footage', + followlines: 'Follow / Line Filming', + lines: 'Line & B-Roll Filming', + }, + serviceDesc: { + miniclips: '15-60 sec clips for social media', + longparts: '3-10 min full video parts with music', + koth: 'Adventure/challenge style skate videos', + documentaries: 'Skate culture and story-driven content', + contests: 'Professional contest and competition coverage', + raw: 'Unedited multi-angle footage', + followlines: 'Fisheye follow-cam for lines', + lines: 'Long-lens lines and cinematic b-roll', + }, + skill: { + vx1000: 'VX1000', + hvx: 'HVX / HPX', + cinema: 'Cinema Camera', + dslr: 'DSLR / Mirrorless', + actioncam: 'GoPro / Action Cam', + phone: 'Phone Filming', + drone: 'Drone / Aerial', + gimbal: 'Gimbal / Stabilizer', + fisheye: 'Fisheye Lens', + longlens: 'Long Lens', + '4k': '4K / High Frame Rate', + editing: 'Video Editing', + color: 'Color Grading', + }, + skillDesc: { + vx1000: 'Classic VX1000 skateboarding aesthetic', + hvx: 'Panasonic HVX/HPX standard-def / HD look', + cinema: 'Cinema camera (FX3, C-series, RED, BMPCC)', + dslr: 'High quality DSLR or mirrorless camera', + actioncam: 'GoPro / action cam POV and mounts', + phone: 'Mobile filming with modern smartphones', + drone: 'Aerial and drone footage', + gimbal: 'Stabilized gimbal movement', + fisheye: 'Wide-angle fisheye lens shots', + longlens: 'Long lens for detailed shots', + '4k': '4K resolution and high frame rate slow-mo', + editing: 'Professional video editing and post-production', + color: 'Color correction and grading', + }, + }, + editor: { + service: { + parts: 'Video Parts', + montages: 'Montages', + reels: 'Reels / Short Clips', + documentaries: 'Documentaries', + contest: 'Contest Edits', + brand: 'Brand / Promo Videos', + motion: 'Motion Graphics', + colorgrade: 'Color Grading Only', + }, + serviceDesc: { + parts: 'Full skate video parts with soundtrack', + montages: 'Compilation edits from raw footage', + reels: '15–60 sec social media clips & reels', + documentaries: 'Story-driven skate culture films', + contest: 'Recap edits for contests and events', + brand: 'Promotional content for brands and shops', + motion: 'Titles, transitions, effects, and graphics', + colorgrade: 'Colour correction and LUT-based grading', + }, + skill: { + premiere: 'Adobe Premiere', + resolve: 'DaVinci Resolve', + finalcut: 'Final Cut Pro', + aftereffects: 'After Effects', + luts: 'Custom LUTs', + vhs: 'VHS / Retro Aesthetic', + '4k': '4K & RAW Workflows', + sound: 'Sound Design / Mix', + }, + skillDesc: { + premiere: 'Adobe Premiere Pro editing', + resolve: 'DaVinci Resolve editing & grading', + finalcut: 'Final Cut Pro editing', + aftereffects: 'Motion graphics and compositing', + luts: 'Custom colour look development', + vhs: 'VHS glitch, super-8, retro film looks', + '4k': 'High-res RAW/LOG camera workflows', + sound: 'Music sync, SFX, audio mastering', + }, + }, + photo: { + service: { + action: 'Action / Trick Shots', + sequence: 'Sequence Shots', + portraits: 'Portraits', + editorial: 'Editorial / Magazine', + product: 'Product / Shop Photography', + events: 'Event Coverage', + lifestyle: 'Lifestyle / Documentary', + prints: 'Prints & Zines', + }, + serviceDesc: { + action: 'Peak-action stills of tricks and lines', + sequence: 'Multi-frame trick sequences', + portraits: 'Rider portraits and team shots', + editorial: 'Magazine-ready editorial spreads', + product: 'Deck, gear and shop product photography', + events: 'Comp, jam and premiere coverage', + lifestyle: 'Behind-the-scenes and culture documentary', + prints: 'Physical prints, zines and photo books', + }, + camera: { + dslr: 'DSLR', + mirrorless: 'Mirrorless', + medium: 'Medium Format', + film35: '35mm Film', + filmmedium: 'Medium Format Film', + point: 'Point & Shoot', + phone: 'Phone', + }, + lens: { + fisheye: 'Fisheye', + wide: 'Wide Angle', + standard: 'Standard / Prime', + telephoto: 'Telephoto / Long Lens', + macro: 'Macro', + zoom: 'Zoom', + }, + lighting: { + strobes: 'Studio Strobes', + speedlights: 'Speedlights / Flashes', + profoto: 'Profoto', + elinchrom: 'Elinchrom', + godox: 'Godox', + triggers: 'Remote Triggers', + continuous: 'Continuous / LED', + reflectors: 'Reflectors / Diffusers', + naturallight: 'Natural Light Only', + }, + support: { + tripod: 'Tripod', + monopod: 'Monopod', + lightstands: 'Light Stands', + gimbal: 'Gimbal', + bag: 'Mobile Rig / Bag', + laptop: 'On-site Tethering', + }, + style: { + flashfisheye: 'Flash Fisheye', + natural: 'Natural Light', + bw: 'Black & White', + film: 'Film Aesthetic', + longlens: 'Long Lens', + studio: 'Studio / Lit', + street: 'Street / Documentary', + }, + }, + builder: { + service: { + diy: 'DIY Spots', + concrete: 'Concrete Parks', + wood: 'Wooden Ramps', + bowls: 'Bowls & Pools', + transitions: 'Transitions & Quarters', + rails: 'Rails & Ledges', + boxes: 'Manny Pads & Boxes', + minis: 'Mini Ramps', + pumptrack: 'Pump Tracks', + foam: 'Foam Pits / Indoor', + repairs: 'Repairs & Resurfacing', + design: 'Design & Consulting', + }, + serviceDesc: { + diy: 'Community DIY spots and guerrilla builds', + concrete: 'Poured-concrete skateparks and features', + wood: 'Wooden ramps, launch boxes and obstacles', + bowls: 'Bowls, pools and deep transition', + transitions: 'Quarterpipes, banks and transition', + rails: 'Steel rails, ledges and grind features', + boxes: 'Manny pads, funboxes and street features', + minis: 'Mini ramps and backyard setups', + pumptrack: 'Pump tracks and flow lines', + foam: 'Foam pits, resi and indoor training features', + repairs: 'Fixing, patching and resurfacing existing spots', + design: 'Plans, drawings and build consulting', + }, + skill: { + concretework: 'Concrete / Shotcrete', + welding: 'Welding / Steel Fab', + carpentry: 'Carpentry / Woodwork', + formwork: 'Formwork & Rebar', + masonry: 'Masonry / Blockwork', + coping: 'Coping (pool / steel)', + surfacing: 'Skatelite / Surfacing', + earthworks: 'Earthworks / Excavation', + }, + scale: { + backyard: 'Backyard / Mini', + diycommunity: 'Community DIY', + publicpark: 'Full Public Park', + indoor: 'Indoor Facility', + event: 'Event / Contest Course', + }, + }, + artist: { + service: { + boardart: 'Board Graphics', + logos: 'Logos & Branding', + posters: 'Posters & Flyers', + merch: 'Merch & Apparel', + zines: 'Zines & Print', + illustration: 'Illustration', + stickers: 'Stickers & Decals', + webdesign: 'Web / Digital', + typography: 'Typography / Lettering', + animation: 'Animation / Motion', + }, + serviceDesc: { + boardart: 'Deck graphics and griptape art', + logos: 'Brand identity, logos and wordmarks', + posters: 'Event posters, flyers and gig art', + merch: 'T-shirts, hoodies and apparel graphics', + zines: 'Zines, editorial and print layout', + illustration: 'Custom illustration and character work', + stickers: 'Sticker packs, decals and slaps', + webdesign: 'Websites, digital and UI', + typography: 'Hand lettering and custom type', + animation: 'Motion graphics and animation', + }, + medium: { + digital: 'Digital / Vector', + handdrawn: 'Hand-Drawn', + screenprint: 'Screen Print', + paint: 'Paint / Acrylic', + collage: 'Collage / Mixed', + photo: 'Photo / Photoshop', + '3d': '3D / Render', + }, + tool: { + illustrator: 'Adobe Illustrator', + photoshop: 'Adobe Photoshop', + procreate: 'Procreate', + affinity: 'Affinity', + figma: 'Figma', + blender: 'Blender', + aftereffects: 'After Effects', + clipstudio: 'Clip Studio', + }, + }, + graffiti: { + style: { + throwups: 'Throw-ups', + pieces: 'Pieces / Burners', + murals: 'Murals', + characters: 'Characters', + lettering: 'Lettering / Wildstyle', + stencil: 'Stencil', + handstyles: 'Handstyles / Tags', + '3dstyle': '3D Style', + abstract: 'Abstract', + }, + styleDesc: { + throwups: 'Quick two-color fills', + pieces: 'Full-color pieces and burners', + murals: 'Large-scale murals and productions', + characters: 'Character and figure work', + lettering: 'Wildstyle and letter structure', + stencil: 'Stencil and street art', + handstyles: 'Tags and handstyles', + '3dstyle': '3D and anamorphic lettering', + abstract: 'Abstract and freestyle', + }, + medium: { + spray: 'Spray Cans', + rollers: 'Rollers / Emulsion', + markers: 'Markers / Inks', + brush: 'Brush / Paint', + digital: 'Digital / Procreate', + }, + context: { + legalwalls: 'Legal Walls', + commissions: 'Commissions', + liveart: 'Live Painting', + skateparks: 'Skatepark Murals', + interiors: 'Interiors / Shops', + workshops: 'Workshops', + }, + }, + tattoo: { + style: { + traditional: 'Traditional / Old School', + neotraditional: 'Neo-Traditional', + fineline: 'Fine Line', + blackwork: 'Blackwork', + realism: 'Realism', + lettering: 'Lettering / Script', + illustrative: 'Illustrative', + skateflash: 'Skate Flash', + ignorant: 'Ignorant / Handpoke Style', + japanese: 'Japanese / Irezumi', + }, + styleDesc: { + traditional: 'Bold lines, classic Americana', + neotraditional: 'Traditional with modern detail', + fineline: 'Delicate single-needle work', + blackwork: 'Solid black and pattern', + realism: 'Photo-realistic and portraits', + lettering: 'Script, lettering and quotes', + illustrative: 'Illustrative and graphic', + skateflash: 'Skate-culture flash designs', + ignorant: 'Ignorant / naive handpoke style', + japanese: 'Japanese traditional / irezumi', + }, + method: { + machine: 'Machine', + handpoke: 'Hand-Poke / Stick & Poke', + flash: 'Flash Available', + custom: 'Custom Designs', + coverup: 'Cover-ups', + }, + }, + trainer: { + focus: { + strength: 'Strength & Conditioning', + mobility: 'Mobility & Flexibility', + injuryprevention: 'Injury Prevention', + rehab: 'Rehab / Physio', + recovery: 'Recovery & Bodywork', + balance: 'Balance & Stability', + cardio: 'Cardio / Endurance', + nutrition: 'Nutrition Guidance', + }, + focusDesc: { + strength: 'Building strength and power for skating', + mobility: 'Range of motion and flexibility', + injuryprevention: 'Prehab and reducing injury risk', + rehab: 'Post-injury physiotherapy and rehab', + recovery: 'Massage, bodywork and recovery', + balance: 'Proprioception, balance and stability', + cardio: 'Conditioning and endurance', + nutrition: 'Fuelling and nutrition advice', + }, + injury: { + ankles: 'Ankles', + knees: 'Knees', + wrists: 'Wrists', + hips: 'Hips', + back: 'Back / Spine', + shoulders: 'Shoulders', + heelbruise: 'Heel Bruises', + concussion: 'Concussion Protocol', + }, + credential: { + physio: 'Licensed Physiotherapist', + osteo: 'Osteopath', + certtrainer: 'Certified Trainer', + sportstherapist: 'Sports Therapist', + massage: 'Massage Therapist', + inperson: 'In-Person', + remote: 'Remote / Online', + }, + }, + musician: { + genre: { + punk: 'Punk', + hardcore: 'Hardcore', + metal: 'Metal', + rock: 'Rock', + indie: 'Indie / Alt', + hiphop: 'Hip-Hop / Rap', + electronic: 'Electronic', + lofi: 'Lo-Fi / Beats', + reggae: 'Reggae / Dub', + ska: 'Ska', + jazz: 'Jazz', + funk: 'Funk / Soul', + flamenco: 'Flamenco', + folk: 'Folk / Acoustic', + experimental: 'Experimental', + grunge: 'Grunge', + }, + instrument: { + vocals: 'Vocals', + guitar: 'Guitar', + bass: 'Bass', + drums: 'Drums', + keys: 'Keys / Synth', + dj: 'DJ / Turntables', + producer: 'Producer / Beats', + sax: 'Sax / Brass', + strings: 'Strings', + other: 'Other', + }, + act: { + solo: 'Solo Artist', + band: 'Band', + dj: 'DJ', + producer: 'Producer', + collective: 'Collective', + }, + }, + judge: { + format: { + game_of_skate: 'Game of S.K.A.T.E.', + best_trick: 'Best Trick', + jam: 'Jam Format', + contest: 'Contest / Comp', + street_league: 'Street League Style', + bowl: 'Bowl / Vert', + mini_ramp: 'Mini Ramp', + park: 'Park', + street: 'Street', + freestyle: 'Freestyle', + world_tour: 'World Destruction Tour', + am_series: 'Am Series / Qualifiers', + local_jam: 'Local Jam / Grassroots', + online: 'Online / IG Contest', + }, + specialty: { + head_judge: 'Head Judge', + scoring: 'Scoring / Tabulation', + mc: 'MC / Announcer', + format_design: 'Format Design', + rules: 'Rules / Officiating', + brackets: 'Brackets / Heats', + livestream: 'Livestream Judging', + am_scout: 'Am Scouting', + }, + level: { + local: 'Local', + regional: 'Regional', + national: 'National', + international: 'International', + pro: 'Pro Circuit', + }, + }, + organizer: { + event: { + street_jam: 'Street Jam', + best_trick: 'Best Trick', + game_of_skate: 'Game of S.K.A.T.E.', + contest: 'Contest / Comp', + bowl: 'Bowl / Vert', + mini_ramp: 'Mini Ramp', + park: 'Park Session', + demo: 'Pro Demo', + clinic: 'Clinic / Workshop', + meetup: 'Meetup / Cruise', + downhill: 'Downhill / Race', + slalom: 'Slalom', + kids: 'Kids / Youth', + charity: 'Charity / Fundraiser', + tour_stop: 'Tour Stop', + premiere: 'Video Premiere', + market: 'Market / Art Show', + }, + service: { + organizing: 'Full Organizing', + hosting: 'Hosting / MC', + permits: 'Permits & Council', + sponsorship: 'Sponsorship / Brand Deals', + promotion: 'Promotion & Marketing', + livestream: 'Livestream / Coverage', + sound: 'Sound & Music', + ramps: 'Ramps & Obstacles', + safety: 'Safety & First Aid', + vendors: 'Food & Vendors', + media: 'Photo / Video Coverage', + registration: 'Sign-ups & Brackets', + }, + scale: { + grassroots: 'Grassroots', + local: 'Local', + regional: 'Regional', + national: 'National', + international: 'International', + pro: 'Pro Circuit', + }, + }, + barber: { + service: { + haircut: 'Haircut', + fade: 'Fade', + beard: 'Beard Trim', + lineup: 'Line-up / Shape-up', + colour: 'Colour & Dye', + kids: "Kids' Cuts", + }, + serviceDesc: { + haircut: 'A classic cut — clippers and scissors.', + fade: 'A graduated fade — low, mid or high.', + beard: 'Beard trim, shape and tidy-up.', + lineup: 'Clean edges around the hairline and neck.', + colour: 'Colour, bleach, dye and toning.', + kids: 'Patient cuts for younger skaters.', + }, + style: { + skin_fade: 'Skin Fade', + taper: 'Taper', + buzz: 'Buzz Cut', + mohawk: 'Mohawk', + mullet: 'Mullet', + curly: 'Curls / Afro', + long: 'Long Hair / Layers', + classic: 'Classic Scissor Cut', + }, + setting: { + shop: 'Shop / Barbershop', + mobile: 'Mobile / House Calls', + event: 'Skate Events & Comps', + }, + }, + tour: { + skill: { + beginner: { label: 'Beginner', description: 'New to skating or basic tricks' }, + intermediate: { label: 'Intermediate', description: 'Comfortable with most terrain' }, + advanced: { label: 'Advanced', description: 'Experienced skater, gnarly spots' }, + all: { label: 'All Levels', description: 'Suitable for everyone' }, + }, + }, + }, + es: { + coach: { + skill: { + basics: 'Fundamentos', + ollies: 'Ollies', + kickflips: 'Kickflips', + grinds: 'Grinds y slides', + manuals: 'Manuals', + inverts: 'Inverts', + vert: 'Vert/Rampa', + bowl: 'Bowl', + street: 'Calle', + freestyle: 'Freestyle', + safety: 'Seguridad', + diy: 'Módulos de skate DIY', + }, + skillDesc: { + basics: 'Empuje, postura, equilibrio, giros', + ollies: 'Ollie y variaciones (nollie, fakie)', + kickflips: 'Kickflip, heelflip y variaciones', + grinds: '50-50, 5-0, boardslide y más', + manuals: 'Manual, nose manual, combos', + inverts: 'Invert, handplant, eggplant, andrecht', + vert: 'Half-pipe, quarter-pipe, drop-ins', + bowl: 'Bowl, carving, pumping, líneas', + street: 'Obstáculos de calle y creatividad', + freestyle: 'Trucos en llano y flow', + safety: 'Cómo caer, protección y prevención de lesiones', + diy: 'Construir rampas, rails, bordillos y otros obstáculos de skate', + }, + }, + filmer: { + service: { + miniclips: 'Mini clips', + longparts: 'Vídeo-partes largas', + koth: 'Estilo King of the Road', + documentaries: 'Documentales', + contests: 'Grabación para concursos', + raw: 'Metraje en bruto', + followlines: 'Grabación follow / líneas', + lines: 'Grabación de líneas y B-roll', + }, + serviceDesc: { + miniclips: 'Clips de 15-60 s para redes sociales', + longparts: 'Vídeo-partes de 3-10 min con música', + koth: 'Vídeos de skate estilo aventura/reto', + documentaries: 'Cultura del skate y contenido narrativo', + contests: 'Cobertura profesional de concursos y competiciones', + raw: 'Metraje sin editar desde varios ángulos', + followlines: 'Follow-cam ojo de pez para líneas', + lines: 'Líneas con teleobjetivo y b-roll cinematográfico', + }, + skill: { + vx1000: 'VX1000', + hvx: 'HVX / HPX', + cinema: 'Cámara de cine', + dslr: 'DSLR / sin espejo', + actioncam: 'GoPro / cámara de acción', + phone: 'Grabación con móvil', + drone: 'Dron / aéreo', + gimbal: 'Gimbal / estabilizador', + fisheye: 'Objetivo ojo de pez', + longlens: 'Teleobjetivo', + '4k': '4K / alta tasa de fotogramas', + editing: 'Edición de vídeo', + color: 'Etalonaje', + }, + skillDesc: { + vx1000: 'Estética clásica del skate con VX1000', + hvx: 'Look Panasonic HVX/HPX en definición estándar / HD', + cinema: 'Cámara de cine (FX3, serie C, RED, BMPCC)', + dslr: 'Cámara DSLR o sin espejo de alta calidad', + actioncam: 'POV y montajes con GoPro / cámara de acción', + phone: 'Grabación móvil con smartphones modernos', + drone: 'Metraje aéreo y con dron', + gimbal: 'Movimiento estabilizado con gimbal', + fisheye: 'Tomas con objetivo ojo de pez gran angular', + longlens: 'Teleobjetivo para tomas detalladas', + '4k': 'Resolución 4K y cámara lenta a alta tasa de fotogramas', + editing: 'Edición de vídeo y postproducción profesionales', + color: 'Corrección de color y etalonaje', + }, + }, + editor: { + service: { + parts: 'Vídeo-partes', + montages: 'Montajes', + reels: 'Reels / clips cortos', + documentaries: 'Documentales', + contest: 'Ediciones de concurso', + brand: 'Vídeos de marca / promocionales', + motion: 'Gráficos en movimiento', + colorgrade: 'Solo etalonaje', + }, + serviceDesc: { + parts: 'Vídeo-partes completas de skate con banda sonora', + montages: 'Montajes recopilatorios a partir de metraje en bruto', + reels: 'Clips y reels de 15–60 s para redes sociales', + documentaries: 'Películas de cultura del skate con narrativa', + contest: 'Ediciones resumen para concursos y eventos', + brand: 'Contenido promocional para marcas y tiendas', + motion: 'Rótulos, transiciones, efectos y gráficos', + colorgrade: 'Corrección de color y etalonaje con LUT', + }, + skill: { + premiere: 'Adobe Premiere', + resolve: 'DaVinci Resolve', + finalcut: 'Final Cut Pro', + aftereffects: 'After Effects', + luts: 'LUT personalizadas', + vhs: 'VHS / estética retro', + '4k': 'Flujos de trabajo 4K y RAW', + sound: 'Diseño de sonido / mezcla', + }, + skillDesc: { + premiere: 'Edición con Adobe Premiere Pro', + resolve: 'Edición y etalonaje con DaVinci Resolve', + finalcut: 'Edición con Final Cut Pro', + aftereffects: 'Gráficos en movimiento y composición', + luts: 'Desarrollo de looks de color personalizados', + vhs: 'Looks retro de VHS, super-8 y película', + '4k': 'Flujos de trabajo RAW/LOG en alta resolución', + sound: 'Sincronización musical, efectos de sonido y masterización', + }, + }, + photo: { + service: { + action: 'Acción / trucos', + sequence: 'Secuencias', + portraits: 'Retratos', + editorial: 'Editorial / revista', + product: 'Fotografía de producto / tienda', + events: 'Cobertura de eventos', + lifestyle: 'Estilo de vida / documental', + prints: 'Copias y fanzines', + }, + serviceDesc: { + action: 'Fotos del momento álgido de trucos y líneas', + sequence: 'Secuencias de trucos en varios fotogramas', + portraits: 'Retratos de riders y fotos de equipo', + editorial: 'Dobles páginas editoriales listas para revista', + product: 'Fotografía de tablas, material y producto de tienda', + events: 'Cobertura de competiciones, jams y estrenos', + lifestyle: 'Documental de making-of y cultura', + prints: 'Copias físicas, fanzines y fotolibros', + }, + camera: { + dslr: 'DSLR', + mirrorless: 'Sin espejo', + medium: 'Formato medio', + film35: 'Película 35 mm', + filmmedium: 'Película de formato medio', + point: 'Compacta', + phone: 'Móvil', + }, + lens: { + fisheye: 'Ojo de pez', + wide: 'Gran angular', + standard: 'Estándar / fija', + telephoto: 'Teleobjetivo', + macro: 'Macro', + zoom: 'Zoom', + }, + lighting: { + strobes: 'Flashes de estudio', + speedlights: 'Flashes de zapata', + profoto: 'Profoto', + elinchrom: 'Elinchrom', + godox: 'Godox', + triggers: 'Disparadores remotos', + continuous: 'Continua / LED', + reflectors: 'Reflectores / difusores', + naturallight: 'Solo luz natural', + }, + support: { + tripod: 'Trípode', + monopod: 'Monopie', + lightstands: 'Soportes de luz', + gimbal: 'Gimbal', + bag: 'Equipo móvil / bolsa', + laptop: 'Tethered en el lugar', + }, + style: { + flashfisheye: 'Ojo de pez con flash', + natural: 'Luz natural', + bw: 'Blanco y negro', + film: 'Estética de película', + longlens: 'Teleobjetivo', + studio: 'Estudio / iluminado', + street: 'Calle / documental', + }, + }, + builder: { + service: { + diy: 'Spots DIY', + concrete: 'Parks de hormigón', + wood: 'Rampas de madera', + bowls: 'Bowls y piscinas', + transitions: 'Transiciones y quarters', + rails: 'Rails y bordillos', + boxes: 'Manny pads y cajas', + minis: 'Mini rampas', + pumptrack: 'Pump tracks', + foam: 'Fosos de espuma / indoor', + repairs: 'Reparaciones y revestimientos', + design: 'Diseño y asesoría', + }, + serviceDesc: { + diy: 'Spots DIY comunitarios y construcciones guerrilleras', + concrete: 'Skateparks y módulos de hormigón vertido', + wood: 'Rampas de madera, cajas de lanzamiento y obstáculos', + bowls: 'Bowls, piscinas y transición profunda', + transitions: 'Quarterpipes, bank y transición', + rails: 'Rails de acero, bordillos y módulos de grind', + boxes: 'Manny pads, funboxes y módulos de calle', + minis: 'Mini rampas y montajes de patio', + pumptrack: 'Pump tracks y líneas de flow', + foam: 'Fosos de espuma, resi y módulos indoor de entrenamiento', + repairs: 'Arreglo, reparación y revestimiento de spots existentes', + design: 'Planos, dibujos y asesoría de construcción', + }, + skill: { + concretework: 'Hormigón / gunita', + welding: 'Soldadura / fabricación de acero', + carpentry: 'Carpintería / madera', + formwork: 'Encofrado y armadura', + masonry: 'Albañilería / bloques', + coping: 'Coping (piscina / acero)', + surfacing: 'Skatelite / revestimiento', + earthworks: 'Movimiento de tierras / excavación', + }, + scale: { + backyard: 'Patio / mini', + diycommunity: 'DIY comunitario', + publicpark: 'Park público completo', + indoor: 'Instalación indoor', + event: 'Circuito de evento / concurso', + }, + }, + artist: { + service: { + boardart: 'Gráficos de tabla', + logos: 'Logotipos e identidad', + posters: 'Carteles y flyers', + merch: 'Merch y ropa', + zines: 'Fanzines e impresión', + illustration: 'Ilustración', + stickers: 'Pegatinas y calcas', + webdesign: 'Web / digital', + typography: 'Tipografía / lettering', + animation: 'Animación / motion', + }, + serviceDesc: { + boardart: 'Gráficos de tabla y arte en lija', + logos: 'Identidad de marca, logotipos y logotipos tipográficos', + posters: 'Carteles de eventos, flyers y arte para conciertos', + merch: 'Gráficos para camisetas, sudaderas y ropa', + zines: 'Fanzines, maquetación editorial e impresión', + illustration: 'Ilustración personalizada y diseño de personajes', + stickers: 'Packs de pegatinas, calcas y slaps', + webdesign: 'Webs, digital e interfaz', + typography: 'Rotulación a mano y tipografía personalizada', + animation: 'Gráficos en movimiento y animación', + }, + medium: { + digital: 'Digital / vectorial', + handdrawn: 'A mano', + screenprint: 'Serigrafía', + paint: 'Pintura / acrílico', + collage: 'Collage / mixto', + photo: 'Foto / Photoshop', + '3d': '3D / render', + }, + tool: { + illustrator: 'Adobe Illustrator', + photoshop: 'Adobe Photoshop', + procreate: 'Procreate', + affinity: 'Affinity', + figma: 'Figma', + blender: 'Blender', + aftereffects: 'After Effects', + clipstudio: 'Clip Studio', + }, + }, + graffiti: { + style: { + throwups: 'Throw-ups', + pieces: 'Pieces / burners', + murals: 'Murales', + characters: 'Personajes', + lettering: 'Lettering / wildstyle', + stencil: 'Estarcido', + handstyles: 'Handstyles / tags', + '3dstyle': 'Estilo 3D', + abstract: 'Abstracto', + }, + styleDesc: { + throwups: 'Rellenos rápidos a dos colores', + pieces: 'Pieces y burners a todo color', + murals: 'Murales y producciones a gran escala', + characters: 'Trabajo de personajes y figuras', + lettering: 'Wildstyle y estructura de letras', + stencil: 'Estarcido y arte urbano', + handstyles: 'Tags y handstyles', + '3dstyle': 'Lettering 3D y anamórfico', + abstract: 'Abstracto y freestyle', + }, + medium: { + spray: 'Botes de spray', + rollers: 'Rodillos / emulsión', + markers: 'Rotuladores / tintas', + brush: 'Brocha / pintura', + digital: 'Digital / Procreate', + }, + context: { + legalwalls: 'Muros legales', + commissions: 'Encargos', + liveart: 'Pintura en vivo', + skateparks: 'Murales en skateparks', + interiors: 'Interiores / tiendas', + workshops: 'Talleres', + }, + }, + tattoo: { + style: { + traditional: 'Tradicional / old school', + neotraditional: 'Neotradicional', + fineline: 'Línea fina', + blackwork: 'Blackwork', + realism: 'Realismo', + lettering: 'Lettering / caligrafía', + illustrative: 'Ilustrativo', + skateflash: 'Flash de skate', + ignorant: 'Ignorant / estilo handpoke', + japanese: 'Japonés / irezumi', + }, + styleDesc: { + traditional: 'Líneas gruesas, americana clásica', + neotraditional: 'Tradicional con detalle moderno', + fineline: 'Trabajo delicado a una sola aguja', + blackwork: 'Negro sólido y patrón', + realism: 'Fotorrealismo y retratos', + lettering: 'Caligrafía, lettering y frases', + illustrative: 'Ilustrativo y gráfico', + skateflash: 'Diseños flash de la cultura del skate', + ignorant: 'Estilo ignorant / naive handpoke', + japanese: 'Tradicional japonés / irezumi', + }, + method: { + machine: 'Máquina', + handpoke: 'Handpoke / stick & poke', + flash: 'Flash disponible', + custom: 'Diseños personalizados', + coverup: 'Cover-ups', + }, + }, + trainer: { + focus: { + strength: 'Fuerza y acondicionamiento', + mobility: 'Movilidad y flexibilidad', + injuryprevention: 'Prevención de lesiones', + rehab: 'Rehabilitación / fisio', + recovery: 'Recuperación y masaje', + balance: 'Equilibrio y estabilidad', + cardio: 'Cardio / resistencia', + nutrition: 'Asesoría nutricional', + }, + focusDesc: { + strength: 'Desarrollar fuerza y potencia para el skate', + mobility: 'Rango de movimiento y flexibilidad', + injuryprevention: 'Prehabilitación y reducción del riesgo de lesiones', + rehab: 'Fisioterapia y rehabilitación tras una lesión', + recovery: 'Masaje, terapia corporal y recuperación', + balance: 'Propiocepción, equilibrio y estabilidad', + cardio: 'Acondicionamiento y resistencia', + nutrition: 'Consejos de alimentación y nutrición', + }, + injury: { + ankles: 'Tobillos', + knees: 'Rodillas', + wrists: 'Muñecas', + hips: 'Caderas', + back: 'Espalda / columna', + shoulders: 'Hombros', + heelbruise: 'Contusiones en el talón', + concussion: 'Protocolo de conmoción', + }, + credential: { + physio: 'Fisioterapeuta titulado', + osteo: 'Osteópata', + certtrainer: 'Entrenador certificado', + sportstherapist: 'Terapeuta deportivo', + massage: 'Masajista', + inperson: 'Presencial', + remote: 'Remoto / online', + }, + }, + musician: { + genre: { + punk: 'Punk', + hardcore: 'Hardcore', + metal: 'Metal', + rock: 'Rock', + indie: 'Indie / alternativo', + hiphop: 'Hip-hop / rap', + electronic: 'Electrónica', + lofi: 'Lo-fi / beats', + reggae: 'Reggae / dub', + ska: 'Ska', + jazz: 'Jazz', + funk: 'Funk / soul', + flamenco: 'Flamenco', + folk: 'Folk / acústico', + experimental: 'Experimental', + grunge: 'Grunge', + }, + instrument: { + vocals: 'Voz', + guitar: 'Guitarra', + bass: 'Bajo', + drums: 'Batería', + keys: 'Teclados / sintetizador', + dj: 'DJ / platos', + producer: 'Productor / beats', + sax: 'Saxo / vientos', + strings: 'Cuerdas', + other: 'Otro', + }, + act: { + solo: 'Artista solista', + band: 'Banda', + dj: 'DJ', + producer: 'Productor', + collective: 'Colectivo', + }, + }, + judge: { + format: { + game_of_skate: 'Game of S.K.A.T.E.', + best_trick: 'Best Trick', + jam: 'Formato jam', + contest: 'Concurso / competición', + street_league: 'Estilo Street League', + bowl: 'Bowl / vert', + mini_ramp: 'Mini rampa', + park: 'Park', + street: 'Calle', + freestyle: 'Freestyle', + world_tour: 'World Destruction Tour', + am_series: 'Serie Am / clasificatorios', + local_jam: 'Jam local / base', + online: 'Concurso online / IG', + }, + specialty: { + head_judge: 'Juez principal', + scoring: 'Puntuación / tabulación', + mc: 'MC / presentador', + format_design: 'Diseño de formato', + rules: 'Reglamento / arbitraje', + brackets: 'Cuadros / mangas', + livestream: 'Jurado en directo', + am_scout: 'Detección de amateurs', + }, + level: { + local: 'Local', + regional: 'Regional', + national: 'Nacional', + international: 'Internacional', + pro: 'Circuito pro', + }, + }, + organizer: { + event: { + street_jam: 'Jam de calle', + best_trick: 'Best Trick', + game_of_skate: 'Game of S.K.A.T.E.', + contest: 'Concurso / competición', + bowl: 'Bowl / vert', + mini_ramp: 'Mini rampa', + park: 'Sesión de park', + demo: 'Demo pro', + clinic: 'Clase / taller', + meetup: 'Quedada / ruta', + downhill: 'Descenso / carrera', + slalom: 'Eslalon', + kids: 'Niños / jóvenes', + charity: 'Benéfico / recaudación', + tour_stop: 'Parada de gira', + premiere: 'Estreno de vídeo', + market: 'Mercadillo / exposición', + }, + service: { + organizing: 'Organización completa', + hosting: 'Presentación / MC', + permits: 'Permisos y ayuntamiento', + sponsorship: 'Patrocinio / acuerdos de marca', + promotion: 'Promoción y marketing', + livestream: 'Directo / cobertura', + sound: 'Sonido y música', + ramps: 'Rampas y obstáculos', + safety: 'Seguridad y primeros auxilios', + vendors: 'Comida y puestos', + media: 'Cobertura de foto / vídeo', + registration: 'Inscripciones y cuadros', + }, + scale: { + grassroots: 'Base', + local: 'Local', + regional: 'Regional', + national: 'Nacional', + international: 'Internacional', + pro: 'Circuito pro', + }, + }, + barber: { + service: { + haircut: 'Corte de pelo', + fade: 'Degradado', + beard: 'Arreglo de barba', + lineup: 'Perfilado / línea', + colour: 'Color y tinte', + kids: 'Cortes para niños', + }, + serviceDesc: { + haircut: 'Un corte clásico: máquina y tijeras.', + fade: 'Un degradado progresivo: bajo, medio o alto.', + beard: 'Arreglo, perfilado y recorte de barba.', + lineup: 'Bordes limpios en la línea del pelo y el cuello.', + colour: 'Color, decoloración, tinte y matización.', + kids: 'Cortes con paciencia para los skaters más jóvenes.', + }, + style: { + skin_fade: 'Skin fade', + taper: 'Taper', + buzz: 'Rapado', + mohawk: 'Cresta', + mullet: 'Mullet', + curly: 'Rizos / afro', + long: 'Pelo largo / capas', + classic: 'Corte clásico a tijera', + }, + setting: { + shop: 'Local / barbería', + mobile: 'Móvil / a domicilio', + event: 'Eventos y competiciones de skate', + }, + }, + tour: { + skill: { + beginner: { label: 'Principiante', description: 'Nuevo en el skate o trucos básicos' }, + intermediate: { label: 'Intermedio', description: 'Cómodo en la mayoría de terrenos' }, + advanced: { label: 'Avanzado', description: 'Skaters con experiencia, spots complicados' }, + all: { label: 'Todos los niveles', description: 'Apto para todos' }, + }, + }, + }, +}; + +export default labOptions; diff --git a/src/i18n/batch4/sasquatchChat.ts b/src/i18n/batch4/sasquatchChat.ts new file mode 100644 index 0000000..c9e7075 --- /dev/null +++ b/src/i18n/batch4/sasquatchChat.ts @@ -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; diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 2db196b..dd5c1c2 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -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 }; diff --git a/src/i18n/es.ts b/src/i18n/es.ts index c0bc6ad..4967950 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -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 }; diff --git a/src/lib/becomeRoles.tsx b/src/lib/becomeRoles.tsx index 984d7d8..3742c5f 100644 --- a/src/lib/becomeRoles.tsx +++ b/src/lib/becomeRoles.tsx @@ -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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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)' }, diff --git a/src/lib/labelKeys.ts b/src/lib/labelKeys.ts index cb793a3..49b497e 100644 --- a/src/lib/labelKeys.ts +++ b/src/lib/labelKeys.ts @@ -328,3 +328,588 @@ export const STATUS_DESC_KEYS: Record = { closed: 'labelsUi.status.closed.description', gone: 'labelsUi.status.gone.description', }; + +// ─── Batch 4: Lab role option labels (`labOptions.*`) ──────────────────────── + +export const COACH_SKILL_KEYS: Record = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + shop: 'labOptions.barber.setting.shop', + mobile: 'labOptions.barber.setting.mobile', + event: 'labOptions.barber.setting.event', +}; + +export const TOUR_SKILL_KEYS: Record = { + 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 = { + beginner: 'labOptions.tour.skill.beginner.description', + intermediate: 'labOptions.tour.skill.intermediate.description', + advanced: 'labOptions.tour.skill.advanced.description', + all: 'labOptions.tour.skill.all.description', +}; diff --git a/src/pages/BecomeBuilderPage.tsx b/src/pages/BecomeBuilderPage.tsx index a3c281c..14ffa45 100644 --- a/src/pages/BecomeBuilderPage.tsx +++ b/src/pages/BecomeBuilderPage.tsx @@ -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; }) { + const { t } = useTranslation(); return (
{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} ))}
@@ -472,9 +481,9 @@ export default function BecomeBuilderPage() { : 'bg-gray-800 hover:bg-gray-700 border-gray-700' )} > -
{label}
+
{BUILDER_SERVICE_KEYS[service] ? t(BUILDER_SERVICE_KEYS[service]) : label}
- {BUILDER_SERVICE_DESCRIPTIONS[service]} + {BUILDER_SERVICE_DESC_KEYS[service] ? t(BUILDER_SERVICE_DESC_KEYS[service]) : BUILDER_SERVICE_DESCRIPTIONS[service]}
))} @@ -484,13 +493,13 @@ export default function BecomeBuilderPage() { {/* Materials / trade skills */}
- +
{/* Scale */}
- +
{/* Freeform "how we work" */} diff --git a/src/pages/BecomeCoachPage.tsx b/src/pages/BecomeCoachPage.tsx index 465ea6e..23fc621 100644 --- a/src/pages/BecomeCoachPage.tsx +++ b/src/pages/BecomeCoachPage.tsx @@ -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' )} > -
{label}
+
{COACH_SKILL_KEYS[skill] ? t(COACH_SKILL_KEYS[skill]) : label}
- {COACH_SKILL_DESCRIPTIONS[skill]} + {COACH_SKILL_DESC_KEYS[skill] ? t(COACH_SKILL_DESC_KEYS[skill]) : COACH_SKILL_DESCRIPTIONS[skill]}
))} diff --git a/src/pages/BecomeEditorPage.tsx b/src/pages/BecomeEditorPage.tsx index b69556b..78b84c7 100644 --- a/src/pages/BecomeEditorPage.tsx +++ b/src/pages/BecomeEditorPage.tsx @@ -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' )} > -
{label}
-
{EDITOR_SERVICE_DESCRIPTIONS[service]}
+
{EDITOR_SERVICE_KEYS[service] ? t(EDITOR_SERVICE_KEYS[service]) : label}
+
{EDITOR_SERVICE_DESC_KEYS[service] ? t(EDITOR_SERVICE_DESC_KEYS[service]) : EDITOR_SERVICE_DESCRIPTIONS[service]}
))}
@@ -442,8 +443,8 @@ export default function BecomeEditorPage() { : 'bg-gray-800 hover:bg-gray-700 border-gray-700' )} > -
{label}
-
{EDITOR_SKILL_DESCRIPTIONS[skill]}
+
{EDITOR_SKILL_KEYS[skill] ? t(EDITOR_SKILL_KEYS[skill]) : label}
+
{EDITOR_SKILL_DESC_KEYS[skill] ? t(EDITOR_SKILL_DESC_KEYS[skill]) : EDITOR_SKILL_DESCRIPTIONS[skill]}
))} diff --git a/src/pages/BecomeFilmerPage.tsx b/src/pages/BecomeFilmerPage.tsx index 5514ed6..f936eb1 100644 --- a/src/pages/BecomeFilmerPage.tsx +++ b/src/pages/BecomeFilmerPage.tsx @@ -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' )} > -
{label}
+
{FILMING_SERVICE_KEYS[service] ? t(FILMING_SERVICE_KEYS[service]) : label}
- {FILMING_SERVICE_DESCRIPTIONS[service]} + {FILMING_SERVICE_DESC_KEYS[service] ? t(FILMING_SERVICE_DESC_KEYS[service]) : FILMING_SERVICE_DESCRIPTIONS[service]}
))} @@ -464,9 +465,9 @@ export default function BecomeFilmerPage() { : 'bg-gray-800 hover:bg-gray-700 border-gray-700' )} > -
{label}
+
{FILMING_SKILL_KEYS[skill] ? t(FILMING_SKILL_KEYS[skill]) : label}
- {FILMING_SKILL_DESCRIPTIONS[skill]} + {FILMING_SKILL_DESC_KEYS[skill] ? t(FILMING_SKILL_DESC_KEYS[skill]) : FILMING_SKILL_DESCRIPTIONS[skill]}
))} diff --git a/src/pages/BecomePhotographerPage.tsx b/src/pages/BecomePhotographerPage.tsx index b7a92b8..d814e86 100644 --- a/src/pages/BecomePhotographerPage.tsx +++ b/src/pages/BecomePhotographerPage.tsx @@ -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; }) { + const { t } = useTranslation(); return (
{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} ))}
@@ -493,9 +505,9 @@ export default function BecomePhotographerPage() { : 'bg-gray-800 hover:bg-gray-700 border-gray-700' )} > -
{label}
+
{PHOTO_SERVICE_KEYS[service] ? t(PHOTO_SERVICE_KEYS[service]) : label}
- {PHOTO_SERVICE_DESCRIPTIONS[service]} + {PHOTO_SERVICE_DESC_KEYS[service] ? t(PHOTO_SERVICE_DESC_KEYS[service]) : PHOTO_SERVICE_DESCRIPTIONS[service]}
))} @@ -505,31 +517,31 @@ export default function BecomePhotographerPage() { {/* Camera */}
- +
{/* Lenses */}
- +
{/* Lighting */}
- +
{/* Support */}
- +
{/* Style */}
- +
{/* Freeform gear list */} diff --git a/src/pages/CoachDetailPage.tsx b/src/pages/CoachDetailPage.tsx index 9c4bca5..c2950b1 100644 --- a/src/pages/CoachDetailPage.tsx +++ b/src/pages/CoachDetailPage.tsx @@ -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> = { + 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> = { + 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 = { 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'), diff --git a/src/pages/CreateTourPage.tsx b/src/pages/CreateTourPage.tsx index 692eef7..b26b286 100644 --- a/src/pages/CreateTourPage.tsx +++ b/src/pages/CreateTourPage.tsx @@ -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} > {level.emoji} - {level.label} + {TOUR_SKILL_KEYS[key] ? t(TOUR_SKILL_KEYS[key]) : level.label} ))} diff --git a/src/pages/EventsPage.tsx b/src/pages/EventsPage.tsx index 88324b9..8cf0168 100644 --- a/src/pages/EventsPage.tsx +++ b/src/pages/EventsPage.tsx @@ -101,7 +101,7 @@ const DATE_WINDOW_LABELS_SHORT: Record = { 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} ); })} diff --git a/src/pages/LabPage.tsx b/src/pages/LabPage.tsx index 3eb5f57..07b44fb 100644 --- a/src/pages/LabPage.tsx +++ b/src/pages/LabPage.tsx @@ -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 = barbering: 'barber', }; +/** Lab option translation keys per service type (merged across each craft's maps). */ +const KEYS_BY_TYPE: Record> = { + 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} ); })} @@ -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 ( diff --git a/src/pages/TourDetailPage.tsx b/src/pages/TourDetailPage.tsx index 3eacfbd..2d1c05a 100644 --- a/src/pages/TourDetailPage.tsx +++ b/src/pages/TourDetailPage.tsx @@ -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() {

{skillInfo.emoji} - {skillInfo.label} + {skillKey ? t(skillKey) : skillInfo.label}

)}