i18n: sweep hardcoded English (deckCost, becomeRoles, weather, skaterLabels, notify, misc) into 4 locales

This commit is contained in:
SkateSpots 2026-10-07 18:09:57 +07:00
parent af0a138d1a
commit f7c9f9ef4c
48 changed files with 3005 additions and 439 deletions

View file

@ -38,6 +38,8 @@ import { Upload, X, Loader2, ArrowLeft, MapPin, Eye } from 'lucide-react';
/** A group of selectable tag chips (e.g. "Genre", "Instruments"). */
export interface SkillSection {
title: string;
/** Optional i18n key for the section title (takes precedence over `title`). */
titleKey?: string;
/** [tagValue, label] pairs. */
entries: [string, string][];
/** Optional value → `labOptions.*` translation key for the entry labels. */
@ -54,9 +56,13 @@ export interface ExtraField {
/** Optional i18n key for the label (takes precedence over `label`). */
labelKey?: string;
placeholder?: string;
/** Optional i18n key for the placeholder (takes precedence over `placeholder`). */
placeholderKey?: string;
/** "text" (default) | "url" | "toggle" */
type?: 'text' | 'url' | 'toggle';
helper?: string;
/** Optional i18n key for the helper (takes precedence over `helper`). */
helperKey?: string;
}
export interface CraftFormConfig {
@ -66,10 +72,16 @@ export interface CraftFormConfig {
identifier: string;
/** Page heading, e.g. "Become a Tattoo Artist". */
heading: string;
/** Optional i18n key for the heading (takes precedence over `heading`). */
headingKey?: string;
/** Sub-line under the heading. */
tagline: string;
/** Optional i18n key for the tagline (takes precedence over `tagline`). */
taglineKey?: string;
/** Human noun for toasts / alt text, e.g. "tattoo artist". */
noun: string;
/** Optional i18n key for the noun (takes precedence over `noun`). */
nounKey?: string;
/** Tailwind text color for the accent, e.g. "text-rose-400". */
accentText: string;
/** Tailwind bg color for the accent, e.g. "bg-rose-600". */
@ -90,23 +102,35 @@ export interface CraftFormConfig {
showRate?: boolean;
/** Label for the rate field. */
rateLabel?: string;
/** Optional i18n key for the rate label (takes precedence over `rateLabel`). */
rateLabelKey?: string;
/** Label for the portfolio field. */
portfolioLabel?: string;
/** Optional i18n key for the portfolio label (takes precedence over `portfolioLabel`). */
portfolioLabelKey?: string;
/** Billing units relevant to this craft (structured-rate dropdown). */
rateUnits?: LabOption[];
}
export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
const {
kind, selfTag, identifier, heading, tagline, noun,
kind, selfTag, identifier, heading, headingKey, tagline, taglineKey, noun, nounKey,
accentText, accentBg, accentChip, accentChipHover, icon: Icon,
skillSections, extraFields = [], allowCollective = true,
showRate = true, rateLabel = 'Rate', portfolioLabel = 'Portfolio Link',
showRate = true, rateLabel = 'Rate', rateLabelKey, portfolioLabel = 'Portfolio Link', portfolioLabelKey,
rateUnits,
} = config;
const navigate = useNavigate();
const { t } = useTranslation();
// Prefer the i18n key when the data table supplies one, else the English fallback.
const headingText = headingKey ? t(headingKey) : heading;
const taglineText = taglineKey ? t(taglineKey) : tagline;
const nounText = nounKey ? t(nounKey) : noun;
const rateLabelText = rateLabelKey ? t(rateLabelKey) : rateLabel;
const portfolioLabelText = portfolioLabelKey ? t(portfolioLabelKey) : portfolioLabel;
const sectionLabel = (section: SkillSection) => (section.titleKey ? t(section.titleKey) : section.title);
const { user, metadata } = useCurrentUser();
const author = useAuthor(user?.pubkey || '');
const { mutateAsync: createEvent, isPending } = useNostrPublish();
@ -196,7 +220,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
const npub = nip19.npubEncode(user.pubkey);
return `${npub.slice(0, 12)}...${npub.slice(-4)}`;
}
return `Anonymous ${noun}`;
return `Anonymous ${nounText}`;
};
const toggle = (key: string) =>
@ -269,7 +293,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
if (!selected.some((s) => keys.includes(s))) {
setStep(1);
window.setTimeout(() => scrollTo('craft-skills'), 60);
toast({ title: t('labUi.craftForm.sectionRequired', { section: section.title }), description: t('labUi.craftForm.sectionRequiredDesc', { section: section.title }), variant: 'destructive' });
toast({ title: t('labUi.craftForm.sectionRequired', { section: sectionLabel(section) }), description: t('labUi.craftForm.sectionRequiredDesc', { section: sectionLabel(section) }), variant: 'destructive' });
return;
}
}
@ -280,7 +304,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
['d', identifier],
['title', titleInput.trim() || displayName],
['t', selfTag],
['alt', `Skate ${noun}: ${displayName}`],
['alt', `Skate ${nounText}: ${displayName}`],
];
if (location.trim()) tags.push(['location', location.trim()]);
@ -352,7 +376,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
<CardContent className="py-12 text-center">
<Icon className={cn('h-12 w-12 mx-auto mb-4', accentText)} />
<h2 className="text-xl font-bold mb-2">{t('labUi.become.loginRequired')}</h2>
<p className="text-gray-400 mb-6">{t('labUi.become.loginRequiredDescCraft', { noun })}</p>
<p className="text-gray-400 mb-6">{t('labUi.become.loginRequiredDescCraft', { noun: nounText })}</p>
<LoginArea className="justify-center max-w-60 mx-auto" />
</CardContent>
</Card>
@ -385,9 +409,9 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
</div>
<div>
<h1 className={cn('text-2xl sm:text-3xl font-black uppercase leading-none', accentText)} style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}>
{heading}
{headingText}
</h1>
<p className="text-gray-400 mt-1 text-sm">{tagline}</p>
<p className="text-gray-400 mt-1 text-sm">{taglineText}</p>
</div>
</div>
@ -415,7 +439,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
{existingProfile && (
<p className="mb-3 text-xs text-gray-500">
{t('labUi.craftForm.editingNote', { noun })}
{t('labUi.craftForm.editingNote', { noun: nounText })}
</p>
)}
@ -498,7 +522,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
id="bio"
value={bio}
onChange={(e) => setBio(e.target.value)}
placeholder={t('labUi.craftForm.aboutPlaceholder', { noun })}
placeholder={t('labUi.craftForm.aboutPlaceholder', { noun: nounText })}
className="mt-1.5 bg-white/5 border-white/10 min-h-[100px] resize-none"
maxLength={3000}
/>
@ -516,7 +540,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
<Card key={section.title} id={sectionIndex === 0 ? 'craft-skills' : undefined} className="bg-black/60 border-white/10">
<CardContent className="p-5">
<Label className="text-xs font-bold uppercase tracking-widest text-gray-400 flex items-center gap-2">
{section.title}
{sectionLabel(section)}
{section.required && <span className={cn('text-[10px]', accentText)}>• {t('labUi.craftForm.required')}</span>}
</Label>
<div className="mt-3 flex flex-wrap gap-2">
@ -539,7 +563,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
})}
</div>
{attempted && section.required && !section.entries.some(([k]) => selected.includes(k)) && (
<p className="mt-2 text-xs font-bold text-red-400">{t('labUi.craftForm.sectionRequiredDesc', { section: section.title })}</p>
<p className="mt-2 text-xs font-bold text-red-400">{t('labUi.craftForm.sectionRequiredDesc', { section: sectionLabel(section) })}</p>
)}
</CardContent>
</Card>
@ -554,7 +578,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
<div key={f.tag} className="flex items-center justify-between gap-3">
<div>
<Label className="text-sm font-bold text-gray-200">{f.labelKey ? t(f.labelKey) : f.label}</Label>
{f.helper && <p className="text-xs text-gray-500">{f.helper}</p>}
{f.helper && <p className="text-xs text-gray-500">{f.helperKey ? t(f.helperKey) : f.helper}</p>}
</div>
<Switch
checked={extras[f.tag] === 'true'}
@ -568,11 +592,11 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
id={f.tag}
value={extras[f.tag] ?? ''}
onChange={(e) => setExtras((prev) => ({ ...prev, [f.tag]: e.target.value }))}
placeholder={f.placeholder}
placeholder={f.placeholderKey ? t(f.placeholderKey) : f.placeholder}
inputMode={f.type === 'url' ? 'url' : undefined}
className="mt-1.5 bg-white/5 border-white/10"
/>
{f.helper && <p className="text-xs text-gray-500 mt-1">{f.helper}</p>}
{f.helper && <p className="text-xs text-gray-500 mt-1">{f.helperKey ? t(f.helperKey) : f.helper}</p>}
</div>
)
))}
@ -602,7 +626,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
</div>
{showRate && (
<div>
<Label htmlFor="rate" className="text-xs font-bold uppercase tracking-widest text-gray-400">{rateLabel}</Label>
<Label htmlFor="rate" className="text-xs font-bold uppercase tracking-widest text-gray-400">{rateLabelText}</Label>
<Input id="rate" value={rate} onChange={(e) => setRate(e.target.value)} placeholder={t('labUi.craftForm.ratePlaceholder')} className="mt-1.5 bg-white/5 border-white/10" />
</div>
)}
@ -628,7 +652,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
</div>
<Input value={social} onChange={(e) => setSocial(e.target.value)} placeholder={t('labUi.craftForm.socialPlaceholder')} className="bg-white/5 border-white/10" />
<div className="sm:col-span-2">
<Input id="craft-portfolio" value={portfolio} onChange={(e) => setPortfolio(e.target.value)} placeholder={portfolioLabel} inputMode="url" className={cn('bg-white/5 border-white/10', portfolioError && 'border-red-500')} />
<Input id="craft-portfolio" value={portfolio} onChange={(e) => setPortfolio(e.target.value)} placeholder={portfolioLabelText} inputMode="url" className={cn('bg-white/5 border-white/10', portfolioError && 'border-red-500')} />
{portfolioError && <p className="mt-1 text-xs text-red-400">{portfolioError}</p>}
</div>
</div>
@ -688,8 +712,8 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
<LabProfilePreview
open={previewOpen}
onOpenChange={setPreviewOpen}
heading={heading}
noun={noun}
heading={headingText}
noun={nounText}
accentText={accentText}
accentChip={accentChip}
icon={Icon}

View file

@ -12,6 +12,7 @@ import { useToast } from '@/hooks/useToast';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { dateLocaleCode } from '@/lib/dateFnsI18n';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils';
/**
@ -126,13 +127,23 @@ const DEFAULT_QUOTE: QuoteProfile = {
/** Payment methods offered on a quote. */
const PAY_METHODS = ['Cash', 'Lightning', 'Cashu', 'Bank transfer', 'Card', 'Other'];
/** Translation keys for the payment-method chips (the stored values stay stable). */
const PAY_METHOD_KEYS: Record<string, string> = {
Cash: 'deckCostUi.payMethodCash',
Lightning: 'deckCostUi.payMethodLightning',
Cashu: 'deckCostUi.payMethodCashu',
'Bank transfer': 'deckCostUi.payMethodBank',
Card: 'deckCostUi.payMethodCard',
Other: 'deckCostUi.payMethodOther',
};
/** Optional Incoterms (2020) for cross-border sales — light, not a contract. */
const INCOTERM_OPTIONS: { code: string; label: string }[] = [
{ code: '', label: 'None' },
{ code: 'EXW', label: 'EXW — collect from the seller' },
{ code: 'FCA', label: 'FCA — handed to the buyer’s carrier' },
{ code: 'DAP', label: 'DAP — delivered to the buyer’s address' },
{ code: 'DDP', label: 'DDP — delivered, duties paid' },
const INCOTERM_OPTIONS: { code: string; labelKey: string }[] = [
{ code: '', labelKey: 'deckCostUi.incotermNone' },
{ code: 'EXW', labelKey: 'deckCostUi.incotermExw' },
{ code: 'FCA', labelKey: 'deckCostUi.incotermFca' },
{ code: 'DAP', labelKey: 'deckCostUi.incotermDap' },
{ code: 'DDP', labelKey: 'deckCostUi.incotermDdp' },
];
interface CalcState {
@ -279,12 +290,12 @@ function starterSheet(): CalcState {
};
}
interface SheetTemplate { id: string; name: string; blurb: string; build: () => CalcState }
interface SheetTemplate { id: string; nameKey: string; blurbKey: string; build: () => CalcState }
/** Quick-start sheets — a lean starter and the full workshop default. */
const SHEET_TEMPLATES: SheetTemplate[] = [
{ id: 'starter', name: 'Hobby starter', blurb: 'A few rows, no fixed costs', build: starterSheet },
{ id: 'shop', name: 'Small shop', blurb: 'Full sheet with rent & overhead', build: () => freshDefault() },
{ id: 'starter', nameKey: 'deckCostUi.templateStarterName', blurbKey: 'deckCostUi.templateStarterBlurb', build: starterSheet },
{ id: 'shop', nameKey: 'deckCostUi.templateShopName', blurbKey: 'deckCostUi.templateShopBlurb', build: () => freshDefault() },
];
/** Collapsible sheet section with a live per-board subtotal in the header. */
@ -484,6 +495,7 @@ function calcStateFromCsv(text: string): CalcState | null {
}
export function DeckCostCalculator() {
const { t } = useTranslation();
const [rawS, setS] = useLocalStorage<CalcState>('skatespots:deck-cost:v2', DEFAULT_CALC);
// Merge over defaults so a sheet saved before a new field existed can't produce
// NaN (e.g. the per-operation / batch-setup / tax fields added with Advanced).
@ -644,22 +656,22 @@ export function DeckCostCalculator() {
/** Cheap sanity checks — surface the numbers that are probably mistakes. */
const warnings = useMemo(() => {
const w: string[] = [];
if (s.profitMargin > 0 && s.profitMargin < 1) w.push('Retail markup is below 1× — you would sell at a loss.');
if (s.wholesaleMarkup > 0 && s.profitMargin > 0 && s.wholesaleMarkup >= s.profitMargin) w.push('Wholesale markup should be lower than the retail markup.');
if (s.profitMargin > 0 && s.profitMargin < 1) w.push(t('deckCostUi.warnRetailBelowOne'));
if (s.wholesaleMarkup > 0 && s.profitMargin > 0 && s.wholesaleMarkup >= s.profitMargin) w.push(t('deckCostUi.warnWholesaleAboveRetail'));
const zeroRows = [...s.equipment, ...s.materials, ...s.processes].filter((row) => row.boards <= 0).length;
if (zeroRows > 0) w.push(`${zeroRows} row${zeroRows > 1 ? 's' : ''} use 0 boards — that row's cost is ignored.`);
if (r.fixedMonthly > 0 && s.boardsPerMonth <= 0) w.push('Fixed monthly costs are set but boards/month is 0, so operations cost is ignored.');
if (r.materials <= 0) w.push('No materials cost — add at least one material.');
if (r.costWithPack > 0 && r.equipment + r.operations > r.costWithPack * 0.6) w.push('Equipment + overhead are over 60% of the cost — this model assumes high volume; check your board counts.');
if (zeroRows > 0) w.push(t('deckCostUi.warnZeroBoards', { count: zeroRows }));
if (r.fixedMonthly > 0 && s.boardsPerMonth <= 0) w.push(t('deckCostUi.warnFixedNoBoards'));
if (r.materials <= 0) w.push(t('deckCostUi.warnNoMaterials'));
if (r.costWithPack > 0 && r.equipment + r.operations > r.costWithPack * 0.6) w.push(t('deckCostUi.warnEquipmentOverhead'));
const oneBoard = s.equipment.filter((e) => e.cost > 100 && e.boards === 1);
if (oneBoard.length > 0) w.push('Some equipment is spread over a single board — raise its board count to amortise it.');
if (oneBoard.length > 0) w.push(t('deckCostUi.warnOneBoard'));
return w;
}, [s, r]);
}, [s, r, t]);
const patch = (p: Partial<CalcState>) => setS((prev) => ({ ...prev, ...p }));
const savePreset = () => {
const name = window.prompt('Name this preset', `Preset ${presets.length + 1}`);
const name = window.prompt(t('deckCostUi.presetPrompt'), t('deckCostUi.presetDefaultName', { n: presets.length + 1 }));
if (!name) return;
setPresets((prev) => [...prev.filter((p) => p.name !== name), { name, state: s }]);
};
@ -1039,9 +1051,9 @@ export function DeckCostCalculator() {
].join('\n');
try {
await navigator.clipboard.writeText(text);
toast({ title: 'Summary copied' });
toast({ title: t('deckCostUi.summaryCopied') });
} catch {
toast({ title: 'Could not copy', variant: 'destructive' });
toast({ title: t('deckCostUi.couldNotCopy'), variant: 'destructive' });
}
};
const download = (filename: string, content: string, mime: string) => {
@ -1125,7 +1137,7 @@ export function DeckCostCalculator() {
e.target.value = '';
};
const reset = () => {
if (!window.confirm('Reset everything to the example values?')) return;
if (!window.confirm(t('deckCostUi.resetConfirm'))) return;
setS(freshDefault());
};
@ -1133,7 +1145,7 @@ export function DeckCostCalculator() {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 500 * 1024) {
toast({ title: 'Logo too large (max 500 KB)', variant: 'destructive' });
toast({ title: t('deckCostUi.logoTooLarge'), variant: 'destructive' });
e.target.value = '';
return;
}
@ -1146,7 +1158,7 @@ export function DeckCostCalculator() {
/** Autofill the quote identity from the logged-in Nostr profile (kind 0). */
const fillFromProfile = () => {
if (!user) {
toast({ title: 'Log in to fill from your Nostr profile', variant: 'destructive' });
toast({ title: t('deckCostUi.loginToFill'), variant: 'destructive' });
return;
}
const meta = author.data?.metadata;
@ -1158,7 +1170,7 @@ export function DeckCostCalculator() {
lightning: quote.lightning || meta?.lud16 || meta?.lud06 || '',
npub: quote.npub || nip19.npubEncode(user.pubkey),
});
toast({ title: 'Filled from your Nostr profile' });
toast({ title: t('deckCostUi.filledFromProfile') });
};
const numInput = (value: number, onChange: (n: number) => void, className?: string) => (
@ -1180,7 +1192,7 @@ export function DeckCostCalculator() {
{/* Currency — searchable; switching CONVERTS every money field via live
BTC rates (sats included). */}
<div className="sticky top-16 z-20 -mx-1 flex flex-wrap items-center gap-2 rounded-xl border border-white/10 bg-black/90 px-3 py-2 backdrop-blur">
<span className="text-[10px] font-black uppercase tracking-wider text-gray-500">Currency</span>
<span className="text-[10px] font-black uppercase tracking-wider text-gray-500">{t('deckCostUi.currency')}</span>
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
<PopoverTrigger asChild>
<button
@ -1218,43 +1230,43 @@ export function DeckCostCalculator() {
</button>
))}
{filteredCurrencies.length === 0 && (
<p className="px-3 py-4 text-center text-xs text-gray-500">No currency matches.</p>
<p className="px-3 py-4 text-center text-xs text-gray-500">{t('deckCostUi.noCurrencyMatches')}</p>
)}
</div>
{!rates && (
<p className="border-t border-white/10 px-3 py-2 text-[10px] text-gray-500">Live rates unavailable — switching relabels only.</p>
<p className="border-t border-white/10 px-3 py-2 text-[10px] text-gray-500">{t('deckCostUi.ratesUnavailable')}</p>
)}
</PopoverContent>
</Popover>
{rates && <span className="text-[10px] text-gray-500">live BTC rates · sats included</span>}
{rates && <span className="text-[10px] text-gray-500">{t('deckCostUi.liveRates')}</span>}
<span className="ml-auto text-[11px] font-bold text-gray-400">
{fmt(r.costWithPackUnit)} cost · <span className="text-[#ccff00]">{fmt(r.priceWithPack)} sell</span>{r.qty > 1 ? <> · <span className="text-gray-300">{fmt(r.orderTotal)} total</span></> : null}
{fmt(r.costWithPackUnit)} {t('deckCostUi.chipCost')} · <span className="text-[#ccff00]">{fmt(r.priceWithPack)} {t('deckCostUi.chipSell')}</span>{r.qty > 1 ? <> · <span className="text-gray-300">{fmt(r.orderTotal)} {t('deckCostUi.chipTotal')}</span></> : null}
</span>
</div>
{/* Quick-start templates */}
<div className="flex flex-wrap items-center gap-2">
<span className="flex items-center gap-1.5 text-[10px] font-black uppercase tracking-wider text-gray-500">
<LayoutTemplate className="h-3.5 w-3.5" /> Start from
<LayoutTemplate className="h-3.5 w-3.5" /> {t('deckCostUi.startFrom')}
</span>
{SHEET_TEMPLATES.map((tpl) => (
<button
key={tpl.id}
type="button"
title={tpl.blurb}
title={t(tpl.blurbKey)}
onClick={() => setS(tpl.build())}
className="min-h-0 rounded-full border border-white/15 px-3 py-1 text-xs font-semibold text-gray-300 hover:border-[#ccff00]/60 hover:text-white"
>
{tpl.name}
{t(tpl.nameKey)}
</button>
))}
<button
type="button"
onClick={() => setAdvanced((a) => !a)}
className={`ml-auto inline-flex min-h-0 items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-bold ${advanced ? 'border-[#ccff00]/60 bg-[#ccff00]/10 text-[#ccff00]' : 'border-white/15 text-gray-300 hover:bg-white/5'}`}
title="Reveal per-operation rates and scrap, batch setup, tax and the cost-vs-quantity curve"
title={t('deckCostUi.advancedToggleHint')}
>
<SlidersHorizontal className="h-3.5 w-3.5" /> {advanced ? 'Advanced' : 'Simple'}
<SlidersHorizontal className="h-3.5 w-3.5" /> {advanced ? t('deckCostUi.advanced') : t('deckCostUi.simple')}
</button>
</div>
@ -1270,43 +1282,43 @@ export function DeckCostCalculator() {
)}
{/* Labor */}
<CalcSection title="Labor" subtotal={`${fmt(r.laborPerHr)}/hr loaded`} defaultOpen>
<CalcSection title={t('deckCostUi.sectionLabor')} subtotal={t('deckCostUi.subtotalLoaded', { value: fmt(r.laborPerHr) })} defaultOpen>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
<label className="space-y-1">
<span className={label}>Hourly rate ({unit})</span>
<span className={label}>{t('deckCostUi.hourlyRate', { unit })}</span>
{numInput(s.hourlyRate, (n) => patch({ hourlyRate: n }))}
</label>
<label className="space-y-1">
<span className={label}>Labour burden (×)</span>
<span className={label}>{t('deckCostUi.labourBurden')}</span>
{numInput(s.overheadFactor, (n) => patch({ overheadFactor: n }))}
</label>
<div className="space-y-1">
<span className={label}>Loaded rate</span>
<span className={label}>{t('deckCostUi.loadedRate')}</span>
<div className="flex h-8 items-center rounded-md border border-white/10 bg-black/40 px-2 text-sm font-bold text-[#ccff00]">
{fmt(s.hourlyRate * s.overheadFactor)}/hr
</div>
</div>
</div>
<p className="mt-2 text-[11px] text-gray-500">Loaded rate = wage × burden (employer taxes and insurance on labour). Rent and bills go under <span className="text-gray-300">Operation expenses</span> — don’t count them in both.</p>
<p className="mt-2 text-[11px] text-gray-500">{t('deckCostUi.loadedRateHintBefore')}<span className="text-gray-300">{t('deckCostUi.sectionOperations')}</span>{t('deckCostUi.loadedRateHintAfter')}</p>
</CalcSection>
{/* Equipment */}
<CalcSection title="Equipment" subtotal={`${fmt(r.equipment)}/board`}>
<CalcSection title={t('deckCostUi.sectionEquipment')} subtotal={t('deckCostUi.subtotalPerBoard', { value: fmt(r.equipment) })}>
<div className="overflow-x-auto">
<table className="ss-sheet-table w-full text-xs">
<thead><tr><th className={th}>Equipment</th><th className={th}>Cost ({unit})</th><th className={th}>Setup (h)</th><th className={th}>Lifetime boards</th><th className={th}>Per board</th><th /></tr></thead>
<thead><tr><th className={th}>{t('deckCostUi.colEquipment')}</th><th className={th}>{t('deckCostUi.colCostUnit', { unit })}</th><th className={th}>{t('deckCostUi.colSetupH')}</th><th className={th}>{t('deckCostUi.colLifetimeBoards')}</th><th className={th}>{t('deckCostUi.colPerBoard')}</th><th /></tr></thead>
<tbody>
{s.equipment.map((e) => (
<tr key={e.id} className="border-t border-white/5">
<td className={`${td} ss-cell-name`}><Input value={e.name} onChange={(ev) => patch({ equipment: s.equipment.map((x) => x.id === e.id ? { ...x, name: ev.target.value } : x) })} className="h-8 min-w-36" /></td>
<td className={td} data-label="Cost">{numInput(e.cost, (n) => patch({ equipment: s.equipment.map((x) => x.id === e.id ? { ...x, cost: n } : x) }))}</td>
<td className={td} data-label="Setup (h)">{numInput(e.setupHrs, (n) => patch({ equipment: s.equipment.map((x) => x.id === e.id ? { ...x, setupHrs: n } : x) }))}</td>
<td className={td} data-label="Lifetime boards">{numInput(e.boards, (n) => patch({ equipment: s.equipment.map((x) => x.id === e.id ? { ...x, boards: n } : x) }))}</td>
<td className={`${td} whitespace-nowrap font-bold text-gray-300`} data-label="Per board">{fmt(e.boards > 0 ? (e.cost + e.setupHrs * r.laborPerHr) / e.boards : 0)}</td>
<td className={td} data-label={t('deckCostUi.colCost')}>{numInput(e.cost, (n) => patch({ equipment: s.equipment.map((x) => x.id === e.id ? { ...x, cost: n } : x) }))}</td>
<td className={td} data-label={t('deckCostUi.colSetupH')}>{numInput(e.setupHrs, (n) => patch({ equipment: s.equipment.map((x) => x.id === e.id ? { ...x, setupHrs: n } : x) }))}</td>
<td className={td} data-label={t('deckCostUi.colLifetimeBoards')}>{numInput(e.boards, (n) => patch({ equipment: s.equipment.map((x) => x.id === e.id ? { ...x, boards: n } : x) }))}</td>
<td className={`${td} whitespace-nowrap font-bold text-gray-300`} data-label={t('deckCostUi.colPerBoard')}>{fmt(e.boards > 0 ? (e.cost + e.setupHrs * r.laborPerHr) / e.boards : 0)}</td>
<td className={`${td} ss-cell-actions`}>
<div className="flex items-center gap-0.5">
<button type="button" onClick={() => patch({ equipment: [...s.equipment, { ...e, id: uid() }] })} className="min-h-0 min-w-0 text-gray-600 hover:text-[#ccff00]" aria-label="Duplicate"><Copy className="h-3.5 w-3.5" /></button>
<button type="button" onClick={() => patch({ equipment: s.equipment.filter((x) => x.id !== e.id) })} className="min-h-0 min-w-0 text-gray-600 hover:text-red-400" aria-label="Remove"><Trash2 className="h-3.5 w-3.5" /></button>
<button type="button" onClick={() => patch({ equipment: [...s.equipment, { ...e, id: uid() }] })} className="min-h-0 min-w-0 text-gray-600 hover:text-[#ccff00]" aria-label={t('deckCostUi.duplicate')}><Copy className="h-3.5 w-3.5" /></button>
<button type="button" onClick={() => patch({ equipment: s.equipment.filter((x) => x.id !== e.id) })} className="min-h-0 min-w-0 text-gray-600 hover:text-red-400" aria-label={t('deckCostUi.remove')}><Trash2 className="h-3.5 w-3.5" /></button>
</div>
</td>
</tr>
@ -1314,28 +1326,28 @@ export function DeckCostCalculator() {
</tbody>
</table>
</div>
<p className="mt-2 text-xs text-gray-500">Equipment per board: <span className="font-bold text-gray-300">{fmt(r.equipment)}</span></p>
<p className="mt-2 text-xs text-gray-500">{t('deckCostUi.equipmentPerBoard')} <span className="font-bold text-gray-300">{fmt(r.equipment)}</span></p>
<Button type="button" variant="ghost" size="sm" className="mt-2 h-7 gap-1 text-xs text-gray-400" onClick={() => patch({ equipment: [...s.equipment, { id: uid(), name: 'New', cost: 0, setupHrs: 0, boards: 1 }] })}>
<Plus className="h-3.5 w-3.5" /> Add equipment
<Plus className="h-3.5 w-3.5" /> {t('deckCostUi.addEquipment')}
</Button>
</CalcSection>
{/* Operations */}
<CalcSection title="Operation expenses" subtotal={`${fmt(r.operations)}/board`}>
<CalcSection title={t('deckCostUi.sectionOperations')} subtotal={t('deckCostUi.subtotalPerBoard', { value: fmt(r.operations) })}>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<label className="space-y-1"><span className={label}>Boards / month</span>{numInput(s.boardsPerMonth, (n) => patch({ boardsPerMonth: n }))}</label>
<label className="space-y-1"><span className={label}>Rent / month</span>{numInput(s.rent, (n) => patch({ rent: n }))}</label>
<label className="space-y-1"><span className={label}>Utilities / month</span>{numInput(s.utilities, (n) => patch({ utilities: n }))}</label>
<label className="space-y-1"><span className={label}>Subscriptions / month</span>{numInput(s.subscriptions, (n) => patch({ subscriptions: n }))}</label>
<label className="space-y-1"><span className={label}>{t('deckCostUi.boardsPerMonth')}</span>{numInput(s.boardsPerMonth, (n) => patch({ boardsPerMonth: n }))}</label>
<label className="space-y-1"><span className={label}>{t('deckCostUi.rentPerMonth')}</span>{numInput(s.rent, (n) => patch({ rent: n }))}</label>
<label className="space-y-1"><span className={label}>{t('deckCostUi.utilitiesPerMonth')}</span>{numInput(s.utilities, (n) => patch({ utilities: n }))}</label>
<label className="space-y-1"><span className={label}>{t('deckCostUi.subscriptionsPerMonth')}</span>{numInput(s.subscriptions, (n) => patch({ subscriptions: n }))}</label>
</div>
<p className="mt-2 text-xs text-gray-500">Per board: <span className="font-bold text-gray-300">{fmt(r.operations)}</span></p>
<p className="mt-2 text-xs text-gray-500">{t('deckCostUi.perBoard')} <span className="font-bold text-gray-300">{fmt(r.operations)}</span></p>
</CalcSection>
{/* Materials */}
<CalcSection title="Materials" subtotal={`${fmt(r.materials)}/board`}>
<CalcSection title={t('deckCostUi.sectionMaterials')} subtotal={t('deckCostUi.subtotalPerBoard', { value: fmt(r.materials) })}>
<div className="overflow-x-auto">
<table className="ss-sheet-table w-full text-xs">
<thead><tr><th className={th}>Material</th><th className={th}>Order ({unit})</th><th className={th}>Ship ({unit})</th><th className={th}>Boards / order</th><th className={th}>Order (m)</th><th className={th}>Unload (m)</th><th className={th}>Per board</th><th /></tr></thead>
<thead><tr><th className={th}>{t('deckCostUi.colMaterial')}</th><th className={th}>{t('deckCostUi.colOrderUnit', { unit })}</th><th className={th}>{t('deckCostUi.colShipUnit', { unit })}</th><th className={th}>{t('deckCostUi.colBoardsOrder')}</th><th className={th}>{t('deckCostUi.colOrderM')}</th><th className={th}>{t('deckCostUi.colUnloadM')}</th><th className={th}>{t('deckCostUi.colPerBoard')}</th><th /></tr></thead>
<tbody>
{s.materials.map((m) => {
const per = m.boards > 0 ? (m.orderCost + m.shipping + (m.orderMin + m.unloadMin) * r.laborPerMin) / m.boards : 0;
@ -1343,16 +1355,16 @@ export function DeckCostCalculator() {
return (
<tr key={m.id} className="border-t border-white/5">
<td className={`${td} ss-cell-name`}><Input value={m.name} onChange={(e) => up({ name: e.target.value })} className="h-8 min-w-36" /></td>
<td className={td} data-label="Order">{numInput(m.orderCost, (n) => up({ orderCost: n }))}</td>
<td className={td} data-label="Shipping">{numInput(m.shipping, (n) => up({ shipping: n }))}</td>
<td className={td} data-label="Boards / order">{numInput(m.boards, (n) => up({ boards: n }))}</td>
<td className={td} data-label="Order (min)">{numInput(m.orderMin, (n) => up({ orderMin: n }))}</td>
<td className={td} data-label="Unload (min)">{numInput(m.unloadMin, (n) => up({ unloadMin: n }))}</td>
<td className={`${td} whitespace-nowrap font-bold text-gray-300`} data-label="Per board">{fmt(per)}</td>
<td className={td} data-label={t('deckCostUi.colOrder')}>{numInput(m.orderCost, (n) => up({ orderCost: n }))}</td>
<td className={td} data-label={t('deckCostUi.colShipping')}>{numInput(m.shipping, (n) => up({ shipping: n }))}</td>
<td className={td} data-label={t('deckCostUi.colBoardsOrder')}>{numInput(m.boards, (n) => up({ boards: n }))}</td>
<td className={td} data-label={t('deckCostUi.colOrderMin')}>{numInput(m.orderMin, (n) => up({ orderMin: n }))}</td>
<td className={td} data-label={t('deckCostUi.colUnloadMin')}>{numInput(m.unloadMin, (n) => up({ unloadMin: n }))}</td>
<td className={`${td} whitespace-nowrap font-bold text-gray-300`} data-label={t('deckCostUi.colPerBoard')}>{fmt(per)}</td>
<td className={`${td} ss-cell-actions`}>
<div className="flex items-center gap-0.5">
<button type="button" onClick={() => patch({ materials: [...s.materials, { ...m, id: uid() }] })} className="min-h-0 min-w-0 text-gray-600 hover:text-[#ccff00]" aria-label="Duplicate"><Copy className="h-3.5 w-3.5" /></button>
<button type="button" onClick={() => patch({ materials: s.materials.filter((x) => x.id !== m.id) })} className="min-h-0 min-w-0 text-gray-600 hover:text-red-400" aria-label="Remove"><Trash2 className="h-3.5 w-3.5" /></button>
<button type="button" onClick={() => patch({ materials: [...s.materials, { ...m, id: uid() }] })} className="min-h-0 min-w-0 text-gray-600 hover:text-[#ccff00]" aria-label={t('deckCostUi.duplicate')}><Copy className="h-3.5 w-3.5" /></button>
<button type="button" onClick={() => patch({ materials: s.materials.filter((x) => x.id !== m.id) })} className="min-h-0 min-w-0 text-gray-600 hover:text-red-400" aria-label={t('deckCostUi.remove')}><Trash2 className="h-3.5 w-3.5" /></button>
</div>
</td>
</tr>
@ -1361,17 +1373,17 @@ export function DeckCostCalculator() {
</tbody>
</table>
</div>
<p className="mt-2 text-xs text-gray-500">Materials per board: <span className="font-bold text-gray-300">{fmt(r.materials)}</span></p>
<p className="mt-2 text-xs text-gray-500">{t('deckCostUi.materialsPerBoard')} <span className="font-bold text-gray-300">{fmt(r.materials)}</span></p>
<Button type="button" variant="ghost" size="sm" className="mt-2 h-7 gap-1 text-xs text-gray-400" onClick={() => patch({ materials: [...s.materials, { id: uid(), name: 'New', orderCost: 0, shipping: 0, boards: 1, orderMin: 0, unloadMin: 0 }] })}>
<Plus className="h-3.5 w-3.5" /> Add material
<Plus className="h-3.5 w-3.5" /> {t('deckCostUi.addMaterial')}
</Button>
</CalcSection>
{/* Processes */}
<CalcSection title="Process costs" subtotal={`${fmt(r.processes)}/board`}>
<CalcSection title={t('deckCostUi.sectionProcesses')} subtotal={t('deckCostUi.subtotalPerBoard', { value: fmt(r.processes) })}>
<div className="overflow-x-auto">
<table className="ss-sheet-table w-full text-xs">
<thead><tr><th className={th}>Process</th><th className={th}>Time (m)</th><th className={th}>Boards / run</th>{advanced && <th className={th}>Rate/hr (0 = shop)</th>}{advanced && <th className={th}>Scrap %</th>}<th className={th}>Per board</th><th /></tr></thead>
<thead><tr><th className={th}>{t('deckCostUi.colProcess')}</th><th className={th}>{t('deckCostUi.colTimeM')}</th><th className={th}>{t('deckCostUi.colBoardsRun')}</th>{advanced && <th className={th}>{t('deckCostUi.colRateHr')}</th>}{advanced && <th className={th}>{t('deckCostUi.colScrapPct')}</th>}<th className={th}>{t('deckCostUi.colPerBoard')}</th><th /></tr></thead>
<tbody>
{s.processes.map((p) => {
const rate = p.rate > 0 ? p.rate : r.laborPerHr;
@ -1380,15 +1392,15 @@ export function DeckCostCalculator() {
return (
<tr key={p.id} className="border-t border-white/5">
<td className={`${td} ss-cell-name`}><Input value={p.name} onChange={(e) => up({ name: e.target.value })} className="h-8 min-w-36" /></td>
<td className={td} data-label="Time (min)">{numInput(p.minutes, (n) => up({ minutes: n }))}</td>
<td className={td} data-label="Boards / run">{numInput(p.boards, (n) => up({ boards: n }))}</td>
{advanced && <td className={td} data-label="Rate/hr">{numInput(p.rate, (n) => up({ rate: n }))}</td>}
{advanced && <td className={td} data-label="Scrap %">{numInput(p.scrapPct, (n) => up({ scrapPct: Math.min(99, n) }))}</td>}
<td className={`${td} whitespace-nowrap font-bold text-gray-300`} data-label="Per board">{fmt(per)}</td>
<td className={td} data-label={t('deckCostUi.colTimeMin')}>{numInput(p.minutes, (n) => up({ minutes: n }))}</td>
<td className={td} data-label={t('deckCostUi.colBoardsRun')}>{numInput(p.boards, (n) => up({ boards: n }))}</td>
{advanced && <td className={td} data-label={t('deckCostUi.colRateHrShort')}>{numInput(p.rate, (n) => up({ rate: n }))}</td>}
{advanced && <td className={td} data-label={t('deckCostUi.colScrapPct')}>{numInput(p.scrapPct, (n) => up({ scrapPct: Math.min(99, n) }))}</td>}
<td className={`${td} whitespace-nowrap font-bold text-gray-300`} data-label={t('deckCostUi.colPerBoard')}>{fmt(per)}</td>
<td className={`${td} ss-cell-actions`}>
<div className="flex items-center gap-0.5">
<button type="button" onClick={() => patch({ processes: [...s.processes, { ...p, id: uid() }] })} className="min-h-0 min-w-0 text-gray-600 hover:text-[#ccff00]" aria-label="Duplicate"><Copy className="h-3.5 w-3.5" /></button>
<button type="button" onClick={() => patch({ processes: s.processes.filter((x) => x.id !== p.id) })} className="min-h-0 min-w-0 text-gray-600 hover:text-red-400" aria-label="Remove"><Trash2 className="h-3.5 w-3.5" /></button>
<button type="button" onClick={() => patch({ processes: [...s.processes, { ...p, id: uid() }] })} className="min-h-0 min-w-0 text-gray-600 hover:text-[#ccff00]" aria-label={t('deckCostUi.duplicate')}><Copy className="h-3.5 w-3.5" /></button>
<button type="button" onClick={() => patch({ processes: s.processes.filter((x) => x.id !== p.id) })} className="min-h-0 min-w-0 text-gray-600 hover:text-red-400" aria-label={t('deckCostUi.remove')}><Trash2 className="h-3.5 w-3.5" /></button>
</div>
</td>
</tr>
@ -1397,17 +1409,17 @@ export function DeckCostCalculator() {
</tbody>
</table>
</div>
<p className="mt-2 text-xs text-gray-500">Processes per board: <span className="font-bold text-gray-300">{fmt(r.processes)}</span></p>
<p className="mt-2 text-xs text-gray-500">{t('deckCostUi.processesPerBoard')} <span className="font-bold text-gray-300">{fmt(r.processes)}</span></p>
<Button type="button" variant="ghost" size="sm" className="mt-2 h-7 gap-1 text-xs text-gray-400" onClick={() => patch({ processes: [...s.processes, { id: uid(), name: 'New', minutes: 0, boards: 1, rate: 0, scrapPct: 0 }] })}>
<Plus className="h-3.5 w-3.5" /> Add process
<Plus className="h-3.5 w-3.5" /> {t('deckCostUi.addProcess')}
</Button>
</CalcSection>
{/* Packaging */}
<CalcSection title="Packaging" subtotal={`${fmt(r.packaging)}/board`}>
<CalcSection title={t('deckCostUi.sectionPackaging')} subtotal={t('deckCostUi.subtotalPerBoard', { value: fmt(r.packaging) })}>
<div className="overflow-x-auto">
<table className="ss-sheet-table w-full text-xs">
<thead><tr><th className={th}>Packaging</th><th className={th}>Time/board (m)</th><th className={th}>Materials/board ({unit})</th><th className={th}>Per board</th><th /></tr></thead>
<thead><tr><th className={th}>{t('deckCostUi.colPackaging')}</th><th className={th}>{t('deckCostUi.colTimeBoardM')}</th><th className={th}>{t('deckCostUi.colMaterialsBoardUnit', { unit })}</th><th className={th}>{t('deckCostUi.colPerBoard')}</th><th /></tr></thead>
<tbody>
{s.packaging.map((p) => {
const per = p.materialsPerBoard + p.minutesPerBoard * r.laborPerMin;
@ -1415,13 +1427,13 @@ export function DeckCostCalculator() {
return (
<tr key={p.id} className="border-t border-white/5">
<td className={`${td} ss-cell-name`}><Input value={p.name} onChange={(e) => up({ name: e.target.value })} className="h-8 min-w-36" /></td>
<td className={td} data-label="Time/board (min)">{numInput(p.minutesPerBoard, (n) => up({ minutesPerBoard: n }))}</td>
<td className={td} data-label="Materials/board">{numInput(p.materialsPerBoard, (n) => up({ materialsPerBoard: n }))}</td>
<td className={`${td} whitespace-nowrap font-bold text-gray-300`} data-label="Per board">{fmt(per)}</td>
<td className={td} data-label={t('deckCostUi.colTimeBoardMin')}>{numInput(p.minutesPerBoard, (n) => up({ minutesPerBoard: n }))}</td>
<td className={td} data-label={t('deckCostUi.colMaterialsBoard')}>{numInput(p.materialsPerBoard, (n) => up({ materialsPerBoard: n }))}</td>
<td className={`${td} whitespace-nowrap font-bold text-gray-300`} data-label={t('deckCostUi.colPerBoard')}>{fmt(per)}</td>
<td className={`${td} ss-cell-actions`}>
<div className="flex items-center gap-0.5">
<button type="button" onClick={() => patch({ packaging: [...s.packaging, { ...p, id: uid() }] })} className="min-h-0 min-w-0 text-gray-600 hover:text-[#ccff00]" aria-label="Duplicate"><Copy className="h-3.5 w-3.5" /></button>
<button type="button" onClick={() => patch({ packaging: s.packaging.filter((x) => x.id !== p.id) })} className="min-h-0 min-w-0 text-gray-600 hover:text-red-400" aria-label="Remove"><Trash2 className="h-3.5 w-3.5" /></button>
<button type="button" onClick={() => patch({ packaging: [...s.packaging, { ...p, id: uid() }] })} className="min-h-0 min-w-0 text-gray-600 hover:text-[#ccff00]" aria-label={t('deckCostUi.duplicate')}><Copy className="h-3.5 w-3.5" /></button>
<button type="button" onClick={() => patch({ packaging: s.packaging.filter((x) => x.id !== p.id) })} className="min-h-0 min-w-0 text-gray-600 hover:text-red-400" aria-label={t('deckCostUi.remove')}><Trash2 className="h-3.5 w-3.5" /></button>
</div>
</td>
</tr>
@ -1430,104 +1442,104 @@ export function DeckCostCalculator() {
</tbody>
</table>
</div>
<p className="mt-2 text-xs text-gray-500">Packaging per board: <span className="font-bold text-gray-300">{fmt(r.packaging)}</span></p>
<p className="mt-2 text-xs text-gray-500">{t('deckCostUi.packagingPerBoard')} <span className="font-bold text-gray-300">{fmt(r.packaging)}</span></p>
<Button type="button" variant="ghost" size="sm" className="mt-2 h-7 gap-1 text-xs text-gray-400" onClick={() => patch({ packaging: [...s.packaging, { id: uid(), name: 'New', minutesPerBoard: 0, materialsPerBoard: 0 }] })}>
<Plus className="h-3.5 w-3.5" /> Add packaging step
<Plus className="h-3.5 w-3.5" /> {t('deckCostUi.addPackagingStep')}
</Button>
</CalcSection>
{/* Margins + results */}
<section className="rounded-2xl border-2 border-[#ccff00]/30 bg-[#ccff00]/[0.04] p-4">
<h3 className="mb-3 text-sm font-black uppercase tracking-wide text-[#ccff00]">Margins</h3>
<h3 className="mb-3 text-sm font-black uppercase tracking-wide text-[#ccff00]">{t('deckCostUi.sectionMargins')}</h3>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<label className="space-y-1"><span className={label}>Markup (×)</span>{numInput(s.profitMargin, (n) => patch({ profitMargin: n }))}</label>
<label className="space-y-1"><span className={label}>Wholesale markup (×)</span>{numInput(s.wholesaleMarkup, (n) => patch({ wholesaleMarkup: n }))}</label>
<label className="space-y-1"><span className={label}>Scrap / waste (%)</span>{numInput(s.scrapPct, (n) => patch({ scrapPct: n }))}</label>
<label className="space-y-1"><span className={label}>Quantity (decks)</span>{numInput(s.quantity, (n) => patch({ quantity: Math.max(1, Math.round(n) || 1) }))}</label>
<label className="space-y-1"><span className={label}>Target monthly profit ({unit})</span>{numInput(s.targetMonthlyProfit, (n) => patch({ targetMonthlyProfit: n }))}</label>
<div className="space-y-1"><span className={label}>Cost / board (no packaging)</span><div className="flex h-8 items-center text-sm font-bold text-gray-200">{fmt(r.costNoPackUnit)}</div></div>
<div className="space-y-1"><span className={label}>Cost / board (with packaging)</span><div className="flex h-8 items-center text-sm font-bold text-gray-200">{fmt(r.costWithPackUnit)}</div></div>
<label className="space-y-1"><span className={label}>{t('deckCostUi.markup')}</span>{numInput(s.profitMargin, (n) => patch({ profitMargin: n }))}</label>
<label className="space-y-1"><span className={label}>{t('deckCostUi.wholesaleMarkup')}</span>{numInput(s.wholesaleMarkup, (n) => patch({ wholesaleMarkup: n }))}</label>
<label className="space-y-1"><span className={label}>{t('deckCostUi.scrapWaste')}</span>{numInput(s.scrapPct, (n) => patch({ scrapPct: n }))}</label>
<label className="space-y-1"><span className={label}>{t('deckCostUi.quantityDecks')}</span>{numInput(s.quantity, (n) => patch({ quantity: Math.max(1, Math.round(n) || 1) }))}</label>
<label className="space-y-1"><span className={label}>{t('deckCostUi.targetMonthlyProfit', { unit })}</span>{numInput(s.targetMonthlyProfit, (n) => patch({ targetMonthlyProfit: n }))}</label>
<div className="space-y-1"><span className={label}>{t('deckCostUi.costNoPackaging')}</span><div className="flex h-8 items-center text-sm font-bold text-gray-200">{fmt(r.costNoPackUnit)}</div></div>
<div className="space-y-1"><span className={label}>{t('deckCostUi.costWithPackaging')}</span><div className="flex h-8 items-center text-sm font-bold text-gray-200">{fmt(r.costWithPackUnit)}</div></div>
</div>
{advanced && (
<div className="mt-3 grid grid-cols-2 sm:grid-cols-4 gap-3 rounded-xl border border-white/10 bg-black/20 p-3">
<label className="space-y-1"><span className={label}>Batch setup (h / run)</span>{numInput(s.batchSetupHours, (n) => patch({ batchSetupHours: n }))}</label>
<label className="space-y-1"><span className={label}>Batch setup cost ({unit} / run)</span>{numInput(s.batchSetupCost, (n) => patch({ batchSetupCost: n }))}</label>
<label className="space-y-1"><span className={label}>Tax / VAT (%)</span>{numInput(s.taxPct, (n) => patch({ taxPct: Math.min(99, n) }))}</label>
<label className="space-y-1"><span className={label}>Quote tax</span>
<label className="space-y-1"><span className={label}>{t('deckCostUi.batchSetupHours')}</span>{numInput(s.batchSetupHours, (n) => patch({ batchSetupHours: n }))}</label>
<label className="space-y-1"><span className={label}>{t('deckCostUi.batchSetupCost', { unit })}</span>{numInput(s.batchSetupCost, (n) => patch({ batchSetupCost: n }))}</label>
<label className="space-y-1"><span className={label}>{t('deckCostUi.taxVat')}</span>{numInput(s.taxPct, (n) => patch({ taxPct: Math.min(99, n) }))}</label>
<label className="space-y-1"><span className={label}>{t('deckCostUi.quoteTax')}</span>
<button type="button" onClick={() => patch({ taxInclusive: !s.taxInclusive })} className="flex h-8 w-full items-center rounded-md border border-white/10 bg-black/40 px-2 text-xs font-bold text-gray-200 hover:bg-white/5">
{s.taxInclusive ? 'Included in price' : 'Added on top'}
{s.taxInclusive ? t('deckCostUi.includedInPrice') : t('deckCostUi.addedOnTop')}
</button>
</label>
{r.batchSetup > 0 && <div className="space-y-1"><span className={label}>Setup / board</span><div className="flex h-8 items-center text-sm font-bold text-gray-200">{fmt(r.setupPerUnit)}</div></div>}
{r.batchSetup > 0 && <div className="space-y-1"><span className={label}>{t('deckCostUi.setupPerBoard')}</span><div className="flex h-8 items-center text-sm font-bold text-gray-200">{fmt(r.setupPerUnit)}</div></div>}
</div>
)}
<p className="mt-2 text-[11px] text-gray-500">
Markup {s.profitMargin}× ≈ <span className="text-gray-300">{marginPct}% gross margin</span> · waste {r.effectiveWastePct.toFixed(1)}% is spread over the good boards.
{t('deckCostUi.marginNoteBefore', { markup: s.profitMargin })}<span className="text-gray-300">{t('deckCostUi.grossMargin', { pct: marginPct })}</span>{t('deckCostUi.marginNoteAfter', { waste: r.effectiveWastePct.toFixed(1) })}
</p>
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="rounded-xl border border-[#ccff00]/40 bg-black/40 p-3">
<p className="text-[10px] font-bold uppercase tracking-wider text-gray-500">Selling price · no packaging</p>
<p className="text-[10px] font-bold uppercase tracking-wider text-gray-500">{t('deckCostUi.sellingPriceNoPackaging')}</p>
<p className="mt-1 text-2xl font-black text-[#ccff00]">{fmt(r.priceNoPack)}</p>
</div>
<div className="rounded-xl border border-[#ccff00]/40 bg-black/40 p-3">
<p className="text-[10px] font-bold uppercase tracking-wider text-gray-500">Selling price · with packaging</p>
<p className="text-[10px] font-bold uppercase tracking-wider text-gray-500">{t('deckCostUi.sellingPriceWithPackaging')}</p>
<p className="mt-1 text-2xl font-black text-[#ccff00]">{fmt(r.priceWithPack)}</p>
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-4">
<div className="rounded-lg border border-white/10 bg-black/30 p-2.5">
<p className="text-[10px] font-bold uppercase tracking-wider text-gray-500">Profit / deck</p>
<p className="text-[10px] font-bold uppercase tracking-wider text-gray-500">{t('deckCostUi.profitPerDeck')}</p>
<p className="mt-0.5 text-sm font-black text-gray-100">{fmt(r.profitPerUnit)}</p>
</div>
<div className="rounded-lg border border-white/10 bg-black/30 p-2.5">
<p className="text-[10px] font-bold uppercase tracking-wider text-gray-500">Wholesale / deck</p>
<p className="text-[10px] font-bold uppercase tracking-wider text-gray-500">{t('deckCostUi.wholesalePerDeck')}</p>
<p className="mt-0.5 text-sm font-black text-gray-100">{fmt(r.wholesaleUnit)}</p>
</div>
<div className="rounded-lg border border-white/10 bg-black/30 p-2.5">
<p className="text-[10px] font-bold uppercase tracking-wider text-gray-500">{r.qty > 1 ? `Order total (${r.qty})` : 'Order total'}</p>
<p className="text-[10px] font-bold uppercase tracking-wider text-gray-500">{r.qty > 1 ? t('deckCostUi.orderTotalQty', { qty: r.qty }) : t('deckCostUi.orderTotal')}</p>
<p className="mt-0.5 text-sm font-black text-gray-100">{fmt(r.orderTotal)}</p>
</div>
<div className="rounded-lg border border-white/10 bg-black/30 p-2.5">
<p className="text-[10px] font-bold uppercase tracking-wider text-gray-500">Breakeven</p>
<p className="mt-0.5 text-sm font-black text-gray-100">{r.breakevenDecks > 0 ? `${r.breakevenDecks}/mo` : '—'}</p>
<p className="text-[10px] font-bold uppercase tracking-wider text-gray-500">{t('deckCostUi.breakeven')}</p>
<p className="mt-0.5 text-sm font-black text-gray-100">{r.breakevenDecks > 0 ? t('deckCostUi.breakevenPerMonth', { count: r.breakevenDecks }) : '—'}</p>
</div>
</div>
{r.targetDecks > 0 && (
<p className="mt-2 text-[11px] text-gray-500">
To clear {fmt(s.targetMonthlyProfit)}/month on top of fixed costs, sell <span className="text-gray-300">{r.targetDecks} decks/month</span>.
{t('deckCostUi.targetDecksHint', { amount: fmt(s.targetMonthlyProfit), decks: r.targetDecks })}
</p>
)}
{s.taxPct > 0 && (
<p className="mt-2 text-[11px] text-gray-500">
{s.taxInclusive
? <>Price includes {s.taxPct}% tax: <span className="text-gray-300">{fmt(r.orderTax)}</span> of the total.</>
: <>Plus {s.taxPct}% tax: <span className="text-gray-300">{fmt(r.orderTax)}</span> → {fmt(r.orderTotal)} total.</>}
? <>{t('deckCostUi.priceIncludesTax', { pct: s.taxPct, amount: fmt(r.orderTax) })}</>
: <>{t('deckCostUi.plusTax', { pct: s.taxPct, amount: fmt(r.orderTax), total: fmt(r.orderTotal) })}</>}
</p>
)}
{advanced && (
<div className="mt-3 rounded-xl border border-white/10 bg-black/20 p-3">
<p className="mb-2 text-[10px] font-bold uppercase tracking-wider text-gray-500">Cost vs quantity (setup amortised)</p>
<p className="mb-2 text-[10px] font-bold uppercase tracking-wider text-gray-500">{t('deckCostUi.costVsQuantity')}</p>
<div>
{r.curve.map((c) => (
<div key={c.qty} className="flex items-start justify-between gap-3 border-t border-white/5 py-1.5 text-xs">
<span className="shrink-0 pt-0.5 font-bold text-gray-400">{c.qty}{c.qty === 1 ? ' deck' : ' decks'}</span>
<span className="shrink-0 pt-0.5 font-bold text-gray-400">{c.qty} {c.qty === 1 ? t('deckCostUi.deckOne') : t('deckCostUi.deckOther')}</span>
<span className="min-w-0 text-right">
<span className="block font-bold text-gray-100">{fmt(c.unit)}<span className="font-normal text-gray-500"> / deck</span></span>
<span className="block text-[10px] text-gray-500">order {fmt(c.total)}</span>
<span className="block font-bold text-gray-100">{fmt(c.unit)}<span className="font-normal text-gray-500"> {t('deckCostUi.perDeckSuffix')}</span></span>
<span className="block text-[10px] text-gray-500">{t('deckCostUi.orderSuffix', { amount: fmt(c.total) })}</span>
</span>
</div>
))}
</div>
</div>
)}
<p className="mt-2 text-[11px] text-gray-500">Does not include shipping to the buyer.</p>
<p className="mt-2 text-[11px] text-gray-500">{t('deckCostUi.shippingNotIncluded')}</p>
{/* Quote details — your identity on the client PDF (collapsed by default). */}
<details className="group mt-4 rounded-xl border border-white/10 bg-black/30 p-3">
<summary className="flex cursor-pointer list-none items-center justify-between gap-2 text-[11px] font-black uppercase tracking-wider text-gray-500 [&::-webkit-details-marker]:hidden">
<span>Client quote details (for the PDF)</span>
<span>{t('deckCostUi.clientQuoteDetails')}</span>
<ChevronDown className="h-3.5 w-3.5 transition-transform group-open:rotate-180" />
</summary>
<div className="mt-3 space-y-3">
@ -1536,79 +1548,79 @@ export function DeckCostCalculator() {
type="button"
onClick={fillFromProfile}
className="inline-flex min-h-0 items-center gap-1 rounded-md border border-white/15 px-2 py-1 text-[11px] font-semibold hover:bg-white/5"
title="Fill name, website, NIP-05, Lightning and npub from your Nostr profile"
title={t('deckCostUi.fillFromProfileTitle')}
>
<AtSign className="h-3.5 w-3.5" /> Fill from profile
<AtSign className="h-3.5 w-3.5" /> {t('deckCostUi.fillFromProfile')}
</button>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className="space-y-1">
<span className={label}>Business / shop name</span>
<span className={label}>{t('deckCostUi.businessName')}</span>
<Input value={quote.businessName} onChange={(e) => setQuote({ ...quote, businessName: e.target.value })} placeholder="Your name / shop" className="h-8 bg-black/40 border-white/10" />
</label>
<label className="space-y-1">
<span className={label}>Email</span>
<span className={label}>{t('deckCostUi.email')}</span>
<Input value={quote.email} onChange={(e) => setQuote({ ...quote, email: e.target.value })} placeholder="you@example.com" className="h-8 bg-black/40 border-white/10" />
</label>
<label className="space-y-1">
<span className={label}>Phone</span>
<span className={label}>{t('deckCostUi.phone')}</span>
<Input value={quote.phone} onChange={(e) => setQuote({ ...quote, phone: e.target.value })} placeholder="+1 …" className="h-8 bg-black/40 border-white/10" />
</label>
<label className="space-y-1">
<span className={label}>Website</span>
<span className={label}>{t('deckCostUi.website')}</span>
<Input value={quote.website} onChange={(e) => setQuote({ ...quote, website: e.target.value })} placeholder="yourshop.com" className="h-8 bg-black/40 border-white/10" />
</label>
<label className="space-y-1 sm:col-span-2">
<span className={label}>Address</span>
<span className={label}>{t('deckCostUi.address')}</span>
<Input value={quote.address} onChange={(e) => setQuote({ ...quote, address: e.target.value })} placeholder="Street, city" className="h-8 bg-black/40 border-white/10" />
</label>
<label className="space-y-1">
<span className={label}>Quote #</span>
<span className={label}>{t('deckCostUi.quoteNumber')}</span>
<div className="flex items-center gap-1.5">
<Input type="number" min={1} value={quote.number} onChange={(e) => setQuote({ ...quote, number: Math.max(1, Number(e.target.value) || 1) })} className="h-8 bg-black/40 border-white/10" />
<button type="button" onClick={() => setQuote({ ...quote, number: quote.number + 1 })} className="min-h-0 shrink-0 rounded-md border border-white/15 px-2 py-1 text-xs font-semibold hover:bg-white/5" title="Next number">+1</button>
<button type="button" onClick={() => setQuote({ ...quote, number: quote.number + 1 })} className="min-h-0 shrink-0 rounded-md border border-white/15 px-2 py-1 text-xs font-semibold hover:bg-white/5" title={t('deckCostUi.nextNumber')}>+1</button>
</div>
</label>
<label className="space-y-1">
<span className={label}>Valid (days)</span>
<span className={label}>{t('deckCostUi.validDays')}</span>
<Input type="number" min={1} value={quote.validDays} onChange={(e) => setQuote({ ...quote, validDays: Math.max(1, Number(e.target.value) || 1) })} className="h-8 bg-black/40 border-white/10" />
</label>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className="space-y-1">
<span className={label}>NIP-05</span>
<span className={label}>{t('deckCostUi.nip05')}</span>
<Input value={quote.nip05} onChange={(e) => setQuote({ ...quote, nip05: e.target.value })} placeholder="you@skate-spots.org" className="h-8 bg-black/40 border-white/10" />
</label>
<label className="space-y-1">
<span className={label}>Lightning address</span>
<span className={label}>{t('deckCostUi.lightningAddress')}</span>
<Input value={quote.lightning} onChange={(e) => setQuote({ ...quote, lightning: e.target.value })} placeholder="you@getalby.com" className="h-8 bg-black/40 border-white/10" />
</label>
<label className="space-y-1 sm:col-span-2">
<span className={label}>Nostr (npub)</span>
<span className={label}>{t('deckCostUi.nostrNpub')}</span>
<Input value={quote.npub} onChange={(e) => setQuote({ ...quote, npub: e.target.value })} placeholder="npub1…" className="h-8 bg-black/40 border-white/10 font-mono text-[11px]" />
</label>
</div>
{/* Payment details & terms */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className="space-y-1">
<span className={label}>Deposit due up front (%)</span>
<span className={label}>{t('deckCostUi.depositPct')}</span>
<Input type="number" min={0} max={100} value={quote.depositPct} onChange={(e) => setQuote({ ...quote, depositPct: Math.min(100, Math.max(0, Number(e.target.value) || 0)) })} className="h-8 bg-black/40 border-white/10" />
</label>
<label className="space-y-1">
<span className={label}>Delivery terms (Incoterm, optional)</span>
<span className={label}>{t('deckCostUi.deliveryTerms')}</span>
<select
value={quote.incoterm}
onChange={(e) => setQuote({ ...quote, incoterm: e.target.value })}
className="h-8 w-full rounded-md border border-white/10 bg-black/40 px-2 text-sm text-gray-200"
>
{INCOTERM_OPTIONS.map((it) => (
<option key={it.code} value={it.code} className="bg-neutral-900">{it.label}</option>
<option key={it.code} value={it.code} className="bg-neutral-900">{t(it.labelKey)}</option>
))}
</select>
</label>
</div>
<div className="space-y-1">
<span className={label}>Payment methods</span>
<span className={label}>{t('deckCostUi.paymentMethods')}</span>
<div className="flex flex-wrap gap-1.5">
{PAY_METHODS.map((m) => {
const on = quote.payMethods.includes(m);
@ -1619,32 +1631,32 @@ export function DeckCostCalculator() {
onClick={() => setQuote({ ...quote, payMethods: on ? quote.payMethods.filter((x) => x !== m) : [...quote.payMethods, m] })}
className={`min-h-0 rounded-full border px-2.5 py-1 text-[11px] font-semibold ${on ? 'border-[#ccff00]/60 bg-[#ccff00]/10 text-white' : 'border-white/15 text-gray-400 hover:bg-white/5'}`}
>
{m}
{t(PAY_METHOD_KEYS[m] ?? m)}
</button>
);
})}
</div>
</div>
<label className="block space-y-1">
<span className={label}>Payment terms</span>
<span className={label}>{t('deckCostUi.paymentTerms')}</span>
<Input value={quote.paymentTerms} onChange={(e) => setQuote({ ...quote, paymentTerms: e.target.value })} placeholder="50% deposit, balance before dispatch" className="h-8 bg-black/40 border-white/10" />
</label>
<label className="block space-y-1">
<span className={label}>Payment details</span>
<span className={label}>{t('deckCostUi.paymentDetails')}</span>
<Input value={quote.paymentDetails} onChange={(e) => setQuote({ ...quote, paymentDetails: e.target.value })} placeholder="Bank/IBAN, PayPal, Revolut… (optional)" className="h-8 bg-black/40 border-white/10" />
</label>
<div className="flex flex-wrap items-center gap-3">
{quote.logo && <img src={quote.logo} alt="" className="h-10 rounded border border-white/10" />}
<label className="flex h-8 cursor-pointer items-center gap-1.5 rounded-md border border-white/15 px-3 text-xs font-semibold hover:bg-white/5">
<Upload className="h-3.5 w-3.5" /> {quote.logo ? 'Change logo' : 'Add logo'}
<Upload className="h-3.5 w-3.5" /> {quote.logo ? t('deckCostUi.changeLogo') : t('deckCostUi.addLogo')}
<input type="file" accept="image/png,image/jpeg" className="hidden" onChange={onLogo} />
</label>
{quote.logo && (
<button type="button" onClick={() => setQuote({ ...quote, logo: '' })} className="min-h-0 text-xs text-gray-500 hover:text-red-400">Remove</button>
<button type="button" onClick={() => setQuote({ ...quote, logo: '' })} className="min-h-0 text-xs text-gray-500 hover:text-red-400">{t('deckCostUi.remove')}</button>
)}
</div>
<label className="block space-y-1">
<span className={label}>Notes / terms</span>
<span className={label}>{t('deckCostUi.notesTerms')}</span>
<Textarea value={quote.notes} onChange={(e) => setQuote({ ...quote, notes: e.target.value })} rows={2} placeholder="Lead time, deposit, warranty…" className="bg-black/40 border-white/10" />
</label>
</div>
@ -1657,13 +1669,13 @@ export function DeckCostCalculator() {
to={`/create-listing?price=${encodeURIComponent(String(Math.round(buyerUnit * 100) / 100))}&currency=${encodeURIComponent(cur)}`}
className="inline-flex items-center gap-1.5 rounded-full bg-[#ccff00] px-4 py-2 text-xs font-black uppercase tracking-wide text-black transition-colors hover:bg-[#ccff00]/90"
>
List a deck at this price <ChevronRight className="h-3.5 w-3.5" />
{t('deckCostUi.listDeckAtPrice')} <ChevronRight className="h-3.5 w-3.5" />
</Link>
<Link
to="/lab?craft=building"
className="inline-flex items-center gap-1.5 rounded-full border border-white/15 px-4 py-2 text-xs font-bold text-gray-300 transition-colors hover:bg-white/5"
>
Get it made — find a builder
{t('deckCostUi.getItMade')}
</Link>
</div>
</section>
@ -1671,19 +1683,19 @@ export function DeckCostCalculator() {
{/* Presets & data */}
<div className="flex flex-wrap items-center gap-2">
<Button type="button" variant="outline" size="sm" className="h-8 gap-1.5 border-white/15 text-xs" onClick={savePreset}>
<Save className="h-3.5 w-3.5" /> Save preset
<Save className="h-3.5 w-3.5" /> {t('deckCostUi.savePreset')}
</Button>
<Popover open={loadOpen} onOpenChange={setLoadOpen}>
<PopoverTrigger asChild>
<Button type="button" variant="outline" size="sm" className="h-8 gap-1.5 border-white/15 text-xs" disabled={presets.length === 0}>
<FolderOpen className="h-3.5 w-3.5" /> Load{presets.length > 0 ? ` (${presets.length})` : ''}
<FolderOpen className="h-3.5 w-3.5" /> {t('deckCostUi.load')}{presets.length > 0 ? ` (${presets.length})` : ''}
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-1">
{presets.map((p) => (
<div key={p.name} className="flex items-center gap-1">
<button type="button" onClick={() => loadPreset(p.state)} className="min-h-0 flex-1 rounded-lg px-2.5 py-1.5 text-left text-xs font-semibold text-gray-300 hover:bg-white/5">{p.name}</button>
<button type="button" onClick={() => deletePreset(p.name)} aria-label="Delete preset" className="min-h-0 min-w-0 p-1 text-gray-600 hover:text-red-400"><Trash2 className="h-3.5 w-3.5" /></button>
<button type="button" onClick={() => deletePreset(p.name)} aria-label={t('deckCostUi.deletePreset')} className="min-h-0 min-w-0 p-1 text-gray-600 hover:text-red-400"><Trash2 className="h-3.5 w-3.5" /></button>
</div>
))}
</PopoverContent>
@ -1691,19 +1703,19 @@ export function DeckCostCalculator() {
<Popover>
<PopoverTrigger asChild>
<Button type="button" variant="outline" size="sm" className="h-8 gap-1.5 border-white/15 text-xs">
<Download className="h-3.5 w-3.5" /> Export <ChevronDown className="h-3.5 w-3.5" />
<Download className="h-3.5 w-3.5" /> {t('deckCostUi.export')} <ChevronDown className="h-3.5 w-3.5" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-52 p-1">
{[
{ label: 'PDF — full sheet', run: () => void exportPdf() },
{ label: 'PDF — client quote', run: () => void exportQuotePdf() },
{ label: 'PNG image', run: () => void exportPng() },
{ label: 'XLSX workbook', run: () => void exportXlsx() },
{ label: 'CSV (spreadsheet)', run: exportCsv },
{ label: 'Markdown', run: exportMarkdown },
{ label: 'JSON (backup)', run: exportJson },
{ label: 'Copy summary', run: () => void copySummary() },
{ label: t('deckCostUi.exportPdfFull'), run: () => void exportPdf() },
{ label: t('deckCostUi.exportPdfQuote'), run: () => void exportQuotePdf() },
{ label: t('deckCostUi.exportPng'), run: () => void exportPng() },
{ label: t('deckCostUi.exportXlsx'), run: () => void exportXlsx() },
{ label: t('deckCostUi.exportCsv'), run: exportCsv },
{ label: t('deckCostUi.exportMarkdown'), run: exportMarkdown },
{ label: t('deckCostUi.exportJson'), run: exportJson },
{ label: t('deckCostUi.exportCopySummary'), run: () => void copySummary() },
].map((o) => (
<button key={o.label} type="button" onClick={o.run} className="flex w-full min-h-0 rounded-lg px-2.5 py-1.5 text-left text-xs font-semibold text-gray-300 hover:bg-white/5">
{o.label}
@ -1712,11 +1724,11 @@ export function DeckCostCalculator() {
</PopoverContent>
</Popover>
<label className="flex h-8 cursor-pointer items-center gap-1.5 rounded-md border border-white/15 px-3 text-xs font-semibold hover:bg-white/5">
<Upload className="h-3.5 w-3.5" /> Import
<Upload className="h-3.5 w-3.5" /> {t('deckCostUi.import')}
<input type="file" accept=".json,.csv,application/json,text/csv" className="hidden" onChange={importFile} />
</label>
<Button type="button" variant="outline" size="sm" className="h-8 gap-1.5 border-white/15 text-xs" onClick={reset}>
<RotateCcw className="h-3.5 w-3.5" /> Reset
<RotateCcw className="h-3.5 w-3.5" /> {t('deckCostUi.reset')}
</Button>
</div>
</div>

View file

@ -80,7 +80,7 @@ export function EventSponsors({
{title.map((s, i) => (
<div key={`${s.name}-${i}`} className="space-y-1.5">
<span className="text-[10px] font-black uppercase tracking-widest text-[#ccff00]">
{EVENT_SPONSOR_TIERS.title.label}
{t(EVENT_SPONSOR_TIERS.title.labelKey)}
</span>
<SponsorLogo sponsor={s} large />
</div>

View file

@ -8,6 +8,7 @@ import { Skeleton } from '@/components/ui/skeleton';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useSkateEvents } from '@/hooks/useSkateEvents';
import { useEventRsvps, eventCoord } from '@/hooks/useEventRsvps';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils';
import type { SkateEvent } from '@/lib/types';
@ -23,6 +24,7 @@ function formatWhen(ts: number): string {
/** A single hosted-event row with live RSVP counts + quick actions. */
function HostedEventRow({ event, isPast }: { event: SkateEvent; isPast: boolean }) {
const { t } = useTranslation();
const { goingCount, tentativeCount } = useEventRsvps(eventCoord(event));
const to = `/event/${event.pubkey}/${event.identifier}`;
@ -53,22 +55,22 @@ function HostedEventRow({ event, isPast }: { event: SkateEvent; isPast: boolean
<div className="mt-1 flex items-center gap-3 text-xs">
<span className="flex items-center gap-1 font-bold text-green-400">
<Users className="h-3 w-3" />
{goingCount} going
{t('legacyMiscUi.hostingGoing', { count: goingCount })}
</span>
{tentativeCount > 0 && (
<span className="text-[#ccff00]">{tentativeCount} interested</span>
<span className="text-[#ccff00]">{t('legacyMiscUi.hostingInterested', { count: tentativeCount })}</span>
)}
</div>
</Link>
{/* Quick actions */}
<div className="flex flex-shrink-0 items-center gap-1.5">
<Link to={to} title="Post update">
<Link to={to} title={t('legacyMiscUi.hostingPostUpdate')}>
<Button size="icon" variant="ghost" className="h-8 w-8 text-gray-400 hover:text-[#ccff00]">
<Megaphone className="h-4 w-4" />
</Button>
</Link>
<Link to={`/edit-event/${event.pubkey}/${event.identifier}`} title="Edit">
<Link to={`/edit-event/${event.pubkey}/${event.identifier}`} title={t('legacyMiscUi.hostingEdit')}>
<Button size="icon" variant="ghost" className="h-8 w-8 text-gray-400 hover:text-white">
<Pencil className="h-4 w-4" />
</Button>
@ -85,6 +87,7 @@ function HostedEventRow({ event, isPast }: { event: SkateEvent; isPast: boolean
* The host's home base.
*/
export function HostingDashboard() {
const { t } = useTranslation();
const { user } = useCurrentUser();
const { data: events, isLoading } = useSkateEvents(200);
@ -104,7 +107,7 @@ export function HostingDashboard() {
<Card className="border-dashed">
<CardContent className="py-12 text-center">
<Calendar className="mx-auto mb-3 h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">Log in to see the events you're hosting.</p>
<p className="text-sm text-muted-foreground">{t('legacyMiscUi.hostingLoginToSee')}</p>
</CardContent>
</Card>
);
@ -129,14 +132,14 @@ export function HostingDashboard() {
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-[#ccff00]/10">
<Calendar className="h-7 w-7 text-[#ccff00]" />
</div>
<h3 className="mb-1 font-black uppercase tracking-wide text-white">You're not hosting anything yet</h3>
<h3 className="mb-1 font-black uppercase tracking-wide text-white">{t('legacyMiscUi.hostingEmptyTitle')}</h3>
<p className="mx-auto mb-5 max-w-xs text-sm text-muted-foreground">
Run a jam, comp or session. Create an event and skaters can RSVP, get updates, and roll through.
{t('legacyMiscUi.hostingEmptyBody')}
</p>
<Link to="/create-event">
<Button className="gap-2 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90">
<Plus className="h-4 w-4" />
Create an event
{t('legacyMiscUi.hostingCreateEvent')}
</Button>
</Link>
</CardContent>
@ -149,7 +152,7 @@ export function HostingDashboard() {
{mine.upcoming.length > 0 && (
<div className="space-y-2.5">
<h2 className="text-xs font-black uppercase tracking-widest text-[#ccff00]">
Upcoming · {mine.upcoming.length}
{t('legacyMiscUi.hostingUpcoming', { count: mine.upcoming.length })}
</h2>
{mine.upcoming.map((e) => (
<HostedEventRow key={e.id} event={e} isPast={false} />
@ -160,7 +163,7 @@ export function HostingDashboard() {
{mine.past.length > 0 && (
<div className="space-y-2.5">
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500">
Past · {mine.past.length}
{t('legacyMiscUi.hostingPast', { count: mine.past.length })}
</h2>
{mine.past.map((e) => (
<HostedEventRow key={e.id} event={e} isPast />

View file

@ -9,6 +9,7 @@ import { useNewsletterSubscription } from '@/hooks/useNewsletterSubscription';
import { useDmEnvelopes, type DmEnvelope } from '@/hooks/useDmEnvelopes';
import { useNotificationPrefs } from '@/hooks/useNotificationPrefs';
import { useLocalReminders } from '@/hooks/useLocalReminders';
import { useTranslation } from '@/contexts/language';
import { isNativePlatform } from '@/lib/platform';
import {
getNotificationPermission,
@ -55,6 +56,9 @@ interface SeenState {
ids: string[];
}
/** Translation function signature (matches `useTranslation().t`). */
type Translate = (key: string, params?: Record<string, string | number>) => string;
const SEEN_KEY = 'skatespots:notifications:seen';
const MAX_SEEN_IDS = 600;
const MAX_PER_BURST = 3;
@ -92,32 +96,34 @@ function coordPath(coord: string, route: string, fallback: string): string {
return fallback;
}
function activityCandidate(n: ActivityNotification): Candidate {
function activityCandidate(n: ActivityNotification, t: Translate): Candidate {
let title: string;
switch (n.type) {
case 'zap':
title = n.amountSats ? `You got zapped ${n.amountSats.toLocaleString()} sats` : 'You got zapped';
title = n.amountSats
? t('notifyUi.activity.zappedWithAmount', { sats: n.amountSats.toLocaleString() })
: t('notifyUi.activity.zapped');
break;
case 'reaction':
title = 'Someone liked your post';
title = t('notifyUi.activity.liked');
break;
case 'repost':
title = 'Someone reposted you';
title = t('notifyUi.activity.reposted');
break;
case 'mention':
title = 'Someone mentioned you';
title = t('notifyUi.activity.mentioned');
break;
case 'reply':
title = 'Someone replied to you';
title = t('notifyUi.activity.replied');
break;
case 'rsvp':
title = "Someone RSVP'd to your event";
title = t('notifyUi.activity.rsvp');
break;
case 'event-update':
title = "An event you're going to was updated";
title = t('notifyUi.activity.eventUpdated');
break;
default:
title = 'New activity';
title = t('notifyUi.activity.newActivity');
break;
}
return {
@ -125,7 +131,7 @@ function activityCandidate(n: ActivityNotification): Candidate {
source: 'social',
createdAt: n.createdAt,
title,
body: 'Tap to open SkateSpots',
body: t('notifyUi.activity.tapToOpen'),
path: '/',
};
}
@ -135,6 +141,7 @@ function buildCrewCandidates(
listedCrews: SkateCrew[],
joinRequests: Array<{ request: CrewJoinRequest }>,
collectiveRequests: CollectiveJoinRequest[],
t: Translate,
): Candidate[] {
const out: Candidate[] = [];
for (const inv of invites) {
@ -142,8 +149,8 @@ function buildCrewCandidates(
key: `crew-invite:${inv.id}`,
source: 'crew',
createdAt: inv.createdAt,
title: 'Crew invite',
body: `You're invited to ${inv.crewName}`,
title: t('notifyUi.crew.inviteTitle'),
body: t('notifyUi.crew.inviteBody', { crew: inv.crewName }),
path: coordPath(inv.crewCoord, 'crew', '/crews'),
});
}
@ -152,8 +159,8 @@ function buildCrewCandidates(
key: `crew-confirm:${crew.pubkey}:${crew.identifier}`,
source: 'crew',
createdAt: crew.createdAt ?? 0,
title: "You've been added to a crew",
body: `${crew.name} — confirm to join`,
title: t('notifyUi.crew.addedTitle'),
body: t('notifyUi.crew.addedBody', { crew: crew.name }),
path: `/crew/${crew.pubkey}/${crew.identifier}`,
});
}
@ -162,8 +169,8 @@ function buildCrewCandidates(
key: `crew-join:${request.id}`,
source: 'crew',
createdAt: request.createdAt,
title: 'Join request',
body: `Someone wants to join ${request.crewName}`,
title: t('notifyUi.crew.joinRequestTitle'),
body: t('notifyUi.crew.joinRequestBody', { name: request.crewName }),
path: coordPath(request.crewCoord, 'crew', '/crews'),
});
}
@ -172,40 +179,40 @@ function buildCrewCandidates(
key: `crew-collective:${req.id}`,
source: 'crew',
createdAt: req.createdAt,
title: 'Join request',
body: `Someone wants to join ${req.collectiveName}`,
title: t('notifyUi.crew.joinRequestTitle'),
body: t('notifyUi.crew.joinRequestBody', { name: req.collectiveName }),
path: '/crews',
});
}
return out;
}
function dmCandidate(envelope: DmEnvelope): Candidate {
function dmCandidate(envelope: DmEnvelope, t: Translate): Candidate {
return {
key: `dm:${envelope.id}`,
source: 'dms',
createdAt: envelope.createdAt,
title: 'New direct message',
body: 'Tap to read it',
title: t('notifyUi.dm.title'),
body: t('notifyUi.dm.body'),
path: '/messages',
};
}
function tradeCandidate(n: TradeNotification): Candidate {
function tradeCandidate(n: TradeNotification, t: Translate): Candidate {
const title =
n.type === 'trade-request'
? 'Confirm a trade'
? t('notifyUi.trade.confirm')
: n.type === 'trade-confirm'
? 'Trade confirmed'
? t('notifyUi.trade.confirmed')
: n.type === 'offer'
? 'New offer on your listing'
? t('notifyUi.trade.newOffer')
: n.type === 'counter'
? 'Counter-offer'
? t('notifyUi.trade.counter')
: n.type === 'accepted'
? 'Offer accepted 🎉'
? t('notifyUi.trade.accepted')
: n.type === 'declined'
? 'Offer declined'
: 'Offer withdrawn';
? t('notifyUi.trade.declined')
: t('notifyUi.trade.withdrawn');
return {
key: `trade:${n.id}`,
source: 'trades',
@ -216,18 +223,18 @@ function tradeCandidate(n: TradeNotification): Candidate {
};
}
function newsletterCandidate(issue: SkateNewsletterIssue): Candidate {
function newsletterCandidate(issue: SkateNewsletterIssue, t: Translate): Candidate {
return {
key: `newsletter:${issue.id}`,
source: 'newsletter',
createdAt: issue.publishedAt,
title: issue.title,
body: 'New SkateSpots drop',
body: t('notifyUi.newsletter.drop'),
path: '/newsletter',
};
}
async function dispatchNotifications(items: Candidate[]): Promise<void> {
async function dispatchNotifications(items: Candidate[], t: Translate): Promise<void> {
const permission = await getNotificationPermission();
if (permission !== 'granted') return;
@ -244,8 +251,8 @@ async function dispatchNotifications(items: Candidate[]): Promise<void> {
if (sorted.length > shown.length) {
await showOsNotification({
id: `summary:${Date.now()}`,
title: `${sorted.length - shown.length} more updates`,
body: 'Open SkateSpots to catch up',
title: t('notifyUi.summary.moreUpdates', { count: sorted.length - shown.length }),
body: t('notifyUi.summary.openToCatchUp'),
path: '/',
});
}
@ -265,6 +272,7 @@ export function OsNotificationBridge() {
function NotificationEngine({ prefs }: { prefs: NotificationPrefs }) {
const navigate = useNavigate();
const { t } = useTranslation();
const { user } = useCurrentUser();
const { notifications: activity, isLoading: activityLoading } = useActivityNotifications();
const { invites, listedCrews, joinRequests, collectiveRequests, isLoading: crewLoading } =
@ -293,17 +301,17 @@ function NotificationEngine({ prefs }: { prefs: NotificationPrefs }) {
};
}, [navigate]);
const socialItems = useMemo(() => activity.map(activityCandidate), [activity]);
const socialItems = useMemo(() => activity.map((n) => activityCandidate(n, t)), [activity, t]);
const crewItems = useMemo(
() => buildCrewCandidates(invites, listedCrews, joinRequests, collectiveRequests),
[invites, listedCrews, joinRequests, collectiveRequests],
() => buildCrewCandidates(invites, listedCrews, joinRequests, collectiveRequests, t),
[invites, listedCrews, joinRequests, collectiveRequests, t],
);
const newsletterItems = useMemo(
() => (isSubscribed ? issues.map(newsletterCandidate) : []),
[issues, isSubscribed],
() => (isSubscribed ? issues.map((issue) => newsletterCandidate(issue, t)) : []),
[issues, isSubscribed, t],
);
const dmItems = useMemo(() => envelopes.map(dmCandidate), [envelopes]);
const tradeItems = useMemo(() => tradeNotifs.map(tradeCandidate), [tradeNotifs]);
const dmItems = useMemo(() => envelopes.map((envelope) => dmCandidate(envelope, t)), [envelopes, t]);
const tradeItems = useMemo(() => tradeNotifs.map((n) => tradeCandidate(n, t)), [tradeNotifs, t]);
useEffect(() => {
const seen = seenRef.current;
@ -350,7 +358,7 @@ function NotificationEngine({ prefs }: { prefs: NotificationPrefs }) {
const allowed = fresh.filter((item) => prefs[item.source]);
if (allowed.length === 0) return;
void dispatchNotifications(allowed);
void dispatchNotifications(allowed, t);
}, [
socialItems,
crewItems,
@ -363,6 +371,7 @@ function NotificationEngine({ prefs }: { prefs: NotificationPrefs }) {
dmFetched,
tradeLoading,
prefs,
t,
]);
// Scheduled reminders are native-only (the web has no scheduler), so only

View file

@ -95,7 +95,7 @@ function ReportRow({ report }: { report: RecentReport }) {
{target ? (
<>
<p className="text-sm font-bold text-white">
{target.title || '(untitled)'}
{target.title || t('legacyMiscUi.reportsUntitled')}
{meta?.label && (
<span className="ml-2 text-[10px] font-bold uppercase tracking-widest text-gray-500">
{meta.label}
@ -109,8 +109,7 @@ function ReportRow({ report }: { report: RecentReport }) {
) : (
<p className="flex items-center gap-2 text-xs text-gray-500">
<ShieldQuestion className="h-3.5 w-3.5" />
Couldn't resolve the reported event (it may have been deleted, or the report targets a
profile rather than a post).
{t('legacyMiscUi.reportsResolveFailed')}
{report.content && <span className="text-gray-400">“{report.content}”</span>}
</p>
)}
@ -176,7 +175,7 @@ function ReportRow({ report }: { report: RecentReport }) {
<Button asChild size="sm" variant="ghost" className="gap-1.5 text-gray-400">
<Link to={`${meta.path}/${target.pubkey}/${target.identifier}`}>
<ExternalLink className="h-3.5 w-3.5" />
Look at it
{t('legacyMiscUi.reportsLookAtIt')}
</Link>
</Button>
)}
@ -191,6 +190,7 @@ function ReportRow({ report }: { report: RecentReport }) {
* reported shows up here, resolvable or not.
*/
export function ReportsInbox() {
const { t } = useTranslation();
const { data: reports, isLoading } = useRecentReports(200);
if (isLoading) {
@ -208,7 +208,7 @@ export function ReportsInbox() {
<CardContent className="space-y-3 py-12 text-center">
<CheckCircle2 className="mx-auto h-8 w-8 text-emerald-500" />
<p className="text-sm text-gray-400">
No reports yet. Either everything is fine, or nobody's found the button.
{t('legacyMiscUi.reportsNoReports')}
</p>
</CardContent>
</Card>

View file

@ -59,6 +59,7 @@ async function fetchWeatherForCity(city: string): Promise<string> {
temperature_c: weather.temperature,
feels_like_c: weather.feelsLike,
condition: weather.weatherDescription,
condition_key: weather.weatherDescriptionKey,
icon: weather.weatherIcon,
precipitation_mm: weather.precipitation,
wind_kmh: weather.windSpeed,
@ -66,6 +67,7 @@ async function fetchWeatherForCity(city: string): Promise<string> {
uv_index: weather.uvIndex,
skateability_score: skateability.score,
skateability_label: skateability.label,
skateability_label_key: skateability.labelKey,
skateability_reasons: skateability.reasons,
geohash,
is_day: weather.isDay,
@ -81,9 +83,11 @@ function parseWeatherLite(json: string): WeatherLite | undefined {
return {
temperature_c: typeof w.temperature_c === 'number' ? w.temperature_c : undefined,
condition: typeof w.condition === 'string' ? w.condition : undefined,
conditionKey: typeof w.condition_key === 'string' ? w.condition_key : undefined,
weatherIcon: typeof w.icon === 'string' ? w.icon : undefined,
precipitation_mm: typeof w.precipitation_mm === 'number' ? w.precipitation_mm : undefined,
skateability_label: typeof w.skateability_label === 'string' ? w.skateability_label : undefined,
skateabilityLabelKey: typeof w.skateability_label_key === 'string' ? w.skateability_label_key : undefined,
skateability_score: typeof w.skateability_score === 'number' ? w.skateability_score : undefined,
};
} catch {
@ -420,7 +424,9 @@ function formatLocalResults(intent: LocalIntent, result: unknown, t: Translate):
try {
const text = (r.content as Array<{ text: string }>)?.[0]?.text;
const w = JSON.parse(text) as Record<string, unknown>;
return `🌤️ **${w.city}** — ${w.condition} ${w.weatherIcon ?? ''}\n${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) })}`;
const condition = t(String(w.condition_key ?? w.condition ?? ''));
const skateLabel = t(String(w.skateability_label_key ?? w.skateability_label ?? ''));
return `🌤️ **${w.city}** — ${condition} ${w.weatherIcon ?? ''}\n${t('sasquatchChat.weatherTemp', { temp: String(w.temperature_c), feels: String(w.feels_like_c) })}\n${t('sasquatchChat.weatherSkateability', { label: skateLabel, score: String(w.skateability_score) })}`;
} catch {
return t('sasquatchChat.weatherError');
}

View file

@ -83,7 +83,7 @@ export function SetStatusDialog({
} catch (error) {
toast({
title: t('toastsUi.statusFailed'),
description: error instanceof Error ? error.message : 'Something went wrong',
description: error instanceof Error ? error.message : t('legacyMiscUi.statusSomethingWrong'),
variant: 'destructive',
});
}
@ -100,7 +100,7 @@ export function SetStatusDialog({
} catch (error) {
toast({
title: t('toastsUi.statusClearFailed'),
description: error instanceof Error ? error.message : 'Something went wrong',
description: error instanceof Error ? error.message : t('legacyMiscUi.statusSomethingWrong'),
variant: 'destructive',
});
}
@ -113,10 +113,12 @@ export function SetStatusDialog({
};
const formatExpiration = (minutes: number) => {
if (minutes < 60) return `${minutes} min`;
if (minutes === 60) return '1 hour';
if (minutes < 1440) return `${Math.round(minutes / 60)} hours`;
return `${Math.round(minutes / 1440)} day${minutes >= 2880 ? 's' : ''}`;
if (minutes < 60) return t('legacyMiscUi.statusExpMin', { count: minutes });
if (minutes === 60) return t('legacyMiscUi.statusExpOneHour');
if (minutes < 1440) return t('legacyMiscUi.statusExpHours', { count: Math.round(minutes / 60) });
return minutes >= 2880
? t('legacyMiscUi.statusExpDays', { count: Math.round(minutes / 1440) })
: t('legacyMiscUi.statusExpOneDay');
};
if (!user) return null;
@ -127,10 +129,10 @@ export function SetStatusDialog({
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-white">
<Zap className="h-5 w-5 text-[#ccff00]" />
Set Your Status
{t('legacyMiscUi.statusSetYourStatus')}
</DialogTitle>
<DialogDescription>
Let others know what you're up to. Your status will be visible on your profile and at linked spots.
{t('legacyMiscUi.statusSetDesc')}
</DialogDescription>
</DialogHeader>
@ -161,7 +163,7 @@ export function SetStatusDialog({
) : (
<>
<X className="h-3 w-3 mr-1" />
Clear
{t('legacyMiscUi.statusClear')}
</>
)}
</Button>
@ -207,11 +209,11 @@ export function SetStatusDialog({
{/* Custom Status */}
<div className="space-y-2">
<Label htmlFor="custom-status" className="text-sm font-bold text-gray-400">
Or write your own
{t('legacyMiscUi.statusOrWriteOwn')}
</Label>
<Input
id="custom-status"
placeholder="What are you up to?"
placeholder={t('legacyMiscUi.statusCustomPlaceholder')}
value={customStatus}
onChange={(e) => {
setCustomStatus(e.target.value);
@ -225,7 +227,7 @@ export function SetStatusDialog({
<div className="space-y-2">
<Label className="text-sm font-bold text-[#ff0040] flex items-center gap-1">
<MapPin className="h-3 w-3" />
Link to a Spot (optional)
{t('legacyMiscUi.statusLinkToSpot')}
</Label>
{linkedSpot ? (
<Card className="bg-[#ff0040]/10 border-[#ff0040]/30">
@ -233,7 +235,7 @@ export function SetStatusDialog({
<div className="flex items-center gap-2">
<MapPin className="h-4 w-4 text-[#ff0040]" />
<span className="text-sm font-medium text-white">
{linkedSpot.title || 'Spot selected'}
{linkedSpot.title || t('legacyMiscUi.statusSpotSelected')}
</span>
</div>
<Button
@ -254,7 +256,7 @@ export function SetStatusDialog({
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500" />
<Input
placeholder="Search spots..."
placeholder={t('legacyMiscUi.statusSearchSpots')}
value={spotSearchQuery}
onChange={(e) => setSpotSearchQuery(e.target.value)}
className="bg-gray-800 border-gray-700 pl-9 h-9"
@ -294,7 +296,7 @@ export function SetStatusDialog({
))}
{filteredSpots.length === 0 && (
<p className="text-sm text-gray-400 text-center py-4">
No spots found.
{t('legacyMiscUi.statusNoSpotsFound')}
</p>
)}
</div>
@ -309,7 +311,7 @@ export function SetStatusDialog({
className="w-full border-gray-700 text-gray-400"
>
<MapPin className="h-4 w-4 mr-2" />
Select a spot
{t('legacyMiscUi.statusSelectSpot')}
</Button>
)}
</div>
@ -318,7 +320,7 @@ export function SetStatusDialog({
<div className="space-y-3">
<Label className="text-sm font-bold text-orange-400 flex items-center gap-1">
<Clock className="h-3 w-3" />
Auto-clear after: {formatExpiration(expirationMinutes)}
{t('legacyMiscUi.statusAutoClearAfter', { time: formatExpiration(expirationMinutes) })}
</Label>
<Slider
value={[expirationMinutes]}
@ -329,10 +331,10 @@ export function SetStatusDialog({
className="w-full"
/>
<div className="flex justify-between text-xs text-gray-500">
<span>15 min</span>
<span>1 hr</span>
<span>4 hrs</span>
<span>24 hrs</span>
<span>{t('legacyMiscUi.statusSlider15')}</span>
<span>{t('legacyMiscUi.statusSlider1h')}</span>
<span>{t('legacyMiscUi.statusSlider4h')}</span>
<span>{t('legacyMiscUi.statusSlider24h')}</span>
</div>
</div>
@ -345,12 +347,12 @@ export function SetStatusDialog({
{setStatus.isPending ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Setting Status...
{t('legacyMiscUi.statusSetting')}
</>
) : (
<>
<Zap className="h-4 w-4 mr-2" />
Set Status
{t('legacyMiscUi.statusSet')}
</>
)}
</Button>

View file

@ -61,7 +61,7 @@ export function SkaterDirectoryCard() {
className="text-[10px] font-black uppercase tracking-wide px-2 py-0.5 rounded-full text-black"
style={{ backgroundColor: level.color }}
>
{level.emoji} {level.label}
{level.emoji} {t(level.labelKey)}
</span>
) : skater.customLevel ? (
<span className="text-[10px] font-black uppercase tracking-wide px-2 py-0.5 rounded-full bg-[#ccff00] text-black">
@ -73,7 +73,7 @@ export function SkaterDirectoryCard() {
key={s}
className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-muted text-muted-foreground border border-border"
>
{SKATER_STYLES[s].emoji} {SKATER_STYLES[s].label}
{SKATER_STYLES[s].emoji} {t(SKATER_STYLES[s].labelKey)}
</span>
))}
{skater.styles.length > 3 && (

View file

@ -16,6 +16,7 @@
import { cn } from '@/lib/utils';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { useTranslation } from '@/contexts/language';
export interface Sponsor {
name: string;
@ -36,9 +37,11 @@ interface SponsorSlotProps {
export function SponsorSlot({
size,
sponsors,
title = 'Partners',
title,
className,
}: SponsorSlotProps) {
const { t } = useTranslation();
const heading = title ?? t('legacyMiscUi.sponsorPartners');
// If no sponsors, render nothing (invisible placeholder)
if (!sponsors || sponsors.length === 0) {
return null;
@ -53,7 +56,7 @@ export function SponsorSlot({
)}>
<div className="container px-4">
<p className="text-xs text-gray-500 uppercase tracking-wider mb-4 text-center">
{title}
{heading}
</p>
<div className="flex items-center justify-center gap-8 flex-wrap">
{sponsors.map((sponsor) => (
@ -73,7 +76,7 @@ export function SponsorSlot({
className
)}>
<p className="text-xs text-gray-500 uppercase tracking-wider mb-3">
{title}
{heading}
</p>
<div className="flex flex-col gap-3">
{sponsors.map((sponsor) => (
@ -91,7 +94,7 @@ export function SponsorSlot({
'flex items-center gap-2 text-xs text-gray-500',
className
)}>
<span>{title}:</span>
<span>{heading}:</span>
{sponsors.map((sponsor, idx) => (
<span key={sponsor.name}>
<SponsorItem sponsor={sponsor} size="inline" />
@ -107,6 +110,7 @@ export function SponsorSlot({
// Individual sponsor display
function SponsorItem({ sponsor, size }: { sponsor: Sponsor; size: 'banner' | 'card' | 'inline' }) {
const { t } = useTranslation();
// SECURITY: sponsor data comes from event tags (a tour author controls it),
// so sanitize before use as an href/src — same as EventSponsors.
const safeUrl = sponsor.url ? sanitizeUrl(sponsor.url) : null;
@ -140,7 +144,7 @@ function SponsorItem({ sponsor, size }: { sponsor: Sponsor; size: 'banner' | 'ca
{sponsor.name}
</span>
{sponsor.tier === 'title' && size !== 'inline' && (
<span className="text-[10px] text-yellow-500 uppercase">Title Sponsor</span>
<span className="text-[10px] text-yellow-500 uppercase">{t('legacyMiscUi.sponsorTitleSponsor')}</span>
)}
</div>
);

View file

@ -598,12 +598,12 @@ function createPopupContent(
<span class="text-2xl">${escapeHtml(weatherData.weather.weatherIcon)}</span>
<div>
<div class="font-semibold">${escapeHtml(weatherData.weather.temperature)}°C</div>
<div class="text-xs text-gray-500">${escapeHtml(weatherData.weather.weatherDescription)}</div>
<div class="text-xs text-gray-500">${escapeHtml(t(weatherData.weather.weatherDescriptionKey))}</div>
</div>
</div>
<div class="text-right">
<div class="text-xs font-semibold" style="color: ${escapeHtml(weatherData.skateability.color)}">
${escapeHtml(t('mapUi.conditions'))} · ${escapeHtml(weatherData.skateability.label)}
${escapeHtml(t('mapUi.conditions'))} · ${escapeHtml(t(weatherData.skateability.labelKey))}
</div>
<div class="text-xs text-gray-500">${escapeHtml(t('mapUi.weatherScoreSuffix', { score: weatherData.skateability.score }))}</div>
</div>
@ -616,7 +616,7 @@ function createPopupContent(
details.innerHTML = `
<div>💨 ${escapeHtml(t('mapUi.wind'))}: ${escapeHtml(weatherData.weather.windSpeed)} km/h ${escapeHtml(weatherData.weather.windDirectionLabel)}</div>
<div>💧 ${escapeHtml(t('mapUi.humidity'))}: ${escapeHtml(weatherData.weather.humidity)}%</div>
${weatherData.airQuality ? `<div>🌫️ ${escapeHtml(t('mapUi.airQuality'))}: ${escapeHtml(weatherData.airQuality.aqiLabel)} (${escapeHtml(weatherData.airQuality.aqi)})</div>` : ''}
${weatherData.airQuality ? `<div>🌫️ ${escapeHtml(t('mapUi.airQuality'))}: ${escapeHtml(t(weatherData.airQuality.aqiLabelKey))} (${escapeHtml(weatherData.airQuality.aqi)})</div>` : ''}
`;
weatherSection.appendChild(details);

View file

@ -86,12 +86,12 @@ export function TripItineraryCard({ itinerary }: { itinerary: Itinerary }) {
</div>
{w && (
<div className="mt-1 flex items-center gap-1.5 text-[9px] text-gray-300">
<span>{w.weatherIcon} {w.condition}</span>
<span>{w.weatherIcon} {t(w.conditionKey ?? w.condition ?? '')}</span>
{typeof w.temperature_c === 'number' && <span>· {Math.round(w.temperature_c)}°C</span>}
{w.skateability_label && (
{(w.skateabilityLabelKey ?? w.skateability_label) && (
<span className={cn('px-1 rounded font-bold',
(w.skateability_score ?? 0) >= 60 ? 'bg-[#ccff00]/15 text-[#ccff00]' : 'bg-orange-500/15 text-orange-300')}>
{w.skateability_label}
{t(w.skateabilityLabelKey ?? w.skateability_label ?? '')}
</span>
)}
</div>

View file

@ -583,7 +583,7 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
setIsZapping(true);
try {
const activeMint = mints.find((m) => m.url);
if (!activeMint) throw new Error('No active Cashu mint found.');
if (!activeMint) throw new Error(t('legacyMiscUi.zapNoActiveMint'));
// Same verified invoice the Lightning rail would use (amount-checked).
const zapInvoice = await generateZapInvoice(finalAmount, comment);

View file

@ -4,6 +4,7 @@
*/
import { batch3En } from './batch3';
import { batch4En } from './batch4';
import { legacyEn } from './legacy';
const en = {
// Common
@ -7366,4 +7367,4 @@ walletUi: {
},
};
export default { ...en, ...batch3En, ...batch4En };
export default { ...en, ...batch3En, ...batch4En, ...legacyEn };

View file

@ -5,6 +5,7 @@
*/
import { batch3Es } from './batch3';
import { batch4Es } from './batch4';
import { legacyEs } from './legacy';
const es = {
// Common
@ -7366,4 +7367,4 @@ walletUi: {
},
};
export default { ...es, ...batch3Es, ...batch4Es };
export default { ...es, ...batch3Es, ...batch4Es, ...legacyEs };

201
src/i18n/ja/batch16.ts Normal file
View file

@ -0,0 +1,201 @@
/**
* Japanese (日本語) — batch 16: the deck-cost calculator UI (`deckCostUi.*`).
*
* On-screen strings for `components/DeckCostCalculator.tsx`. Skate/brand terms
* follow the rest of `ja` (デッキ for deck; sats, Nostr, Lightning, NIP-05,
* Cashu, Incoterm codes, Markdown and JSON stay English). `{{placeholders}}` are
* preserved verbatim.
*/
const jaBatch16 = {
deckCostUi: {
// Toast / prompt / confirm
presetPrompt: 'このプリセットに名前を付ける',
presetDefaultName: 'プリセット {{n}}',
summaryCopied: 'サマリーをコピーしました',
couldNotCopy: 'コピーできませんでした',
resetConfirm: 'すべてサンプル値にリセットしますか?',
logoTooLarge: 'ロゴが大きすぎます(最大 500 KB)',
loginToFill: 'Nostr プロフィールから入力するにはログインしてください',
filledFromProfile: 'Nostr プロフィールから入力しました',
// Sanity warnings
warnRetailBelowOne: '小売の掛け率が 1× 未満です — このままでは赤字になります。',
warnWholesaleAboveRetail: '卸売の掛け率は小売の掛け率より低くしてください。',
warnZeroBoards: '{{count}} 行が 0 デッキです — その行のコストは無視されます。',
warnFixedNoBoards: '固定月次コストが設定されていますが、デッキ/月が 0 のため、運用コストは無視されます。',
warnNoMaterials: '材料費がありません — 少なくとも 1 つの材料を追加してください。',
warnEquipmentOverhead: '設備 + 諸経費がコストの 60% を超えています — このモデルは大量生産を前提としています。デッキ数を確認してください。',
warnOneBoard: '一部の設備が 1 枚のデッキに配賦されています — 償却するにはデッキ数を増やしてください。',
// Quick-start templates
templateStarterName: 'ホビー入門',
templateStarterBlurb: '行数少なめ、固定費なし',
templateShopName: '小さな工房',
templateShopBlurb: '家賃と諸経費を含むフルシート',
// Currency bar
currency: '通貨',
noCurrencyMatches: '一致する通貨がありません。',
ratesUnavailable: '為替レートを取得できません — 切り替えても表示名のみ変わります。',
liveRates: 'BTC レート(ライブ)· sats 込み',
chipCost: 'コスト',
chipSell: '販売',
chipTotal: '合計',
// Toolbar
startFrom: 'から開始',
advancedToggleHint: '工程ごとのレートと廃棄、バッチ段取り、税金、コスト対数量の曲線を表示します',
advanced: '詳細',
simple: 'シンプル',
// Sections
sectionLabor: '人件費',
sectionEquipment: '設備',
sectionOperations: '運用費',
sectionMaterials: '材料',
sectionProcesses: '工程費',
sectionPackaging: '梱包',
sectionMargins: 'マージン',
subtotalLoaded: '{{value}}/時間(負担込み)',
subtotalPerBoard: '{{value}}/デッキ',
// Labor
hourlyRate: '時給({{unit}})',
labourBurden: '労務負担(×)',
loadedRate: '負担込みレート',
loadedRateHintBefore: '負担込みレート = 賃金 × 負担(雇用主の労働税と保険)。家賃や請求書は ',
loadedRateHintAfter: ' に含まれます — 二重計上しないでください。',
// Table columns
colEquipment: '設備',
colCostUnit: 'コスト({{unit}})',
colCost: 'コスト',
colSetupH: '段取り(h)',
colLifetimeBoards: '生涯デッキ数',
colPerBoard: '1 枚あたり',
colMaterial: '材料',
colOrderUnit: '発注({{unit}})',
colShipUnit: '送料({{unit}})',
colBoardsOrder: 'デッキ / 発注',
colOrderM: '発注(分)',
colUnloadM: '荷降ろし(分)',
colOrder: '発注',
colShipping: '送料',
colOrderMin: '発注(分)',
colUnloadMin: '荷降ろし(分)',
colProcess: '工程',
colTimeM: '時間(分)',
colBoardsRun: 'デッキ / 回',
colRateHr: 'レート/時(0 = 工房)',
colRateHrShort: 'レート/時',
colScrapPct: '廃棄 %',
colTimeMin: '時間(分)',
colPackaging: '梱包',
colTimeBoardM: '時間/デッキ(分)',
colTimeBoardMin: '時間/デッキ(分)',
colMaterialsBoardUnit: '材料/デッキ({{unit}})',
colMaterialsBoard: '材料/デッキ',
// Section footers
equipmentPerBoard: '設備の 1 枚あたり:',
materialsPerBoard: '材料の 1 枚あたり:',
processesPerBoard: '工程の 1 枚あたり:',
packagingPerBoard: '梱包の 1 枚あたり:',
perBoard: '1 枚あたり:',
// Row actions
addEquipment: '設備を追加',
addMaterial: '材料を追加',
addProcess: '工程を追加',
addPackagingStep: '梱包工程を追加',
duplicate: '複製',
remove: '削除',
// Operations
boardsPerMonth: 'デッキ / 月',
rentPerMonth: '家賃 / 月',
utilitiesPerMonth: '光熱費 / 月',
subscriptionsPerMonth: 'サブスク / 月',
// Margins & results
markup: '掛け率(×)',
wholesaleMarkup: '卸売の掛け率(×)',
scrapWaste: '廃棄 / ロス(%)',
quantityDecks: '数量(デッキ)',
targetMonthlyProfit: '月間目標利益({{unit}})',
costNoPackaging: 'コスト / デッキ(梱包なし)',
costWithPackaging: 'コスト / デッキ(梱包あり)',
batchSetupHours: 'バッチ段取り(h / 回)',
batchSetupCost: 'バッチ段取りコスト({{unit}} / 回)',
taxVat: '税 / VAT(%)',
quoteTax: '見積の税',
includedInPrice: '価格に含む',
addedOnTop: '上乗せ',
setupPerBoard: '段取り / デッキ',
marginNoteBefore: '掛け率 {{markup}}× ≈ ',
grossMargin: '売上総利益 {{pct}}%',
marginNoteAfter: ' · ロス {{waste}}% は良品のデッキに配賦されます。',
sellingPriceNoPackaging: '販売価格 · 梱包なし',
sellingPriceWithPackaging: '販売価格 · 梱包あり',
profitPerDeck: '利益 / デッキ',
wholesalePerDeck: '卸売 / デッキ',
orderTotalQty: '注文合計({{qty}})',
orderTotal: '注文合計',
breakeven: '損益分岐点',
breakevenPerMonth: '{{count}}/月',
targetDecksHint: '固定費に加えて月 {{amount}} を稼ぐには、月 {{decks}} デッキ販売してください。',
priceIncludesTax: '価格には {{pct}}% の税が含まれます:合計のうち {{amount}}。',
plusTax: 'さらに {{pct}}% の税:{{amount}} → 合計 {{total}}。',
costVsQuantity: 'コスト対数量(段取りを償却)',
deckOne: 'デッキ',
deckOther: 'デッキ',
perDeckSuffix: '/ デッキ',
orderSuffix: '注文 {{amount}}',
shippingNotIncluded: '買い手への送料は含みません。',
// Quote details
clientQuoteDetails: '見積の詳細(PDF 用)',
fillFromProfileTitle: 'Nostr プロフィールから名前、ウェブサイト、NIP-05、Lightning、npub を入力します',
fillFromProfile: 'プロフィールから入力',
businessName: '事業者 / ショップ名',
email: 'メール',
phone: '電話',
website: 'ウェブサイト',
address: '住所',
quoteNumber: '見積番号',
nextNumber: '次の番号',
validDays: '有効期間(日)',
nip05: 'NIP-05',
lightningAddress: 'Lightning アドレス',
nostrNpub: 'Nostr(npub)',
depositPct: '前払いの頭金(%)',
deliveryTerms: '受渡条件(Incoterm、任意)',
paymentMethods: '支払い方法',
paymentTerms: '支払い条件',
paymentDetails: '支払い詳細',
notesTerms: 'メモ / 条件',
changeLogo: 'ロゴを変更',
addLogo: 'ロゴを追加',
// Incoterms
incotermNone: 'なし',
incotermExw: 'EXW — 売主からの引き取り',
incotermFca: 'FCA — 買主の運送業者に引き渡し',
incotermDap: 'DAP — 買主の住所へ配送',
incotermDdp: 'DDP — 関税込みで配送',
// Payment methods
payMethodCash: '現金',
payMethodLightning: 'Lightning',
payMethodCashu: 'Cashu',
payMethodBank: '銀行振込',
payMethodCard: 'カード',
payMethodOther: 'その他',
// Actions
listDeckAtPrice: 'この価格でデッキを出品',
getItMade: '作ってもらう — ビルダーを探す',
savePreset: 'プリセットを保存',
load: '読み込む',
export: 'エクスポート',
import: 'インポート',
reset: 'リセット',
deletePreset: 'プリセットを削除',
// Export menu
exportPdfFull: 'PDF — フルシート',
exportPdfQuote: 'PDF — 顧客向け見積',
exportPng: 'PNG 画像',
exportXlsx: 'XLSX ワークブック',
exportCsv: 'CSV(表計算)',
exportMarkdown: 'Markdown',
exportJson: 'JSON(バックアップ)',
exportCopySummary: 'サマリーをコピー',
},
};
export default jaBatch16;

157
src/i18n/ja/batch17.ts Normal file
View file

@ -0,0 +1,157 @@
/**
* Japanese (日本語) — batch 17: Lab role-form strings (`becomeRolesUi`).
*
* The copy that lives in the `BECOME_ROLES` data table (`src/lib/becomeRoles.tsx`)
* and is rendered by `CraftProfileForm`, `BecomeRolePage` (SEO meta) and
* `LabProfilePreview`.
*
* Brand/protocol names and skate vocabulary stay English or katakana
* (The Lab, Coach, Crew→クルー, Spot→スポット, Stash, Nostr, Lightning, Zap,
* sats, SkateSpots), and trick names stay English. `{{placeholders}}` (if any)
* are kept verbatim.
*/
const jaBatch17 = {
becomeRolesUi: {
artist: {
seoTitle: 'デザイナー / アーティストになる - SkateSpots',
seoDescription: 'スケートのデザインやアートを掲載しましょう — デッキグラフィック、ロゴ、ポスター、グッズなど。',
heading: 'デザイナー / アーティスト',
tagline: 'デッキグラフィック、ロゴ、ポスター、グッズ — あなたのアートをシーンへ',
noun: 'アーティスト',
rateLabel: '料金',
portfolioLabel: 'ポートフォリオ / ショップのリンク',
sectionWhatYouMake: '制作するもの',
sectionMedium: '技法',
sectionSoftwareTools: 'ソフトウェアとツール',
deliverablesPlaceholder: '例: 印刷用ファイル、ソースファイル、デッキグラフィック',
turnaroundPlaceholder: '例: 3〜5日',
},
graffiti: {
seoTitle: 'グラフィティ / 壁画を提供 - SkateSpots',
seoDescription: 'グラフィティや壁画の作品を掲載しましょう — ピース、壁画、スケートパークのアートなど。',
heading: 'グラフィティ / 壁画アーティスト',
tagline: 'ピース、壁画、スケートパークのアート — シーンを彩ろう',
noun: 'グラフィティアーティスト',
rateLabel: '料金',
portfolioLabel: 'ポートフォリオのリンク (IG、サイト)',
sectionStyle: 'スタイル',
sectionMedium: '技法',
sectionWorkContext: '制作の場面',
crewLabel: 'グラフィティのクルー',
crewPlaceholder: 'あなたのライティングクルー (任意)',
crewHelper: 'スケートのクルーとは別 — 一緒に描くライターたち。',
surfacesPlaceholder: '例: コンクリート、レンガ、許可された壁',
maxSizePlaceholder: '例: 最大10 m',
},
musician: {
seoTitle: '音楽プロフィールを追加 - SkateSpots',
seoDescription: 'スケートをして音楽もやる? バンドやソロプロジェクトを掲載しましょう — ジャンル、楽器、リンク。',
heading: 'ミュージシャン / バンド',
tagline: 'スケートも音楽もやる? デモ、ジャム、アフターパーティーの出演依頼を受けて',
noun: 'ミュージシャン',
rateLabel: '出演料',
portfolioLabel: 'ウェブサイト / EPKのリンク',
sectionGenre: 'ジャンル',
sectionInstruments: '楽器と役割',
actLabel: '出演形態',
actPlaceholder: 'ソロ / バンド / DJ / プロデューサー',
spotifyLabel: 'Spotify',
spotifyPlaceholder: 'https://open.spotify.com/…',
bandcampLabel: 'Bandcamp',
bandcampPlaceholder: 'https://…bandcamp.com',
soundcloudLabel: 'SoundCloud',
soundcloudPlaceholder: 'https://soundcloud.com/…',
youtubeLabel: 'YouTube',
youtubePlaceholder: 'https://youtube.com/…',
setLengthPlaceholder: '例: 30〜45分',
techRiderPlaceholder: '例: PA、マイク、バックライン',
},
judge: {
seoTitle: 'スケートイベントの審査員になる - SkateSpots',
seoDescription: 'スケートイベントの審査員として登録しましょう — Game of S.K.A.T.E.、ベストトリック、コンテスト、アマシリーズ、ワールドツアー。公正で信頼できるイベント運営のために依頼されましょう。',
heading: 'スケートイベント審査員',
tagline: '平地のGame of S.K.A.T.E.からWorld Destruction Tourまで — 公正に、信頼できる審査を',
noun: '審査員',
rateLabel: '日当 / 報酬',
portfolioLabel: '実績アーカイブ / プロフィールのリンク',
sectionEventFormats: 'イベント形式',
sectionSpecialty: '専門分野',
sectionLevelCircuit: 'レベル / サーキット',
eventsJudgedLabel: '審査したイベント',
eventsJudgedPlaceholder: '例: 40以上のコンテスト、ワールドツアー3戦',
eventsJudgedHelper: 'あなたが審査した注目のイベント、コンテスト、ツアー。',
travelPlaceholder: '例: 地域 / 全国 / 海外',
certificationsPlaceholder: '例: 全国審査員講習',
},
organizer: {
seoTitle: 'スケートイベントを企画する - SkateSpots',
seoDescription: 'スケートイベントの主催者として登録しましょう — ストリートジャム、ベストトリック、コンテスト、ミートアップ、デモ、ツアー。発見されて、セッション運営を依頼されましょう。',
heading: 'イベント主催者',
tagline: 'ジャム、コンテスト、ミートアップ、デモ — セッションを開いてシーンをつなごう',
noun: '主催者',
rateLabel: 'イベント料 / 予算',
portfolioLabel: '過去のイベント / 実績のリンク',
sectionEventTypes: 'イベントの種類',
sectionWhatYouHandle: '担当する業務',
sectionScale: '規模',
eventsHostedLabel: '主催したイベント',
eventsHostedPlaceholder: '例: 25のジャム、市内コンテスト3件',
eventsHostedHelper: 'あなたが主催した注目のイベント、コンテスト、ミートアップ。',
insurancePlaceholder: '例: 対人賠償責任保険込み',
permitsPlaceholder: '例: 自治体の許可取得済み',
},
tattoo: {
seoTitle: 'タトゥーアーティストになる - SkateSpots',
seoDescription: 'スケートシーンのためのタトゥー作品を掲載しましょう — スタイル、フラッシュ、カスタムなど。',
heading: 'タトゥーアーティスト',
tagline: 'シーンのためのインク — フラッシュ、カスタム、ファインラインなど',
noun: 'タトゥーアーティスト',
rateLabel: '料金 / 時間制',
portfolioLabel: 'ポートフォリオのリンク (IG、サイト)',
sectionStyles: 'スタイル',
sectionMethod: '方法',
studioLabel: 'スタジオ / 店舗',
studioPlaceholder: 'スタジオ名 (任意)',
booksOpenLabel: '予約受付中',
booksOpenHelper: '現在予約を受け付けていますか?',
depositPlaceholder: '例: 50 EUR 返金不可',
minimumPlaceholder: '例: 80 EUR',
},
trainer: {
seoTitle: 'トレーナー / 理学療法士になる - SkateSpots',
seoDescription: 'スケーターが強く健康でいられるよう支援しましょう — 筋力、可動性、怪我の予防、リカバリー。',
heading: 'トレーナー / 理学療法士',
tagline: 'スケーターのための筋力、可動性、怪我の予防、リカバリー',
noun: 'トレーナー',
rateLabel: '料金 / セッション',
portfolioLabel: 'プロフィール / 予約のリンク',
sectionFocus: '重点',
sectionInjuries: '対応する怪我',
sectionCredentialsSetting: '資格と環境',
credentialsPlaceholder: '例: 理学療法学士、CSCS…',
credentialsHelper: '実際の資格を記載しましょう — スケーターは検証できる資格を信頼します。',
remoteLabel: 'リモート / オンライン',
remoteHelper: 'オンラインでのコーチングや相談を提供していますか?',
modalitiesPlaceholder: '例: 手技、S&C、ドライニードリング',
},
barber: {
seoTitle: 'バーバー / ヘアスタイリストになる - SkateSpots',
seoDescription: 'スケートシーンのための理髪店や椅子を掲載しましょう — カット、フェード、髭剃り、カラーなど。スケーターに予約されましょう。',
heading: 'バーバー / ヘアスタイリスト',
tagline: 'シーンのためのカット、フェード、髭剃り — 店舗または出張',
noun: 'バーバー',
rateLabel: '料金 / カット',
portfolioLabel: 'カットギャラリー / IGのリンク',
sectionWhatYouOffer: '提供するサービス',
sectionSignatureStyles: '得意なスタイル',
sectionSetting: '環境',
studioLabel: '店舗 / スタジオ',
studioPlaceholder: '店舗またはスタジオ名 (任意)',
booksOpenLabel: '予約受付中',
booksOpenHelper: '現在予約を受け付けていますか?',
durationPlaceholder: '例: 30〜45分',
},
},
};
export default jaBatch17;

82
src/i18n/ja/batch18.ts Normal file
View file

@ -0,0 +1,82 @@
/**
* Japanese (日本語) — batch 18: `weatherUi` (Open-Meteo weather blocks).
*
* Mirrors `src/lib/weather.ts` — `condition` (WMO weather codes), `aqi`
* (air-quality bands), `skateability` (score label) and `reason`
* (skateability reasons). Skate vocabulary stays English/katakana; no
* placeholders to preserve. Keys must match `src/i18n/legacy/weather.ts`.
*/
const jaBatch18 = {
weatherUi: {
condition: {
clearSky: '快晴',
mainlyClear: 'おおむね晴れ',
partlyCloudy: '晴れ時々曇り',
overcast: '曇り',
foggy: '霧',
depositingRimeFog: '霧氷を伴う霧',
lightDrizzle: '弱い霧雨',
moderateDrizzle: '霧雨',
denseDrizzle: '強い霧雨',
lightFreezingDrizzle: '弱い着氷性霧雨',
denseFreezingDrizzle: '強い着氷性霧雨',
slightRain: '小雨',
moderateRain: '雨',
heavyRain: '大雨',
lightFreezingRain: '弱い着氷性の雨',
heavyFreezingRain: '強い着氷性の雨',
slightSnow: '小雪',
moderateSnow: '雪',
heavySnow: '大雪',
snowGrains: '霧雪',
slightRainShowers: '弱いにわか雨',
moderateRainShowers: 'にわか雨',
violentRainShowers: '激しいにわか雨',
slightSnowShowers: '弱いにわか雪',
heavySnowShowers: '強い雪',
thunderstorm: '雷雨',
thunderstormSlightHail: '弱いひょうを伴う雷雨',
thunderstormHeavyHail: '激しいひょうを伴う雷雨',
unknown: '不明',
},
aqi: {
good: '良い',
moderate: '普通',
unhealthySensitive: '敏感な人に不健康',
unhealthy: '不健康',
veryUnhealthy: '非常に不健康',
hazardous: '危険',
},
skateability: {
perfect: '最高',
good: '良い',
okay: 'まあまあ',
poor: '悪い',
bad: '良くない',
unrideable: '乗れない',
},
reason: {
currentlyRaining: '現在雨が降っています',
raining: '雨',
snowing: '雪',
thunderstorm: '雷雨',
freezingTemperature: '氷点下',
veryCold: '非常に寒い',
extremelyHot: '猛暑',
veryHot: 'とても暑い',
veryStrongWind: '非常に強い風',
strongWind: '強い風',
moderateWind: '中程度の風',
veryUnhealthyAir: '非常に不健康な空気',
unhealthyAir: '不健康な空気',
moderateAirQuality: '中程度の空気質',
veryHumid: '非常に湿っている',
humid: '湿っている',
extremeUv: '極端な紫外線 - 日焼け止めを',
highUv: '強い紫外線 - 日焼け対策を推奨',
perfectConditions: 'スケートに最適なコンディション',
},
},
};
export default jaBatch18;

85
src/i18n/ja/batch19.ts Normal file
View file

@ -0,0 +1,85 @@
/**
* 日本語 — batch 19: `skaterLabelsUi` + `notifyUi`.
*
* Keys mirror `src/i18n/legacy/skaterLabels.ts` and
* `src/i18n/legacy/notify.ts` exactly; `{{placeholders}}` stay byte-identical.
* Skate terrain/stance terms (Street, Park, Vert, Bowl, Regular, Goofy, DIY)
* stay English — Japanese skaters use the English terms.
*/
const jaBatch19 = {
skaterLabelsUi: {
stance: {
regular: 'Regular',
goofy: 'Goofy',
},
style: {
street: 'Street',
park: 'Park',
vert: 'Vert',
transition: 'トランジション',
bowl: 'Bowl',
cruising: 'クルージング',
downhill: 'ダウンヒル',
freestyle: 'フリースタイル',
ledges: 'レッジ',
stairs: '階段',
diy: 'DIY',
},
level: {
grom: 'ビギナー',
intermediate: '中級者',
advanced: '上級者',
ripper: '超上級者',
},
sponsor: {
title: 'タイトルスポンサー',
gold: 'ゴールド',
silver: 'シルバー',
partner: 'パートナー',
},
},
notifyUi: {
activity: {
zappedWithAmount: '{{sats}} sats の zap を受け取りました',
zapped: 'zap を受け取りました',
liked: 'あなたの投稿にいいねがありました',
reposted: 'あなたがリポストされました',
mentioned: 'あなたがメンションされました',
replied: 'あなたに返信がありました',
rsvp: 'あなたのイベントに参加表明がありました',
eventUpdated: '参加予定のイベントが更新されました',
newActivity: '新しいアクティビティ',
tapToOpen: 'タップして SkateSpots を開く',
},
crew: {
inviteTitle: 'crew への招待',
inviteBody: '{{crew}} に招待されています',
addedTitle: 'crew に追加されました',
addedBody: '{{crew}} — 参加を確認',
joinRequestTitle: '参加リクエスト',
joinRequestBody: '{{name}} に参加したい人がいます',
},
dm: {
title: '新しいダイレクトメッセージ',
body: 'タップして読む',
},
trade: {
confirm: '取引を確認',
confirmed: '取引が確認されました',
newOffer: 'あなたの出品に新しいオファー',
counter: 'カウンターオファー',
accepted: 'オファーが承諾されました 🎉',
declined: 'オファーが辞退されました',
withdrawn: 'オファーが取り下げられました',
},
newsletter: {
drop: 'SkateSpots の新着',
},
summary: {
moreUpdates: '他に {{count}} 件',
openToCatchUp: 'SkateSpots を開いて確認',
},
},
};
export default jaBatch19;

77
src/i18n/ja/batch20.ts Normal file
View file

@ -0,0 +1,77 @@
/**
* Japanese (日本語) — batch 20: legacyMiscUi.
*
* Scattered leftovers routed through the new `legacyMiscUi.*` namespace:
* hosting dashboard, sponsor slots, report inbox, set-status dialog, a few
* SEO/aria labels and one zap error.
*
* Skate vocabulary stays English or katakana (spot→スポット, park→パーク,
* crew→クルー, jam, Stash, The Lab, Nostr, Lightning, Zap, Relay, sats, RSVP,
* Cashu) and brand names (SkateSpots, Cordn, Cashu) are kept. `{{placeholders}}`
* are preserved exactly.
*/
const jaBatch20 = {
legacyMiscUi: {
// Hosting dashboard
hostingGoing: '{{count}} 人参加',
hostingInterested: '{{count}} 人興味あり',
hostingPostUpdate: '更新を投稿',
hostingEdit: '編集',
hostingLoginToSee: '主催するイベントを見るにはログインしてください。',
hostingEmptyTitle: 'まだ何も主催していません',
hostingEmptyBody:
'ジャムやコンペ、セッションを開きましょう。イベントを作成すると、スケーターがRSVPしたり、更新を受け取ったり、遊びに来られます。',
hostingCreateEvent: 'イベントを作成',
hostingUpcoming: '今後の予定 · {{count}}',
hostingPast: '過去 · {{count}}',
// Sponsor slots
sponsorPartners: 'パートナー',
sponsorTitleSponsor: 'タイトルスポンサー',
sponsorPlatformPartners: 'プラットフォームパートナー',
// Reports inbox
reportsUntitled: '(無題)',
reportsResolveFailed:
'通報されたイベントを解決できませんでした(削除されたか、通報が投稿ではなくプロフィールを対象としている可能性があります)。',
reportsLookAtIt: '見る',
reportsNoReports: 'まだ通報はありません。すべて問題ないか、誰もボタンを見つけていないかのどちらかです。',
// Set status dialog
statusSetYourStatus: 'ステータスを設定',
statusSetDesc:
'今何をしているか知らせましょう。ステータスはプロフィールとリンクしたスポットに表示されます。',
statusClear: 'クリア',
statusOrWriteOwn: '自分で入力する',
statusCustomPlaceholder: '今何してる?',
statusLinkToSpot: 'スポットをリンク(任意)',
statusSpotSelected: 'スポットを選択済み',
statusSearchSpots: 'スポットを検索...',
statusNoSpotsFound: 'スポットが見つかりません。',
statusSelectSpot: 'スポットを選択',
statusAutoClearAfter: '自動クリア: {{time}}',
statusExpMin: '{{count}} 分',
statusExpOneHour: '1 時間',
statusExpHours: '{{count}} 時間',
statusExpOneDay: '1 日',
statusExpDays: '{{count}} 日',
statusSlider15: '15 分',
statusSlider1h: '1 時間',
statusSlider4h: '4 時間',
statusSlider24h: '24 時間',
statusSetting: 'ステータスを設定中...',
statusSet: 'ステータスを設定',
statusSomethingWrong: '問題が発生しました',
// SEO + aria labels
seoProfileTitle: 'プロフィール - SkateSpots',
seoViewProfile: 'プロフィールを見る',
bannerAlt: 'バナー',
openInCordn: 'Cordnで開く',
// Zap errors
zapNoActiveMint: '有効なCashuミントが見つかりません。',
},
};
export default jaBatch20;

View file

@ -13,6 +13,11 @@ import batch12 from './batch12';
import batch13 from './batch13';
import batch14 from './batch14';
import batch15 from './batch15';
import batch16 from './batch16';
import batch17 from './batch17';
import batch18 from './batch18';
import batch19 from './batch19';
import batch20 from './batch20';
/**
* Japanese (日本語) translations — composed from fragments so parallel batches
@ -38,6 +43,11 @@ const ja = {
...batch13,
...batch14,
...batch15,
...batch16,
...batch17,
...batch18,
...batch19,
...batch20,
};
export default ja;

View file

@ -0,0 +1,296 @@
/**
* Lab role-form strings (`becomeRolesUi.*`) — the copy that lives in the
* `BECOME_ROLES` data table (`src/lib/becomeRoles.tsx`) and is rendered by
* `CraftProfileForm`, `BecomeRolePage` (SEO meta) and `LabProfilePreview`.
*
* Keys must exist in BOTH `en` and `es` (key-for-key), same as the other
* `*Ui` namespaces. Skate/brand terms (SkateSpots) and trick names stay as
* written; every craft field label/placeholder/helper that used to be raw
* English lives here.
*/
const becomeRolesUi = {
en: {
artist: {
seoTitle: 'Become a Designer / Artist - SkateSpots',
seoDescription: 'List your skate design & art — board graphics, logos, posters, merch and more.',
heading: 'Designer / Artist',
tagline: 'Board graphics, logos, posters, merch — put your art on the scene',
noun: 'artist',
rateLabel: 'Rate',
portfolioLabel: 'Portfolio / Shop link',
sectionWhatYouMake: 'What You Make',
sectionMedium: 'Medium',
sectionSoftwareTools: 'Software & Tools',
deliverablesPlaceholder: 'e.g. print-ready files, source files, deck graphics',
turnaroundPlaceholder: 'e.g. 3–5 days',
},
graffiti: {
seoTitle: 'Offer Graffiti / Murals - SkateSpots',
seoDescription: 'List your graffiti & mural work — pieces, murals, skatepark art and more.',
heading: 'Graffiti / Mural Artist',
tagline: 'Pieces, murals, skatepark art — paint the scene',
noun: 'graffiti artist',
rateLabel: 'Rate',
portfolioLabel: 'Portfolio link (IG, site)',
sectionStyle: 'Style',
sectionMedium: 'Medium',
sectionWorkContext: 'Work Context',
crewLabel: 'Graffiti Crew',
crewPlaceholder: 'Your writing crew (optional)',
crewHelper: 'Separate from your skate crew — the writers you paint with.',
surfacesPlaceholder: 'e.g. concrete, brick, permitted walls',
maxSizePlaceholder: 'e.g. up to 10 m',
},
musician: {
seoTitle: 'Add a Music Profile - SkateSpots',
seoDescription: 'Skate and make music? List your band or solo project — genres, instruments and links.',
heading: 'Musician / Band',
tagline: 'Skate and make music? Get booked for demos, jams & after-parties',
noun: 'musician',
rateLabel: 'Booking Rate',
portfolioLabel: 'Website / EPK link',
sectionGenre: 'Genre',
sectionInstruments: 'Instruments & Role',
actLabel: 'Act Type',
actPlaceholder: 'solo / band / dj / producer',
spotifyLabel: 'Spotify',
spotifyPlaceholder: 'https://open.spotify.com/…',
bandcampLabel: 'Bandcamp',
bandcampPlaceholder: 'https://…bandcamp.com',
soundcloudLabel: 'SoundCloud',
soundcloudPlaceholder: 'https://soundcloud.com/…',
youtubeLabel: 'YouTube',
youtubePlaceholder: 'https://youtube.com/…',
setLengthPlaceholder: 'e.g. 30–45 min',
techRiderPlaceholder: 'e.g. PA, mics, backline',
},
judge: {
seoTitle: 'Judge Skate Events - SkateSpots',
seoDescription: 'List yourself as a skate event judge — Game of S.K.A.T.E., best trick jams, contests, am series and world tour stops. Get booked to run fair, credible events.',
heading: 'Skate Event Judge',
tagline: 'From flat-ground Game of S.K.A.T.E. to the World Destruction Tour — run it fair, run it credible',
noun: 'judge',
rateLabel: 'Day Rate / Fee',
portfolioLabel: 'Results archive / profile link',
sectionEventFormats: 'Event Formats',
sectionSpecialty: 'Specialty',
sectionLevelCircuit: 'Level / Circuit',
eventsJudgedLabel: 'Events Judged',
eventsJudgedPlaceholder: 'e.g. 40+ contests, 3 World Tour stops',
eventsJudgedHelper: 'Notable events, comps or tours you have officiated.',
travelPlaceholder: 'e.g. regional / nationwide / international',
certificationsPlaceholder: 'e.g. national judging course',
},
organizer: {
seoTitle: 'Organize Skate Events - SkateSpots',
seoDescription: 'List yourself as a skate event organizer — street jams, best trick, contests, meetups, demos and tour stops. Get discovered and booked to run the session.',
heading: 'Event Organizer',
tagline: 'Jams, contests, meetups & demos — put on the session and bring the scene together',
noun: 'organizer',
rateLabel: 'Event Fee / Budget',
portfolioLabel: 'Past events / results link',
sectionEventTypes: 'Event Types',
sectionWhatYouHandle: 'What You Handle',
sectionScale: 'Scale',
eventsHostedLabel: 'Events Organized',
eventsHostedPlaceholder: 'e.g. 25 jams, 3 city comps',
eventsHostedHelper: 'Notable events, comps or meetups you have run.',
insurancePlaceholder: 'e.g. public liability included',
permitsPlaceholder: 'e.g. council permits handled',
},
tattoo: {
seoTitle: 'Become a Tattoo Artist - SkateSpots',
seoDescription: 'List your tattoo work for the skate scene — styles, flash, custom and more.',
heading: 'Tattoo Artist',
tagline: 'Ink for the scene — flash, custom, fine line & more',
noun: 'tattoo artist',
rateLabel: 'Rate / Hourly',
portfolioLabel: 'Portfolio link (IG, site)',
sectionStyles: 'Styles',
sectionMethod: 'Method',
studioLabel: 'Studio / Shop',
studioPlaceholder: 'Studio name (optional)',
booksOpenLabel: 'Books Open',
booksOpenHelper: 'Are you currently taking bookings?',
depositPlaceholder: 'e.g. 50 EUR non-refundable',
minimumPlaceholder: 'e.g. 80 EUR',
},
trainer: {
seoTitle: 'Become a Trainer / Physio - SkateSpots',
seoDescription: 'Help skaters get strong and stay healthy — strength, mobility, injury prevention and recovery.',
heading: 'Trainer / Physio',
tagline: 'Strength, mobility, injury prevention & recovery for skaters',
noun: 'trainer',
rateLabel: 'Rate / Session',
portfolioLabel: 'Profile / booking link',
sectionFocus: 'Focus',
sectionInjuries: 'Injuries You Work With',
sectionCredentialsSetting: 'Credentials & Setting',
credentialsPlaceholder: 'e.g. BSc Physiotherapy, CSCS…',
credentialsHelper: 'List real qualifications — skaters trust verifiable credentials.',
remoteLabel: 'Remote / Online',
remoteHelper: 'Do you offer online coaching or consultations?',
modalitiesPlaceholder: 'e.g. hands-on, S&C, dry needling',
},
barber: {
seoTitle: 'Become a Barber / Hairstylist - SkateSpots',
seoDescription: 'List your barbershop or chair for the skate scene — cuts, fades, beard trims, colour and more. Get booked by skaters.',
heading: 'Barber / Hairstylist',
tagline: 'Cuts, fades & beard trims for the scene — shop chair or mobile',
noun: 'barber',
rateLabel: 'Price / Cut',
portfolioLabel: 'Cuts gallery / IG link',
sectionWhatYouOffer: 'What You Offer',
sectionSignatureStyles: 'Signature Styles',
sectionSetting: 'Setting',
studioLabel: 'Shop / Studio',
studioPlaceholder: 'Shop or studio name (optional)',
booksOpenLabel: 'Books Open',
booksOpenHelper: 'Are you currently taking bookings?',
durationPlaceholder: 'e.g. 30–45 min',
},
},
es: {
artist: {
seoTitle: 'Conviértete en diseñador / artista - SkateSpots',
seoDescription: 'Publica tu diseño y arte de skate: gráficos de tabla, logos, pósters, merch y más.',
heading: 'Diseñador / Artista',
tagline: 'Gráficos de tabla, logos, pósters, merch — pon tu arte en la escena',
noun: 'artista',
rateLabel: 'Tarifa',
portfolioLabel: 'Portafolio / enlace de tienda',
sectionWhatYouMake: 'Qué haces',
sectionMedium: 'Técnica',
sectionSoftwareTools: 'Software y herramientas',
deliverablesPlaceholder: 'p. ej. archivos listos para imprimir, archivos fuente, gráficos de tabla',
turnaroundPlaceholder: 'p. ej. 3–5 días',
},
graffiti: {
seoTitle: 'Ofrece graffiti / murales - SkateSpots',
seoDescription: 'Publica tu trabajo de graffiti y murales: piezas, murales, arte para skateparks y más.',
heading: 'Artista de graffiti / murales',
tagline: 'Piezas, murales, arte para skateparks — pinta la escena',
noun: 'artista de graffiti',
rateLabel: 'Tarifa',
portfolioLabel: 'Enlace de portafolio (IG, web)',
sectionStyle: 'Estilo',
sectionMedium: 'Técnica',
sectionWorkContext: 'Contexto del trabajo',
crewLabel: 'Peña de graffiti',
crewPlaceholder: 'Tu peña de escritura (opcional)',
crewHelper: 'Aparte de tu peña de skate: los escritores con los que pintas.',
surfacesPlaceholder: 'p. ej. hormigón, ladrillo, muros permitidos',
maxSizePlaceholder: 'p. ej. hasta 10 m',
},
musician: {
seoTitle: 'Añade un perfil musical - SkateSpots',
seoDescription: '¿Patinas y haces música? Publica tu banda o proyecto en solitario: géneros, instrumentos y enlaces.',
heading: 'Músico / Banda',
tagline: '¿Patinas y haces música? Consigue bolos para demos, jams y afters',
noun: 'músico',
rateLabel: 'Tarifa de bolo',
portfolioLabel: 'Web / enlace EPK',
sectionGenre: 'Género',
sectionInstruments: 'Instrumentos y rol',
actLabel: 'Tipo de actuación',
actPlaceholder: 'solo / banda / dj / productor',
spotifyLabel: 'Spotify',
spotifyPlaceholder: 'https://open.spotify.com/…',
bandcampLabel: 'Bandcamp',
bandcampPlaceholder: 'https://…bandcamp.com',
soundcloudLabel: 'SoundCloud',
soundcloudPlaceholder: 'https://soundcloud.com/…',
youtubeLabel: 'YouTube',
youtubePlaceholder: 'https://youtube.com/…',
setLengthPlaceholder: 'p. ej. 30–45 min',
techRiderPlaceholder: 'p. ej. PA, micros, backline',
},
judge: {
seoTitle: 'Juzga eventos de skate - SkateSpots',
seoDescription: 'Publícate como juez de eventos de skate: Game of S.K.A.T.E., best trick, concursos, series am y paradas de world tour. Consigue que te contraten para eventos justos y creíbles.',
heading: 'Juez de eventos de skate',
tagline: 'Del Game of S.K.A.T.E. en llano al World Destruction Tour — júzgalo con justicia y criterio',
noun: 'juez',
rateLabel: 'Tarifa por día / honorarios',
portfolioLabel: 'Archivo de resultados / enlace de perfil',
sectionEventFormats: 'Formatos de evento',
sectionSpecialty: 'Especialidad',
sectionLevelCircuit: 'Nivel / circuito',
eventsJudgedLabel: 'Eventos juzgados',
eventsJudgedPlaceholder: 'p. ej. más de 40 concursos, 3 paradas de World Tour',
eventsJudgedHelper: 'Eventos, concursos o giras destacadas que hayas arbitrado.',
travelPlaceholder: 'p. ej. regional / nacional / internacional',
certificationsPlaceholder: 'p. ej. curso nacional de jueces',
},
organizer: {
seoTitle: 'Organiza eventos de skate - SkateSpots',
seoDescription: 'Publícate como organizador de eventos de skate: jams de calle, best trick, concursos, quedadas, demos y paradas de gira. Deja que te descubran y te contraten para dirigir la sesión.',
heading: 'Organizador de eventos',
tagline: 'Jams, concursos, quedadas y demos — monta la sesión y une a la escena',
noun: 'organizador',
rateLabel: 'Tarifa de evento / presupuesto',
portfolioLabel: 'Enlace a eventos pasados / resultados',
sectionEventTypes: 'Tipos de evento',
sectionWhatYouHandle: 'De qué te encargas',
sectionScale: 'Escala',
eventsHostedLabel: 'Eventos organizados',
eventsHostedPlaceholder: 'p. ej. 25 jams, 3 concursos municipales',
eventsHostedHelper: 'Eventos, concursos o quedadas destacadas que hayas organizado.',
insurancePlaceholder: 'p. ej. responsabilidad civil incluida',
permitsPlaceholder: 'p. ej. permisos municipales gestionados',
},
tattoo: {
seoTitle: 'Conviértete en tatuador - SkateSpots',
seoDescription: 'Publica tu trabajo de tatuaje para la escena del skate: estilos, flash, custom y más.',
heading: 'Tatuador / Tatuadora',
tagline: 'Tinta para la escena — flash, custom, línea fina y más',
noun: 'tatuador',
rateLabel: 'Tarifa / por hora',
portfolioLabel: 'Enlace de portafolio (IG, web)',
sectionStyles: 'Estilos',
sectionMethod: 'Método',
studioLabel: 'Estudio / local',
studioPlaceholder: 'Nombre del estudio (opcional)',
booksOpenLabel: 'Agenda abierta',
booksOpenHelper: '¿Estás aceptando reservas ahora mismo?',
depositPlaceholder: 'p. ej. 50 EUR no reembolsables',
minimumPlaceholder: 'p. ej. 80 EUR',
},
trainer: {
seoTitle: 'Conviértete en entrenador / fisio - SkateSpots',
seoDescription: 'Ayuda a los skaters a estar fuertes y saludables: fuerza, movilidad, prevención de lesiones y recuperación.',
heading: 'Entrenador / Fisio',
tagline: 'Fuerza, movilidad, prevención de lesiones y recuperación para skaters',
noun: 'entrenador',
rateLabel: 'Tarifa / por sesión',
portfolioLabel: 'Perfil / enlace de reserva',
sectionFocus: 'Enfoque',
sectionInjuries: 'Lesiones con las que trabajas',
sectionCredentialsSetting: 'Credenciales y entorno',
credentialsPlaceholder: 'p. ej. Grado en Fisioterapia, CSCS…',
credentialsHelper: 'Indica titulaciones reales: los skaters confían en credenciales verificables.',
remoteLabel: 'Remoto / online',
remoteHelper: '¿Ofreces entrenamiento o consultas online?',
modalitiesPlaceholder: 'p. ej. manual, fuerza y acondicionamiento, punción seca',
},
barber: {
seoTitle: 'Conviértete en barbero / peluquero - SkateSpots',
seoDescription: 'Publica tu barbería o tu silla para la escena del skate: cortes, fades, arreglo de barba, color y más. Consigue clientes skaters.',
heading: 'Barbero / Peluquero',
tagline: 'Cortes, fades y arreglo de barba para la escena — en local o a domicilio',
noun: 'barbero',
rateLabel: 'Precio / corte',
portfolioLabel: 'Galería de cortes / enlace IG',
sectionWhatYouOffer: 'Qué ofreces',
sectionSignatureStyles: 'Estilos característicos',
sectionSetting: 'Entorno',
studioLabel: 'Local / estudio',
studioPlaceholder: 'Nombre del local o estudio (opcional)',
booksOpenLabel: 'Agenda abierta',
booksOpenHelper: '¿Estás aceptando reservas ahora mismo?',
durationPlaceholder: 'p. ej. 30–45 min',
},
},
};
export default becomeRolesUi;

392
src/i18n/legacy/deckCost.ts Normal file
View file

@ -0,0 +1,392 @@
/**
* Deck-cost calculator UI (`deckCostUi.*`).
*
* On-screen strings for `components/DeckCostCalculator.tsx`: labels, column
* headers, buttons, placeholders, toasts, warnings and the quick-start
* templates. Layout/currency words that are format examples (`you@example.com`,
* `Street, city`, `Search currency…`, `npub1…`) and machine-stable CSV keys stay
* untranslated in the component. Skate/gear words follow each locale's policy
* (`es` uses «tabla» for deck; `th`/`ja` keep brand names English).
*/
const deckCostUi = {
en: {
// Toast / prompt / confirm
presetPrompt: 'Name this preset',
presetDefaultName: 'Preset {{n}}',
summaryCopied: 'Summary copied',
couldNotCopy: 'Could not copy',
resetConfirm: 'Reset everything to the example values?',
logoTooLarge: 'Logo too large (max 500 KB)',
loginToFill: 'Log in to fill from your Nostr profile',
filledFromProfile: 'Filled from your Nostr profile',
// Sanity warnings
warnRetailBelowOne: 'Retail markup is below 1× — you would sell at a loss.',
warnWholesaleAboveRetail: 'Wholesale markup should be lower than the retail markup.',
warnZeroBoards: '{{count}} row(s) use 0 boards — that row’s cost is ignored.',
warnFixedNoBoards: 'Fixed monthly costs are set but boards/month is 0, so operations cost is ignored.',
warnNoMaterials: 'No materials cost — add at least one material.',
warnEquipmentOverhead: 'Equipment + overhead are over 60% of the cost — this model assumes high volume; check your board counts.',
warnOneBoard: 'Some equipment is spread over a single board — raise its board count to amortise it.',
// Quick-start templates
templateStarterName: 'Hobby starter',
templateStarterBlurb: 'A few rows, no fixed costs',
templateShopName: 'Small shop',
templateShopBlurb: 'Full sheet with rent & overhead',
// Currency bar
currency: 'Currency',
noCurrencyMatches: 'No currency matches.',
ratesUnavailable: 'Live rates unavailable — switching relabels only.',
liveRates: 'live BTC rates · sats included',
chipCost: 'cost',
chipSell: 'sell',
chipTotal: 'total',
// Toolbar
startFrom: 'Start from',
advancedToggleHint: 'Reveal per-operation rates and scrap, batch setup, tax and the cost-vs-quantity curve',
advanced: 'Advanced',
simple: 'Simple',
// Sections
sectionLabor: 'Labor',
sectionEquipment: 'Equipment',
sectionOperations: 'Operation expenses',
sectionMaterials: 'Materials',
sectionProcesses: 'Process costs',
sectionPackaging: 'Packaging',
sectionMargins: 'Margins',
subtotalLoaded: '{{value}}/hr loaded',
subtotalPerBoard: '{{value}}/board',
// Labor
hourlyRate: 'Hourly rate ({{unit}})',
labourBurden: 'Labour burden (×)',
loadedRate: 'Loaded rate',
loadedRateHintBefore: 'Loaded rate = wage × burden (employer taxes and insurance on labour). Rent and bills go under ',
loadedRateHintAfter: ' — don’t count them in both.',
// Table columns
colEquipment: 'Equipment',
colCostUnit: 'Cost ({{unit}})',
colCost: 'Cost',
colSetupH: 'Setup (h)',
colLifetimeBoards: 'Lifetime boards',
colPerBoard: 'Per board',
colMaterial: 'Material',
colOrderUnit: 'Order ({{unit}})',
colShipUnit: 'Ship ({{unit}})',
colBoardsOrder: 'Boards / order',
colOrderM: 'Order (m)',
colUnloadM: 'Unload (m)',
colOrder: 'Order',
colShipping: 'Shipping',
colOrderMin: 'Order (min)',
colUnloadMin: 'Unload (min)',
colProcess: 'Process',
colTimeM: 'Time (m)',
colBoardsRun: 'Boards / run',
colRateHr: 'Rate/hr (0 = shop)',
colRateHrShort: 'Rate/hr',
colScrapPct: 'Scrap %',
colTimeMin: 'Time (min)',
colPackaging: 'Packaging',
colTimeBoardM: 'Time/board (m)',
colTimeBoardMin: 'Time/board (min)',
colMaterialsBoardUnit: 'Materials/board ({{unit}})',
colMaterialsBoard: 'Materials/board',
// Section footers
equipmentPerBoard: 'Equipment per board:',
materialsPerBoard: 'Materials per board:',
processesPerBoard: 'Processes per board:',
packagingPerBoard: 'Packaging per board:',
perBoard: 'Per board:',
// Row actions
addEquipment: 'Add equipment',
addMaterial: 'Add material',
addProcess: 'Add process',
addPackagingStep: 'Add packaging step',
duplicate: 'Duplicate',
remove: 'Remove',
// Operations
boardsPerMonth: 'Boards / month',
rentPerMonth: 'Rent / month',
utilitiesPerMonth: 'Utilities / month',
subscriptionsPerMonth: 'Subscriptions / month',
// Margins & results
markup: 'Markup (×)',
wholesaleMarkup: 'Wholesale markup (×)',
scrapWaste: 'Scrap / waste (%)',
quantityDecks: 'Quantity (decks)',
targetMonthlyProfit: 'Target monthly profit ({{unit}})',
costNoPackaging: 'Cost / board (no packaging)',
costWithPackaging: 'Cost / board (with packaging)',
batchSetupHours: 'Batch setup (h / run)',
batchSetupCost: 'Batch setup cost ({{unit}} / run)',
taxVat: 'Tax / VAT (%)',
quoteTax: 'Quote tax',
includedInPrice: 'Included in price',
addedOnTop: 'Added on top',
setupPerBoard: 'Setup / board',
marginNoteBefore: 'Markup {{markup}}× ≈ ',
grossMargin: '{{pct}}% gross margin',
marginNoteAfter: ' · waste {{waste}}% is spread over the good boards.',
sellingPriceNoPackaging: 'Selling price · no packaging',
sellingPriceWithPackaging: 'Selling price · with packaging',
profitPerDeck: 'Profit / deck',
wholesalePerDeck: 'Wholesale / deck',
orderTotalQty: 'Order total ({{qty}})',
orderTotal: 'Order total',
breakeven: 'Breakeven',
breakevenPerMonth: '{{count}}/mo',
targetDecksHint: 'To clear {{amount}}/month on top of fixed costs, sell {{decks}} decks/month.',
priceIncludesTax: 'Price includes {{pct}}% tax: {{amount}} of the total.',
plusTax: 'Plus {{pct}}% tax: {{amount}} → {{total}} total.',
costVsQuantity: 'Cost vs quantity (setup amortised)',
deckOne: 'deck',
deckOther: 'decks',
perDeckSuffix: '/ deck',
orderSuffix: 'order {{amount}}',
shippingNotIncluded: 'Does not include shipping to the buyer.',
// Quote details
clientQuoteDetails: 'Client quote details (for the PDF)',
fillFromProfileTitle: 'Fill name, website, NIP-05, Lightning and npub from your Nostr profile',
fillFromProfile: 'Fill from profile',
businessName: 'Business / shop name',
email: 'Email',
phone: 'Phone',
website: 'Website',
address: 'Address',
quoteNumber: 'Quote #',
nextNumber: 'Next number',
validDays: 'Valid (days)',
nip05: 'NIP-05',
lightningAddress: 'Lightning address',
nostrNpub: 'Nostr (npub)',
depositPct: 'Deposit due up front (%)',
deliveryTerms: 'Delivery terms (Incoterm, optional)',
paymentMethods: 'Payment methods',
paymentTerms: 'Payment terms',
paymentDetails: 'Payment details',
notesTerms: 'Notes / terms',
changeLogo: 'Change logo',
addLogo: 'Add logo',
// Incoterms
incotermNone: 'None',
incotermExw: 'EXW — collect from the seller',
incotermFca: 'FCA — handed to the buyer’s carrier',
incotermDap: 'DAP — delivered to the buyer’s address',
incotermDdp: 'DDP — delivered, duties paid',
// Payment methods
payMethodCash: 'Cash',
payMethodLightning: 'Lightning',
payMethodCashu: 'Cashu',
payMethodBank: 'Bank transfer',
payMethodCard: 'Card',
payMethodOther: 'Other',
// Actions
listDeckAtPrice: 'List a deck at this price',
getItMade: 'Get it made — find a builder',
savePreset: 'Save preset',
load: 'Load',
export: 'Export',
import: 'Import',
reset: 'Reset',
deletePreset: 'Delete preset',
// Export menu
exportPdfFull: 'PDF — full sheet',
exportPdfQuote: 'PDF — client quote',
exportPng: 'PNG image',
exportXlsx: 'XLSX workbook',
exportCsv: 'CSV (spreadsheet)',
exportMarkdown: 'Markdown',
exportJson: 'JSON (backup)',
exportCopySummary: 'Copy summary',
},
es: {
// Toast / prompt / confirm
presetPrompt: 'Nombra este preajuste',
presetDefaultName: 'Preajuste {{n}}',
summaryCopied: 'Resumen copiado',
couldNotCopy: 'No se pudo copiar',
resetConfirm: '¿Restablecer todo a los valores de ejemplo?',
logoTooLarge: 'Logotipo demasiado grande (máx. 500 KB)',
loginToFill: 'Inicia sesión para rellenar desde tu perfil de Nostr',
filledFromProfile: 'Rellenado desde tu perfil de Nostr',
// Sanity warnings
warnRetailBelowOne: 'El recargo minorista es inferior a 1× — venderías con pérdidas.',
warnWholesaleAboveRetail: 'El recargo mayorista debería ser inferior al minorista.',
warnZeroBoards: '{{count}} fila(s) usan 0 tablas — el coste de esa fila se ignora.',
warnFixedNoBoards: 'Hay costes fijos mensuales definidos pero tablas/mes es 0, así que el coste de operaciones se ignora.',
warnNoMaterials: 'Sin coste de materiales — añade al menos un material.',
warnEquipmentOverhead: 'Equipo + gastos generales superan el 60 % del coste — este modelo asume un volumen alto; revisa el número de tablas.',
warnOneBoard: 'Parte del equipo se reparte sobre una sola tabla — aumenta su número de tablas para amortizarlo.',
// Quick-start templates
templateStarterName: 'Inicio de aficionado',
templateStarterBlurb: 'Unas pocas filas, sin costes fijos',
templateShopName: 'Taller pequeño',
templateShopBlurb: 'Hoja completa con alquiler y gastos generales',
// Currency bar
currency: 'Moneda',
noCurrencyMatches: 'Ninguna moneda coincide.',
ratesUnavailable: 'Tipos de cambio no disponibles — cambiar solo reetiqueta.',
liveRates: 'tipos de BTC en vivo · sats incluidos',
chipCost: 'coste',
chipSell: 'venta',
chipTotal: 'total',
// Toolbar
startFrom: 'Empezar desde',
advancedToggleHint: 'Muestra las tarifas y mermas por operación, la configuración del lote, los impuestos y la curva coste-cantidad',
advanced: 'Avanzado',
simple: 'Simple',
// Sections
sectionLabor: 'Mano de obra',
sectionEquipment: 'Equipo',
sectionOperations: 'Gastos de operación',
sectionMaterials: 'Materiales',
sectionProcesses: 'Costes de proceso',
sectionPackaging: 'Embalaje',
sectionMargins: 'Márgenes',
subtotalLoaded: '{{value}}/h cargado',
subtotalPerBoard: '{{value}}/tabla',
// Labor
hourlyRate: 'Tarifa por hora ({{unit}})',
labourBurden: 'Carga laboral (×)',
loadedRate: 'Tarifa cargada',
loadedRateHintBefore: 'Tarifa cargada = salario × carga (impuestos laborales y seguros del empresario). El alquiler y las facturas van en ',
loadedRateHintAfter: ' — no los cuentes en ambos.',
// Table columns
colEquipment: 'Equipo',
colCostUnit: 'Coste ({{unit}})',
colCost: 'Coste',
colSetupH: 'Preparación (h)',
colLifetimeBoards: 'Tablas de vida útil',
colPerBoard: 'Por tabla',
colMaterial: 'Material',
colOrderUnit: 'Pedido ({{unit}})',
colShipUnit: 'Envío ({{unit}})',
colBoardsOrder: 'Tablas / pedido',
colOrderM: 'Pedido (m)',
colUnloadM: 'Descarga (m)',
colOrder: 'Pedido',
colShipping: 'Envío',
colOrderMin: 'Pedido (min)',
colUnloadMin: 'Descarga (min)',
colProcess: 'Proceso',
colTimeM: 'Tiempo (m)',
colBoardsRun: 'Tablas / tanda',
colRateHr: 'Tarifa/h (0 = taller)',
colRateHrShort: 'Tarifa/h',
colScrapPct: 'Merma %',
colTimeMin: 'Tiempo (min)',
colPackaging: 'Embalaje',
colTimeBoardM: 'Tiempo/tabla (m)',
colTimeBoardMin: 'Tiempo/tabla (min)',
colMaterialsBoardUnit: 'Materiales/tabla ({{unit}})',
colMaterialsBoard: 'Materiales/tabla',
// Section footers
equipmentPerBoard: 'Equipo por tabla:',
materialsPerBoard: 'Materiales por tabla:',
processesPerBoard: 'Procesos por tabla:',
packagingPerBoard: 'Embalaje por tabla:',
perBoard: 'Por tabla:',
// Row actions
addEquipment: 'Añadir equipo',
addMaterial: 'Añadir material',
addProcess: 'Añadir proceso',
addPackagingStep: 'Añadir paso de embalaje',
duplicate: 'Duplicar',
remove: 'Eliminar',
// Operations
boardsPerMonth: 'Tablas / mes',
rentPerMonth: 'Alquiler / mes',
utilitiesPerMonth: 'Suministros / mes',
subscriptionsPerMonth: 'Suscripciones / mes',
// Margins & results
markup: 'Recargo (×)',
wholesaleMarkup: 'Recargo mayorista (×)',
scrapWaste: 'Merma / desperdicio (%)',
quantityDecks: 'Cantidad (tablas)',
targetMonthlyProfit: 'Beneficio mensual objetivo ({{unit}})',
costNoPackaging: 'Coste / tabla (sin embalaje)',
costWithPackaging: 'Coste / tabla (con embalaje)',
batchSetupHours: 'Preparación de lote (h / tanda)',
batchSetupCost: 'Coste de preparación de lote ({{unit}} / tanda)',
taxVat: 'Impuesto / IVA (%)',
quoteTax: 'Impuesto del presupuesto',
includedInPrice: 'Incluido en el precio',
addedOnTop: 'Añadido aparte',
setupPerBoard: 'Preparación / tabla',
marginNoteBefore: 'Recargo {{markup}}× ≈ ',
grossMargin: '{{pct}}% de margen bruto',
marginNoteAfter: ' · la merma del {{waste}}% se reparte entre las tablas buenas.',
sellingPriceNoPackaging: 'Precio de venta · sin embalaje',
sellingPriceWithPackaging: 'Precio de venta · con embalaje',
profitPerDeck: 'Beneficio / tabla',
wholesalePerDeck: 'Mayorista / tabla',
orderTotalQty: 'Total del pedido ({{qty}})',
orderTotal: 'Total del pedido',
breakeven: 'Punto de equilibrio',
breakevenPerMonth: '{{count}}/mes',
targetDecksHint: 'Para cubrir {{amount}}/mes además de los costes fijos, vende {{decks}} tablas/mes.',
priceIncludesTax: 'El precio incluye un {{pct}}% de impuesto: {{amount}} del total.',
plusTax: 'Más un {{pct}}% de impuesto: {{amount}} → {{total}} en total.',
costVsQuantity: 'Coste frente a cantidad (preparación amortizada)',
deckOne: 'tabla',
deckOther: 'tablas',
perDeckSuffix: '/ tabla',
orderSuffix: 'pedido {{amount}}',
shippingNotIncluded: 'No incluye el envío al comprador.',
// Quote details
clientQuoteDetails: 'Datos del presupuesto (para el PDF)',
fillFromProfileTitle: 'Rellena nombre, web, NIP-05, Lightning y npub desde tu perfil de Nostr',
fillFromProfile: 'Rellenar desde el perfil',
businessName: 'Nombre del negocio / tienda',
email: 'Correo electrónico',
phone: 'Teléfono',
website: 'Sitio web',
address: 'Dirección',
quoteNumber: 'N.º de presupuesto',
nextNumber: 'Siguiente número',
validDays: 'Validez (días)',
nip05: 'NIP-05',
lightningAddress: 'Dirección Lightning',
nostrNpub: 'Nostr (npub)',
depositPct: 'Señal por adelantado (%)',
deliveryTerms: 'Condiciones de entrega (Incoterm, opcional)',
paymentMethods: 'Métodos de pago',
paymentTerms: 'Condiciones de pago',
paymentDetails: 'Datos de pago',
notesTerms: 'Notas / condiciones',
changeLogo: 'Cambiar logotipo',
addLogo: 'Añadir logotipo',
// Incoterms
incotermNone: 'Ninguno',
incotermExw: 'EXW — recogida por el comprador',
incotermFca: 'FCA — entregado al transportista del comprador',
incotermDap: 'DAP — entregado en la dirección del comprador',
incotermDdp: 'DDP — entregado con derechos pagados',
// Payment methods
payMethodCash: 'Efectivo',
payMethodLightning: 'Lightning',
payMethodCashu: 'Cashu',
payMethodBank: 'Transferencia bancaria',
payMethodCard: 'Tarjeta',
payMethodOther: 'Otro',
// Actions
listDeckAtPrice: 'Publicar una tabla a este precio',
getItMade: 'Que te la hagan — encuentra un constructor',
savePreset: 'Guardar preajuste',
load: 'Cargar',
export: 'Exportar',
import: 'Importar',
reset: 'Restablecer',
deletePreset: 'Eliminar preajuste',
// Export menu
exportPdfFull: 'PDF — hoja completa',
exportPdfQuote: 'PDF — presupuesto del cliente',
exportPng: 'Imagen PNG',
exportXlsx: 'Libro XLSX',
exportCsv: 'CSV (hoja de cálculo)',
exportMarkdown: 'Markdown',
exportJson: 'JSON (copia de seguridad)',
exportCopySummary: 'Copiar resumen',
},
};
export default deckCostUi;

29
src/i18n/legacy/index.ts Normal file
View file

@ -0,0 +1,29 @@
import deckCost from './deckCost';
import becomeRoles from './becomeRoles';
import weather from './weather';
import skaterLabels from './skaterLabels';
import notify from './notify';
import misc from './misc';
/**
* Namespaces added by the hardcoded-string sweep (2026-10-07), spread into
* `en` / `es`. Each file holds one namespace as `{ en, es }` so parallel passes
* never touch a shared file.
*/
export const legacyEn = {
deckCostUi: deckCost.en,
becomeRolesUi: becomeRoles.en,
weatherUi: weather.en,
skaterLabelsUi: skaterLabels.en,
notifyUi: notify.en,
legacyMiscUi: misc.en,
};
export const legacyEs = {
deckCostUi: deckCost.es,
becomeRolesUi: becomeRoles.es,
weatherUi: weather.es,
skaterLabelsUi: skaterLabels.es,
notifyUi: notify.es,
legacyMiscUi: misc.es,
};

134
src/i18n/legacy/misc.ts Normal file
View file

@ -0,0 +1,134 @@
/**
* Legacy misc UI namespace (`legacyMiscUi.*`) — scattered leftovers that don't
* belong to a themed batch: the hosting dashboard, sponsor slots, the report
* inbox, the status dialog, a few SEO/aria labels and one zap error.
*
* Wired into `en`/`es` by spreading `legacyMiscUi.en` / `legacyMiscUi.es`, and
* into `th`/`ja` via their own batch fragments.
*/
const legacyMiscUi = {
en: {
// Hosting dashboard
hostingGoing: '{{count}} going',
hostingInterested: '{{count}} interested',
hostingPostUpdate: 'Post update',
hostingEdit: 'Edit',
hostingLoginToSee: "Log in to see the events you're hosting.",
hostingEmptyTitle: "You're not hosting anything yet",
hostingEmptyBody:
'Run a jam, comp or session. Create an event and skaters can RSVP, get updates, and roll through.',
hostingCreateEvent: 'Create an event',
hostingUpcoming: 'Upcoming · {{count}}',
hostingPast: 'Past · {{count}}',
// Sponsor slots
sponsorPartners: 'Partners',
sponsorTitleSponsor: 'Title Sponsor',
sponsorPlatformPartners: 'Platform Partners',
// Reports inbox
reportsUntitled: '(untitled)',
reportsResolveFailed:
"Couldn't resolve the reported event (it may have been deleted, or the report targets a profile rather than a post).",
reportsLookAtIt: 'Look at it',
reportsNoReports: "No reports yet. Either everything is fine, or nobody's found the button.",
// Set status dialog
statusSetYourStatus: 'Set Your Status',
statusSetDesc:
"Let others know what you're up to. Your status will be visible on your profile and at linked spots.",
statusClear: 'Clear',
statusOrWriteOwn: 'Or write your own',
statusCustomPlaceholder: 'What are you up to?',
statusLinkToSpot: 'Link to a Spot (optional)',
statusSpotSelected: 'Spot selected',
statusSearchSpots: 'Search spots...',
statusNoSpotsFound: 'No spots found.',
statusSelectSpot: 'Select a spot',
statusAutoClearAfter: 'Auto-clear after: {{time}}',
statusExpMin: '{{count}} min',
statusExpOneHour: '1 hour',
statusExpHours: '{{count}} hours',
statusExpOneDay: '1 day',
statusExpDays: '{{count}} days',
statusSlider15: '15 min',
statusSlider1h: '1 hr',
statusSlider4h: '4 hrs',
statusSlider24h: '24 hrs',
statusSetting: 'Setting Status...',
statusSet: 'Set Status',
statusSomethingWrong: 'Something went wrong',
// SEO + aria labels
seoProfileTitle: 'Profile - SkateSpots',
seoViewProfile: 'View profile.',
bannerAlt: 'Banner',
openInCordn: 'Open in Cordn',
// Zap errors
zapNoActiveMint: 'No active Cashu mint found.',
},
es: {
// Hosting dashboard
hostingGoing: '{{count}} asistirán',
hostingInterested: '{{count}} interesados',
hostingPostUpdate: 'Publicar novedad',
hostingEdit: 'Editar',
hostingLoginToSee: 'Inicia sesión para ver los eventos que organizas.',
hostingEmptyTitle: 'Aún no organizas nada',
hostingEmptyBody:
'Organiza una jam, competición o sesión. Crea un evento y los skaters podrán confirmar asistencia, recibir novedades y pasarse.',
hostingCreateEvent: 'Crear un evento',
hostingUpcoming: 'Próximos · {{count}}',
hostingPast: 'Pasados · {{count}}',
// Sponsor slots
sponsorPartners: 'Colaboradores',
sponsorTitleSponsor: 'Patrocinador principal',
sponsorPlatformPartners: 'Colaboradores de la plataforma',
// Reports inbox
reportsUntitled: '(sin título)',
reportsResolveFailed:
'No se pudo resolver el evento reportado (puede que se haya eliminado, o que el reporte apunte a un perfil en vez de a una publicación).',
reportsLookAtIt: 'Verlo',
reportsNoReports: 'Aún no hay reportes. O todo va bien, o nadie ha encontrado el botón.',
// Set status dialog
statusSetYourStatus: 'Define tu estado',
statusSetDesc:
'Deja que los demás sepan qué estás haciendo. Tu estado se verá en tu perfil y en los spots vinculados.',
statusClear: 'Quitar',
statusOrWriteOwn: 'O escribe el tuyo',
statusCustomPlaceholder: '¿Qué estás haciendo?',
statusLinkToSpot: 'Vincular a un spot (opcional)',
statusSpotSelected: 'Spot seleccionado',
statusSearchSpots: 'Buscar spots...',
statusNoSpotsFound: 'No se encontraron spots.',
statusSelectSpot: 'Seleccionar un spot',
statusAutoClearAfter: 'Borrar automáticamente en: {{time}}',
statusExpMin: '{{count}} min',
statusExpOneHour: '1 hora',
statusExpHours: '{{count}} horas',
statusExpOneDay: '1 día',
statusExpDays: '{{count}} días',
statusSlider15: '15 min',
statusSlider1h: '1 h',
statusSlider4h: '4 h',
statusSlider24h: '24 h',
statusSetting: 'Estableciendo estado...',
statusSet: 'Establecer estado',
statusSomethingWrong: 'Algo salió mal',
// SEO + aria labels
seoProfileTitle: 'Perfil - SkateSpots',
seoViewProfile: 'Ver perfil.',
bannerAlt: 'Portada',
openInCordn: 'Abrir en Cordn',
// Zap errors
zapNoActiveMint: 'No se encontró ninguna casa de moneda Cashu activa.',
},
};
export default legacyMiscUi;

101
src/i18n/legacy/notify.ts Normal file
View file

@ -0,0 +1,101 @@
/**
* `notifyUi.*` — OS notification titles and bodies.
*
* Legacy hardcoded English lived in `src/components/OsNotificationBridge.tsx`
* (the OS-shade mirror of the in-app bell: social, crews, DMs, trades,
* newsletter, plus the burst summary). `src/lib/osNotify.ts` only holds
* `console.warn` diagnostics, no user prose, so nothing there needs a key.
*
* `{{placeholders}}` must stay byte-identical across locales.
*
* `en`/`es` live here (the `th`/`ja` fragments are `th/batch18.ts` and
* `ja/batch19.ts`).
*/
const notifyUi = {
en: {
activity: {
zappedWithAmount: 'You got zapped {{sats}} sats',
zapped: 'You got zapped',
liked: 'Someone liked your post',
reposted: 'Someone reposted you',
mentioned: 'Someone mentioned you',
replied: 'Someone replied to you',
rsvp: "Someone RSVP'd to your event",
eventUpdated: "An event you're going to was updated",
newActivity: 'New activity',
tapToOpen: 'Tap to open SkateSpots',
},
crew: {
inviteTitle: 'Crew invite',
inviteBody: "You're invited to {{crew}}",
addedTitle: "You've been added to a crew",
addedBody: '{{crew}} — confirm to join',
joinRequestTitle: 'Join request',
joinRequestBody: 'Someone wants to join {{name}}',
},
dm: {
title: 'New direct message',
body: 'Tap to read it',
},
trade: {
confirm: 'Confirm a trade',
confirmed: 'Trade confirmed',
newOffer: 'New offer on your listing',
counter: 'Counter-offer',
accepted: 'Offer accepted 🎉',
declined: 'Offer declined',
withdrawn: 'Offer withdrawn',
},
newsletter: {
drop: 'New SkateSpots drop',
},
summary: {
moreUpdates: '{{count}} more updates',
openToCatchUp: 'Open SkateSpots to catch up',
},
},
es: {
activity: {
zappedWithAmount: 'Te dieron un zap de {{sats}} sats',
zapped: 'Te dieron un zap',
liked: 'A alguien le gustó tu publicación',
reposted: 'Alguien te reposteó',
mentioned: 'Alguien te mencionó',
replied: 'Alguien te respondió',
rsvp: 'Alguien confirmó asistencia a tu evento',
eventUpdated: 'Se actualizó un evento al que vas',
newActivity: 'Nueva actividad',
tapToOpen: 'Toca para abrir SkateSpots',
},
crew: {
inviteTitle: 'Invitación a una peña',
inviteBody: 'Te invitaron a {{crew}}',
addedTitle: 'Te añadieron a una peña',
addedBody: '{{crew}} — confirma para unirte',
joinRequestTitle: 'Solicitud de ingreso',
joinRequestBody: 'Alguien quiere unirse a {{name}}',
},
dm: {
title: 'Nuevo mensaje directo',
body: 'Toca para leerlo',
},
trade: {
confirm: 'Confirma un intercambio',
confirmed: 'Intercambio confirmado',
newOffer: 'Nueva oferta en tu anuncio',
counter: 'Contraoferta',
accepted: 'Oferta aceptada 🎉',
declined: 'Oferta rechazada',
withdrawn: 'Oferta retirada',
},
newsletter: {
drop: 'Nueva entrega de SkateSpots',
},
summary: {
moreUpdates: '{{count}} novedades más',
openToCatchUp: 'Abre SkateSpots para ponerte al día',
},
},
};
export default notifyUi;

View file

@ -0,0 +1,80 @@
/**
* `skaterLabelsUi.*` — skater label maps.
*
* Legacy hardcoded English lived on the `{ label }` maps in `src/lib/types.ts`
* (`SKATER_STANCES` / `SKATER_STYLES` / `SKATER_LEVELS` / `EVENT_SPONSOR_TIERS`).
* They now carry a `labelKey` and render through `t()`.
*
* Genuine skate terrain/discipline/stance terms stay English in every locale
* (Street, Park, Vert, Bowl, Regular, Goofy, DIY); levels, sponsor tiers and the
* descriptive styles translate.
*
* `en`/`es` live here (the `th`/`ja` fragments are `th/batch18.ts` and
* `ja/batch19.ts`).
*/
const skaterLabelsUi = {
en: {
stance: {
regular: 'Regular',
goofy: 'Goofy',
},
style: {
street: 'Street',
park: 'Park',
vert: 'Vert',
transition: 'Transition',
bowl: 'Bowl',
cruising: 'Cruising',
downhill: 'Downhill',
freestyle: 'Freestyle',
ledges: 'Ledges',
stairs: 'Stairs',
diy: 'DIY',
},
level: {
grom: 'Grom',
intermediate: 'Intermediate',
advanced: 'Advanced',
ripper: 'Ripper',
},
sponsor: {
title: 'Title Sponsor',
gold: 'Gold',
silver: 'Silver',
partner: 'Partner',
},
},
es: {
stance: {
regular: 'Regular',
goofy: 'Goofy',
},
style: {
street: 'Street',
park: 'Park',
vert: 'Vert',
transition: 'Transición',
bowl: 'Bowl',
cruising: 'Paseo',
downhill: 'Descenso',
freestyle: 'Freestyle',
ledges: 'Bordes',
stairs: 'Escaleras',
diy: 'DIY',
},
level: {
grom: 'Novato',
intermediate: 'Intermedio',
advanced: 'Avanzado',
ripper: 'Crack',
},
sponsor: {
title: 'Patrocinador principal',
gold: 'Oro',
silver: 'Plata',
partner: 'Colaborador',
},
},
};
export default skaterLabelsUi;

156
src/i18n/legacy/weather.ts Normal file
View file

@ -0,0 +1,156 @@
/**
* `weatherUi.*` — English + Castellano labels for the Open-Meteo weather blocks.
*
* The English source strings live next to the data in `src/lib/weather.ts`
* (`weatherCodes`, `getAQIInfo`, `calculateSkateability`) and are carried on the
* objects as `descriptionKey` / `labelKey` / `reasonKeys` so the UI can render
* `t(key)` (see `SpotDetailPage` and `SpotMap`). Those English `*Key` values are
* `weatherUi.*` and MUST match the keys here.
*
* The plain English strings stay on the data objects for non-UI consumers (the
* Sasquatch tool-result JSON), which is deliberately kept in English.
*/
const weatherUi = {
en: {
condition: {
clearSky: 'Clear sky',
mainlyClear: 'Mainly clear',
partlyCloudy: 'Partly cloudy',
overcast: 'Overcast',
foggy: 'Foggy',
depositingRimeFog: 'Depositing rime fog',
lightDrizzle: 'Light drizzle',
moderateDrizzle: 'Moderate drizzle',
denseDrizzle: 'Dense drizzle',
lightFreezingDrizzle: 'Light freezing drizzle',
denseFreezingDrizzle: 'Dense freezing drizzle',
slightRain: 'Slight rain',
moderateRain: 'Moderate rain',
heavyRain: 'Heavy rain',
lightFreezingRain: 'Light freezing rain',
heavyFreezingRain: 'Heavy freezing rain',
slightSnow: 'Slight snow',
moderateSnow: 'Moderate snow',
heavySnow: 'Heavy snow',
snowGrains: 'Snow grains',
slightRainShowers: 'Slight rain showers',
moderateRainShowers: 'Moderate rain showers',
violentRainShowers: 'Violent rain showers',
slightSnowShowers: 'Slight snow showers',
heavySnowShowers: 'Heavy snow showers',
thunderstorm: 'Thunderstorm',
thunderstormSlightHail: 'Thunderstorm with slight hail',
thunderstormHeavyHail: 'Thunderstorm with heavy hail',
unknown: 'Unknown',
},
aqi: {
good: 'Good',
moderate: 'Moderate',
unhealthySensitive: 'Unhealthy for Sensitive Groups',
unhealthy: 'Unhealthy',
veryUnhealthy: 'Very Unhealthy',
hazardous: 'Hazardous',
},
skateability: {
perfect: 'Perfect',
good: 'Good',
okay: 'Okay',
poor: 'Poor',
bad: 'Bad',
unrideable: 'Unrideable',
},
reason: {
currentlyRaining: 'Currently raining',
raining: 'Raining',
snowing: 'Snowing',
thunderstorm: 'Thunderstorm',
freezingTemperature: 'Freezing temperature',
veryCold: 'Very cold',
extremelyHot: 'Extremely hot',
veryHot: 'Very hot',
veryStrongWind: 'Very strong wind',
strongWind: 'Strong wind',
moderateWind: 'Moderate wind',
veryUnhealthyAir: 'Very unhealthy air',
unhealthyAir: 'Unhealthy air',
moderateAirQuality: 'Moderate air quality',
veryHumid: 'Very humid',
humid: 'Humid',
extremeUv: 'Extreme UV - wear sunscreen',
highUv: 'High UV - sun protection recommended',
perfectConditions: 'Perfect skating conditions',
},
},
es: {
condition: {
clearSky: 'Cielo despejado',
mainlyClear: 'Mayormente despejado',
partlyCloudy: 'Parcialmente nublado',
overcast: 'Cubierto',
foggy: 'Niebla',
depositingRimeFog: 'Niebla con escarcha',
lightDrizzle: 'Llovizna ligera',
moderateDrizzle: 'Llovizna moderada',
denseDrizzle: 'Llovizna intensa',
lightFreezingDrizzle: 'Llovizna helada ligera',
denseFreezingDrizzle: 'Llovizna helada intensa',
slightRain: 'Lluvia ligera',
moderateRain: 'Lluvia moderada',
heavyRain: 'Lluvia fuerte',
lightFreezingRain: 'Lluvia helada ligera',
heavyFreezingRain: 'Lluvia helada fuerte',
slightSnow: 'Nieve ligera',
moderateSnow: 'Nieve moderada',
heavySnow: 'Nieve fuerte',
snowGrains: 'Granos de nieve',
slightRainShowers: 'Chubascos ligeros',
moderateRainShowers: 'Chubascos moderados',
violentRainShowers: 'Chubascos violentos',
slightSnowShowers: 'Chubascos de nieve ligeros',
heavySnowShowers: 'Chubascos de nieve fuertes',
thunderstorm: 'Tormenta eléctrica',
thunderstormSlightHail: 'Tormenta eléctrica con granizo ligero',
thunderstormHeavyHail: 'Tormenta eléctrica con granizo fuerte',
unknown: 'Desconocido',
},
aqi: {
good: 'Bueno',
moderate: 'Moderado',
unhealthySensitive: 'Perjudicial para grupos sensibles',
unhealthy: 'Perjudicial',
veryUnhealthy: 'Muy perjudicial',
hazardous: 'Peligroso',
},
skateability: {
perfect: 'Perfecto',
good: 'Bueno',
okay: 'Aceptable',
poor: 'Deficiente',
bad: 'Malo',
unrideable: 'No patinable',
},
reason: {
currentlyRaining: 'Está lloviendo ahora',
raining: 'Lloviendo',
snowing: 'Nevando',
thunderstorm: 'Tormenta eléctrica',
freezingTemperature: 'Temperatura bajo cero',
veryCold: 'Mucho frío',
extremelyHot: 'Calor extremo',
veryHot: 'Mucho calor',
veryStrongWind: 'Viento muy fuerte',
strongWind: 'Viento fuerte',
moderateWind: 'Viento moderado',
veryUnhealthyAir: 'Aire muy perjudicial',
unhealthyAir: 'Aire perjudicial',
moderateAirQuality: 'Calidad del aire moderada',
veryHumid: 'Muy húmedo',
humid: 'Húmedo',
extremeUv: 'UV extremo: usa protector solar',
highUv: 'UV alto: se recomienda protección solar',
perfectConditions: 'Condiciones perfectas para patinar',
},
},
};
export default weatherUi;

201
src/i18n/th/batch15.ts Normal file
View file

@ -0,0 +1,201 @@
/**
* Thai (ไทย) — batch 15: the deck-cost calculator UI (`deckCostUi.*`).
*
* On-screen strings for `components/DeckCostCalculator.tsx`. Skate/brand terms
* stay English or are transliterated the way the rest of `th` does (เด็ค for
* deck) — sats, Nostr, Lightning, NIP-05, Cashu, Incoterm codes, Markdown and
* JSON stay English. `{{placeholders}}` are preserved verbatim.
*/
const thBatch15 = {
deckCostUi: {
// Toast / prompt / confirm
presetPrompt: 'ตั้งชื่อพรีเซ็ตนี้',
presetDefaultName: 'พรีเซ็ต {{n}}',
summaryCopied: 'คัดลอกสรุปแล้ว',
couldNotCopy: 'คัดลอกไม่ได้',
resetConfirm: 'รีเซ็ตทุกอย่างกลับเป็นค่าตัวอย่างหรือไม่?',
logoTooLarge: 'โลโก้ใหญ่เกินไป (สูงสุด 500 KB)',
loginToFill: 'เข้าสู่ระบบเพื่อเติมจากโปรไฟล์ Nostr ของคุณ',
filledFromProfile: 'เติมจากโปรไฟล์ Nostr ของคุณแล้ว',
// Sanity warnings
warnRetailBelowOne: 'ส่วนบวกขายปลีกต่ำกว่า 1× — คุณจะขายขาดทุน',
warnWholesaleAboveRetail: 'ส่วนบวกขายส่งควรต่ำกว่าส่วนบวกขายปลีก',
warnZeroBoards: '{{count}} แถวใช้ 0 เด็ค — ต้นทุนของแถวนั้นจะถูกละเว้น',
warnFixedNoBoards: 'มีการตั้งต้นทุนคงที่รายเดือนแต่จำนวนเด็ค/เดือนเป็น 0 จึงละเว้นต้นทุนการดำเนินงาน',
warnNoMaterials: 'ไม่มีต้นทุนวัสดุ — เพิ่มวัสดุอย่างน้อยหนึ่งรายการ',
warnEquipmentOverhead: 'อุปกรณ์ + ค่าใช้จ่ายส่วนกลางเกิน 60% ของต้นทุน — โมเดลนี้สมมติปริมาณสูง ตรวจสอบจำนวนเด็คของคุณ',
warnOneBoard: 'อุปกรณ์บางรายการถูกเฉลี่ยบนเด็คเพียงใบเดียว — เพิ่มจำนวนเด็คเพื่อตัดจำหน่าย',
// Quick-start templates
templateStarterName: 'มือใหม่',
templateStarterBlurb: 'ไม่กี่แถว ไม่มีต้นทุนคงที่',
templateShopName: 'ร้านเล็ก',
templateShopBlurb: 'ตารางเต็มพร้อมค่าเช่าและค่าใช้จ่ายส่วนกลาง',
// Currency bar
currency: 'สกุลเงิน',
noCurrencyMatches: 'ไม่พบสกุลเงินที่ตรงกัน',
ratesUnavailable: 'อัตราแลกเปลี่ยนใช้ไม่ได้ — การสลับจะเปลี่ยนแค่ป้ายชื่อ',
liveRates: 'อัตรา BTC สด · รวม sats',
chipCost: 'ต้นทุน',
chipSell: 'ขาย',
chipTotal: 'รวม',
// Toolbar
startFrom: 'เริ่มจาก',
advancedToggleHint: 'แสดงอัตราและของเสียต่อขั้นตอน การตั้งค่าล็อต ภาษี และกราฟต้นทุนเทียบจำนวน',
advanced: 'ขั้นสูง',
simple: 'ง่าย',
// Sections
sectionLabor: 'ค่าแรง',
sectionEquipment: 'อุปกรณ์',
sectionOperations: 'ค่าใช้จ่ายการดำเนินงาน',
sectionMaterials: 'วัสดุ',
sectionProcesses: 'ต้นทุนกระบวนการ',
sectionPackaging: 'บรรจุภัณฑ์',
sectionMargins: 'กำไร',
subtotalLoaded: '{{value}}/ชม. (รวมภาระ)',
subtotalPerBoard: '{{value}}/เด็ค',
// Labor
hourlyRate: 'อัตราต่อชั่วโมง ({{unit}})',
labourBurden: 'ภาระค่าแรง (×)',
loadedRate: 'อัตรารวมภาระ',
loadedRateHintBefore: 'อัตรารวมภาระ = ค่าจ้าง × ภาระ (ภาษีและประกันแรงงานของนายจ้าง) ค่าเช่าและบิลต่าง ๆ อยู่ใน ',
loadedRateHintAfter: ' — อย่านับซ้ำทั้งสองที่',
// Table columns
colEquipment: 'อุปกรณ์',
colCostUnit: 'ต้นทุน ({{unit}})',
colCost: 'ต้นทุน',
colSetupH: 'ตั้งค่า (ชม.)',
colLifetimeBoards: 'เด็คตลอดอายุ',
colPerBoard: 'ต่อเด็ค',
colMaterial: 'วัสดุ',
colOrderUnit: 'สั่งซื้อ ({{unit}})',
colShipUnit: 'จัดส่ง ({{unit}})',
colBoardsOrder: 'เด็ค / คำสั่งซื้อ',
colOrderM: 'สั่งซื้อ (นาที)',
colUnloadM: 'ขนลง (นาที)',
colOrder: 'สั่งซื้อ',
colShipping: 'จัดส่ง',
colOrderMin: 'สั่งซื้อ (นาที)',
colUnloadMin: 'ขนลง (นาที)',
colProcess: 'กระบวนการ',
colTimeM: 'เวลา (นาที)',
colBoardsRun: 'เด็ค / รอบ',
colRateHr: 'อัตรา/ชม. (0 = ร้าน)',
colRateHrShort: 'อัตรา/ชม.',
colScrapPct: 'ของเสีย %',
colTimeMin: 'เวลา (นาที)',
colPackaging: 'บรรจุภัณฑ์',
colTimeBoardM: 'เวลา/เด็ค (นาที)',
colTimeBoardMin: 'เวลา/เด็ค (นาที)',
colMaterialsBoardUnit: 'วัสดุ/เด็ค ({{unit}})',
colMaterialsBoard: 'วัสดุ/เด็ค',
// Section footers
equipmentPerBoard: 'อุปกรณ์ต่อเด็ค:',
materialsPerBoard: 'วัสดุต่อเด็ค:',
processesPerBoard: 'กระบวนการต่อเด็ค:',
packagingPerBoard: 'บรรจุภัณฑ์ต่อเด็ค:',
perBoard: 'ต่อเด็ค:',
// Row actions
addEquipment: 'เพิ่มอุปกรณ์',
addMaterial: 'เพิ่มวัสดุ',
addProcess: 'เพิ่มกระบวนการ',
addPackagingStep: 'เพิ่มขั้นตอนบรรจุภัณฑ์',
duplicate: 'ทำสำเนา',
remove: 'ลบ',
// Operations
boardsPerMonth: 'เด็ค / เดือน',
rentPerMonth: 'ค่าเช่า / เดือน',
utilitiesPerMonth: 'ค่าสาธารณูปโภค / เดือน',
subscriptionsPerMonth: 'ค่าสมาชิก / เดือน',
// Margins & results
markup: 'ส่วนบวก (×)',
wholesaleMarkup: 'ส่วนบวกขายส่ง (×)',
scrapWaste: 'ของเสีย / เศษ (%)',
quantityDecks: 'จำนวน (เด็ค)',
targetMonthlyProfit: 'กำไรต่อเดือนเป้าหมาย ({{unit}})',
costNoPackaging: 'ต้นทุน / เด็ค (ไม่รวมบรรจุภัณฑ์)',
costWithPackaging: 'ต้นทุน / เด็ค (รวมบรรจุภัณฑ์)',
batchSetupHours: 'ตั้งค่าล็อต (ชม. / รอบ)',
batchSetupCost: 'ต้นทุนตั้งค่าล็อต ({{unit}} / รอบ)',
taxVat: 'ภาษี / VAT (%)',
quoteTax: 'ภาษีในใบเสนอราคา',
includedInPrice: 'รวมในราคาแล้ว',
addedOnTop: 'บวกเพิ่ม',
setupPerBoard: 'ตั้งค่า / เด็ค',
marginNoteBefore: 'ส่วนบวก {{markup}}× ≈ ',
grossMargin: 'กำไรขั้นต้น {{pct}}%',
marginNoteAfter: ' · ของเสีย {{waste}}% ถูกเฉลี่ยไปยังเด็คที่ดี',
sellingPriceNoPackaging: 'ราคาขาย · ไม่รวมบรรจุภัณฑ์',
sellingPriceWithPackaging: 'ราคาขาย · รวมบรรจุภัณฑ์',
profitPerDeck: 'กำไร / เด็ค',
wholesalePerDeck: 'ขายส่ง / เด็ค',
orderTotalQty: 'ยอดรวมคำสั่งซื้อ ({{qty}})',
orderTotal: 'ยอดรวมคำสั่งซื้อ',
breakeven: 'จุดคุ้มทุน',
breakevenPerMonth: '{{count}}/เดือน',
targetDecksHint: 'เพื่อทำ {{amount}}/เดือนเหนือต้นทุนคงที่ ขาย {{decks}} เด็ค/เดือน',
priceIncludesTax: 'ราคารวมภาษี {{pct}}%: {{amount}} ของยอดรวม',
plusTax: 'บวกภาษี {{pct}}%: {{amount}} → รวม {{total}}',
costVsQuantity: 'ต้นทุนเทียบจำนวน (ตัดจำหน่ายค่าตั้งค่า)',
deckOne: 'เด็ค',
deckOther: 'เด็ค',
perDeckSuffix: '/ เด็ค',
orderSuffix: 'คำสั่งซื้อ {{amount}}',
shippingNotIncluded: 'ไม่รวมค่าจัดส่งถึงผู้ซื้อ',
// Quote details
clientQuoteDetails: 'รายละเอียดใบเสนอราคา (สำหรับ PDF)',
fillFromProfileTitle: 'เติมชื่อ เว็บไซต์ NIP-05 Lightning และ npub จากโปรไฟล์ Nostr ของคุณ',
fillFromProfile: 'เติมจากโปรไฟล์',
businessName: 'ชื่อธุรกิจ / ร้าน',
email: 'อีเมล',
phone: 'โทรศัพท์',
website: 'เว็บไซต์',
address: 'ที่อยู่',
quoteNumber: 'เลขที่ใบเสนอราคา',
nextNumber: 'เลขถัดไป',
validDays: 'อายุ (วัน)',
nip05: 'NIP-05',
lightningAddress: 'ที่อยู่ Lightning',
nostrNpub: 'Nostr (npub)',
depositPct: 'เงินมัดจำจ่ายล่วงหน้า (%)',
deliveryTerms: 'เงื่อนไขการส่งมอบ (Incoterm, ไม่บังคับ)',
paymentMethods: 'วิธีชำระเงิน',
paymentTerms: 'เงื่อนไขการชำระเงิน',
paymentDetails: 'รายละเอียดการชำระเงิน',
notesTerms: 'หมายเหตุ / เงื่อนไข',
changeLogo: 'เปลี่ยนโลโก้',
addLogo: 'เพิ่มโลโก้',
// Incoterms
incotermNone: 'ไม่มี',
incotermExw: 'EXW — ผู้ซื้อมารับเอง',
incotermFca: 'FCA — ส่งมอบให้ผู้ขนส่งของผู้ซื้อ',
incotermDap: 'DAP — ส่งถึงที่อยู่ผู้ซื้อ',
incotermDdp: 'DDP — ส่งถึงพร้อมชำระอากรแล้ว',
// Payment methods
payMethodCash: 'เงินสด',
payMethodLightning: 'Lightning',
payMethodCashu: 'Cashu',
payMethodBank: 'โอนผ่านธนาคาร',
payMethodCard: 'บัตร',
payMethodOther: 'อื่น ๆ',
// Actions
listDeckAtPrice: 'ลงขายเด็คที่ราคานี้',
getItMade: 'ให้เขาทำให้ — ค้นหาช่าง',
savePreset: 'บันทึกพรีเซ็ต',
load: 'โหลด',
export: 'ส่งออก',
import: 'นำเข้า',
reset: 'รีเซ็ต',
deletePreset: 'ลบพรีเซ็ต',
// Export menu
exportPdfFull: 'PDF — ตารางเต็ม',
exportPdfQuote: 'PDF — ใบเสนอราคาลูกค้า',
exportPng: 'รูป PNG',
exportXlsx: 'ไฟล์ XLSX',
exportCsv: 'CSV (สเปรดชีต)',
exportMarkdown: 'Markdown',
exportJson: 'JSON (สำรอง)',
exportCopySummary: 'คัดลอกสรุป',
},
};
export default thBatch15;

157
src/i18n/th/batch16.ts Normal file
View file

@ -0,0 +1,157 @@
/**
* Thai (ไทย) — batch 16: Lab role-form strings (`becomeRolesUi`).
*
* The copy that lives in the `BECOME_ROLES` data table (`src/lib/becomeRoles.tsx`)
* and is rendered by `CraftProfileForm`, `BecomeRolePage` (SEO meta) and
* `LabProfilePreview`.
*
* Brand/protocol names and skate vocabulary stay English as established in
* earlier batches (The Lab, Coach, Crew, Spot, Stash, Nostr, Lightning, Zap,
* sats, SkateSpots), and trick names stay English. `{{placeholders}}` (if any)
* are kept verbatim.
*/
const thBatch16 = {
becomeRolesUi: {
artist: {
seoTitle: 'เป็นดีไซเนอร์ / ศิลปิน - SkateSpots',
seoDescription: 'ลงประกาศงานออกแบบและศิลปะสเก็ตของคุณ — กราฟิกกระดาน โลโก้ โปสเตอร์ สินค้า และอื่น ๆ',
heading: 'ดีไซเนอร์ / ศิลปิน',
tagline: 'กราฟิกกระดาน โลโก้ โปสเตอร์ สินค้า — เอางานศิลปะของคุณสู่ซีน',
noun: 'ศิลปิน',
rateLabel: 'ราคา',
portfolioLabel: 'ลิงก์พอร์ตโฟลิโอ / ร้านค้า',
sectionWhatYouMake: 'สิ่งที่คุณสร้าง',
sectionMedium: 'เทคนิค',
sectionSoftwareTools: 'ซอฟต์แวร์และเครื่องมือ',
deliverablesPlaceholder: 'เช่น ไฟล์พร้อมพิมพ์ ไฟล์ต้นฉบับ กราฟิกกระดาน',
turnaroundPlaceholder: 'เช่น 3–5 วัน',
},
graffiti: {
seoTitle: 'รับงานกราฟฟิตี้ / จิตรกรรมฝาผนัง - SkateSpots',
seoDescription: 'ลงประกาศงานกราฟฟิตี้และจิตรกรรมฝาผนังของคุณ — ชิ้นงาน จิตรกรรมฝาผนัง ศิลปะสเก็ตพาร์ก และอื่น ๆ',
heading: 'ศิลปินกราฟฟิตี้ / จิตรกรรมฝาผนัง',
tagline: 'ชิ้นงาน จิตรกรรมฝาผนัง ศิลปะสเก็ตพาร์ก — ระบายสีให้ซีน',
noun: 'ศิลปินกราฟฟิตี้',
rateLabel: 'ราคา',
portfolioLabel: 'ลิงก์พอร์ตโฟลิโอ (IG, เว็บไซต์)',
sectionStyle: 'สไตล์',
sectionMedium: 'เทคนิค',
sectionWorkContext: 'บริบทของงาน',
crewLabel: 'crew กราฟฟิตี้',
crewPlaceholder: 'crew ที่คุณเขียนด้วย (ไม่บังคับ)',
crewHelper: 'แยกจาก crew สเก็ตของคุณ — นักเขียนที่คุณไปพ่นด้วยกัน',
surfacesPlaceholder: 'เช่น คอนกรีต อิฐ กำแพงที่ได้รับอนุญาต',
maxSizePlaceholder: 'เช่น สูงสุด 10 ม.',
},
musician: {
seoTitle: 'เพิ่มโปรไฟล์ดนตรี - SkateSpots',
seoDescription: 'เล่นสเก็ตและทำเพลง? ลงประกาศวงหรือโปรเจกต์เดี่ยวของคุณ — แนวเพลง เครื่องดนตรี และลิงก์',
heading: 'นักดนตรี / วง',
tagline: 'เล่นสเก็ตและทำเพลง? รับงานแสดงเดโม แจม และปาร์ตี้หลังงาน',
noun: 'นักดนตรี',
rateLabel: 'ราคาจองงาน',
portfolioLabel: 'ลิงก์เว็บไซต์ / EPK',
sectionGenre: 'แนวเพลง',
sectionInstruments: 'เครื่องดนตรีและบทบาท',
actLabel: 'ประเภทการแสดง',
actPlaceholder: 'เดี่ยว / วง / ดีเจ / โปรดิวเซอร์',
spotifyLabel: 'Spotify',
spotifyPlaceholder: 'https://open.spotify.com/…',
bandcampLabel: 'Bandcamp',
bandcampPlaceholder: 'https://…bandcamp.com',
soundcloudLabel: 'SoundCloud',
soundcloudPlaceholder: 'https://soundcloud.com/…',
youtubeLabel: 'YouTube',
youtubePlaceholder: 'https://youtube.com/…',
setLengthPlaceholder: 'เช่น 30–45 นาที',
techRiderPlaceholder: 'เช่น PA, ไมค์, แบ็กไลน์',
},
judge: {
seoTitle: 'เป็นกรรมการตัดสินอีเวนต์สเก็ต - SkateSpots',
seoDescription: 'ลงประกาศตัวคุณเป็นกรรมการตัดสินอีเวนต์สเก็ต — Game of S.K.A.T.E., best trick, การแข่งขัน, ซีรีส์ am และรายการ world tour ให้คนจ้างคุณไปตัดสินอย่างยุติธรรมและน่าเชื่อถือ',
heading: 'กรรมการตัดสินอีเวนต์สเก็ต',
tagline: 'จาก Game of S.K.A.T.E. บนพื้นเรียบถึง World Destruction Tour — ตัดสินให้ยุติธรรมและน่าเชื่อถือ',
noun: 'กรรมการ',
rateLabel: 'ราคาต่อวัน / ค่าตอบแทน',
portfolioLabel: 'คลังผลงาน / ลิงก์โปรไฟล์',
sectionEventFormats: 'รูปแบบอีเวนต์',
sectionSpecialty: 'ความถนัด',
sectionLevelCircuit: 'ระดับ / ทัวร์นาเมนต์',
eventsJudgedLabel: 'อีเวนต์ที่ตัดสินแล้ว',
eventsJudgedPlaceholder: 'เช่น 40+ การแข่งขัน, 3 รายการ World Tour',
eventsJudgedHelper: 'อีเวนต์ การแข่งขัน หรือทัวร์เด่น ๆ ที่คุณเคยเป็นกรรมการ',
travelPlaceholder: 'เช่น ระดับภูมิภาค / ทั่วประเทศ / ต่างประเทศ',
certificationsPlaceholder: 'เช่น คอร์สกรรมการระดับชาติ',
},
organizer: {
seoTitle: 'จัดอีเวนต์สเก็ต - SkateSpots',
seoDescription: 'ลงประกาศตัวคุณเป็นผู้จัดอีเวนต์สเก็ต — แจมถนน, best trick, การแข่งขัน, มีตติ้ง, เดโม และรายการทัวร์ ให้คนค้นพบและจ้างคุณไปจัดเซสชัน',
heading: 'ผู้จัดอีเวนต์',
tagline: 'แจม การแข่งขัน มีตติ้ง และเดโม — จัดเซสชันและรวมซีนเข้าด้วยกัน',
noun: 'ผู้จัดงาน',
rateLabel: 'ค่าจัดอีเวนต์ / งบประมาณ',
portfolioLabel: 'ลิงก์อีเวนต์ที่ผ่านมา / ผลงาน',
sectionEventTypes: 'ประเภทอีเวนต์',
sectionWhatYouHandle: 'สิ่งที่คุณดูแล',
sectionScale: 'ขนาด',
eventsHostedLabel: 'อีเวนต์ที่จัดแล้ว',
eventsHostedPlaceholder: 'เช่น 25 แจม, 3 การแข่งขันระดับเมือง',
eventsHostedHelper: 'อีเวนต์ การแข่งขัน หรือมีตติ้งเด่น ๆ ที่คุณเคยจัด',
insurancePlaceholder: 'เช่น รวมประกันความรับผิดต่อบุคคลภายนอก',
permitsPlaceholder: 'เช่น จัดการใบอนุญาตจากเทศบาลแล้ว',
},
tattoo: {
seoTitle: 'เป็นช่างสัก - SkateSpots',
seoDescription: 'ลงประกาศงานสักของคุณสำหรับซีนสเก็ต — สไตล์ งานสำเร็จรูป งานสั่งทำ และอื่น ๆ',
heading: 'ช่างสัก',
tagline: 'หมึกสำหรับซีน — งานสำเร็จรูป งานสั่งทำ เส้นเล็ก และอื่น ๆ',
noun: 'ช่างสัก',
rateLabel: 'ราคา / ต่อชั่วโมง',
portfolioLabel: 'ลิงก์พอร์ตโฟลิโอ (IG, เว็บไซต์)',
sectionStyles: 'สไตล์',
sectionMethod: 'วิธี',
studioLabel: 'สตูดิโอ / ร้าน',
studioPlaceholder: 'ชื่อสตูดิโอ (ไม่บังคับ)',
booksOpenLabel: 'เปิดรับคิว',
booksOpenHelper: 'ตอนนี้คุณรับจองคิวอยู่ไหม?',
depositPlaceholder: 'เช่น มัดจำ 50 EUR ไม่คืน',
minimumPlaceholder: 'เช่น 80 EUR',
},
trainer: {
seoTitle: 'เป็นเทรนเนอร์ / นักกายภาพบำบัด - SkateSpots',
seoDescription: 'ช่วยให้สเก็ตเตอร์แข็งแรงและสุขภาพดี — ความแข็งแรง การเคลื่อนไหว การป้องกันการบาดเจ็บ และการฟื้นฟู',
heading: 'เทรนเนอร์ / นักกายภาพบำบัด',
tagline: 'ความแข็งแรง การเคลื่อนไหว การป้องกันการบาดเจ็บ และการฟื้นฟูสำหรับสเก็ตเตอร์',
noun: 'เทรนเนอร์',
rateLabel: 'ราคา / ต่อเซสชัน',
portfolioLabel: 'โปรไฟล์ / ลิงก์จอง',
sectionFocus: 'จุดเน้น',
sectionInjuries: 'อาการบาดเจ็บที่คุณดูแล',
sectionCredentialsSetting: 'คุณวุฒิและสถานที่',
credentialsPlaceholder: 'เช่น ป.ตรี กายภาพบำบัด, CSCS…',
credentialsHelper: 'ระบุคุณวุฒิจริง — สเก็ตเตอร์เชื่อถือคุณวุฒิที่ตรวจสอบได้',
remoteLabel: 'ออนไลน์ / จากระยะไกล',
remoteHelper: 'คุณให้บริการโค้ชหรือปรึกษาออนไลน์ไหม?',
modalitiesPlaceholder: 'เช่น ใช้มือ, S&C, dry needling',
},
barber: {
seoTitle: 'เป็นช่างตัดผม / สไตลิสต์ - SkateSpots',
seoDescription: 'ลงประกาศร้านตัดผมหรือเก้าอี้ของคุณสำหรับซีนสเก็ต — ตัดผม, fade, โกนหนวด, ย้อมสี และอื่น ๆ ให้สเก็ตเตอร์จองคุณ',
heading: 'ช่างตัดผม / สไตลิสต์',
tagline: 'ตัดผม fade และโกนหนวดสำหรับซีน — ที่ร้านหรือบริการนอกสถานที่',
noun: 'ช่างตัดผม',
rateLabel: 'ราคา / ต่อครั้ง',
portfolioLabel: 'แกลเลอรีผลงาน / ลิงก์ IG',
sectionWhatYouOffer: 'สิ่งที่คุณให้บริการ',
sectionSignatureStyles: 'สไตล์เด่น',
sectionSetting: 'สถานที่',
studioLabel: 'ร้าน / สตูดิโอ',
studioPlaceholder: 'ชื่อร้านหรือสตูดิโอ (ไม่บังคับ)',
booksOpenLabel: 'เปิดรับคิว',
booksOpenHelper: 'ตอนนี้คุณรับจองคิวอยู่ไหม?',
durationPlaceholder: 'เช่น 30–45 นาที',
},
},
};
export default thBatch16;

82
src/i18n/th/batch17.ts Normal file
View file

@ -0,0 +1,82 @@
/**
* Thai (ไทย) — batch 17: `weatherUi` (Open-Meteo weather blocks).
*
* Mirrors `src/lib/weather.ts` — `condition` (WMO weather codes), `aqi`
* (air-quality bands), `skateability` (score label) and `reason`
* (skateability reasons). Translated as descriptive condition text; no
* placeholders to preserve. Keys must match `src/i18n/legacy/weather.ts`.
*/
const thBatch17 = {
weatherUi: {
condition: {
clearSky: 'ท้องฟ้าแจ่มใส',
mainlyClear: 'ท้องฟ้าโปร่งเป็นส่วนใหญ่',
partlyCloudy: 'มีเมฆบางส่วน',
overcast: 'ฟ้าครึ้ม',
foggy: 'มีหมอก',
depositingRimeFog: 'หมอกที่เกาะเป็นน้ำค้างแข็ง',
lightDrizzle: 'ฝนละอองเบา',
moderateDrizzle: 'ฝนละอองปานกลาง',
denseDrizzle: 'ฝนละอองหนา',
lightFreezingDrizzle: 'ฝนละอองเยือกแข็งเบา',
denseFreezingDrizzle: 'ฝนละอองเยือกแข็งหนา',
slightRain: 'ฝนเล็กน้อย',
moderateRain: 'ฝนปานกลาง',
heavyRain: 'ฝนหนัก',
lightFreezingRain: 'ฝนเยือกแข็งเบา',
heavyFreezingRain: 'ฝนเยือกแข็งหนัก',
slightSnow: 'หิมะเล็กน้อย',
moderateSnow: 'หิมะปานกลาง',
heavySnow: 'หิมะหนัก',
snowGrains: 'เกล็ดหิมะ',
slightRainShowers: 'ฝนซู่เล็กน้อย',
moderateRainShowers: 'ฝนซู่ปานกลาง',
violentRainShowers: 'ฝนซู่รุนแรง',
slightSnowShowers: 'หิมะซู่เล็กน้อย',
heavySnowShowers: 'หิมะซู่หนัก',
thunderstorm: 'พายุฝนฟ้าคะนอง',
thunderstormSlightHail: 'พายุฝนฟ้าคะนองที่มีลูกเห็บเล็กน้อย',
thunderstormHeavyHail: 'พายุฝนฟ้าคะนองที่มีลูกเห็บหนัก',
unknown: 'ไม่ทราบ',
},
aqi: {
good: 'ดี',
moderate: 'ปานกลาง',
unhealthySensitive: 'ไม่ดีต่อกลุ่มเสี่ยง',
unhealthy: 'ไม่ดีต่อสุขภาพ',
veryUnhealthy: 'ไม่ดีต่อสุขภาพมาก',
hazardous: 'อันตราย',
},
skateability: {
perfect: 'สมบูรณ์แบบ',
good: 'ดี',
okay: 'พอใช้',
poor: 'แย่',
bad: 'ไม่ดี',
unrideable: 'เล่นไม่ได้',
},
reason: {
currentlyRaining: 'ฝนกำลังตกอยู่',
raining: 'ฝนตก',
snowing: 'หิมะตก',
thunderstorm: 'พายุฝนฟ้าคะนอง',
freezingTemperature: 'อุณหภูมิต่ำกว่าจุดเยือกแข็ง',
veryCold: 'หนาวมาก',
extremelyHot: 'ร้อนจัด',
veryHot: 'ร้อนมาก',
veryStrongWind: 'ลมแรงมาก',
strongWind: 'ลมแรง',
moderateWind: 'ลมปานกลาง',
veryUnhealthyAir: 'อากาศไม่ดีต่อสุขภาพมาก',
unhealthyAir: 'อากาศไม่ดีต่อสุขภาพ',
moderateAirQuality: 'คุณภาพอากาศปานกลาง',
veryHumid: 'ชื้นมาก',
humid: 'ชื้น',
extremeUv: 'รังสียูวีรุนแรง - ทาครีมกันแดด',
highUv: 'รังสียูวีสูง - แนะนำให้ป้องกันแสงแดด',
perfectConditions: 'สภาพเหมาะแก่การเล่นสเก็ต',
},
},
};
export default thBatch17;

85
src/i18n/th/batch18.ts Normal file
View file

@ -0,0 +1,85 @@
/**
* Thai (ไทย) — batch 18: `skaterLabelsUi` + `notifyUi`.
*
* Keys mirror `src/i18n/legacy/skaterLabels.ts` and
* `src/i18n/legacy/notify.ts` exactly; `{{placeholders}}` stay byte-identical.
* Skate terrain/stance terms (Street, Park, Vert, Bowl, Regular, Goofy, DIY)
* stay English — Thai skaters use the English terms.
*/
const thBatch18 = {
skaterLabelsUi: {
stance: {
regular: 'Regular',
goofy: 'Goofy',
},
style: {
street: 'Street',
park: 'Park',
vert: 'Vert',
transition: 'ทรานซิชัน',
bowl: 'Bowl',
cruising: 'ครูซซิ่ง',
downhill: 'ดาวน์ฮิลล์',
freestyle: 'ฟรีสไตล์',
ledges: 'ริมสัน',
stairs: 'บันได',
diy: 'DIY',
},
level: {
grom: 'มือใหม่',
intermediate: 'ระดับกลาง',
advanced: 'ขั้นสูง',
ripper: 'ตัวท็อป',
},
sponsor: {
title: 'ผู้สนับสนุนหลัก',
gold: 'ทอง',
silver: 'เงิน',
partner: 'พาร์ทเนอร์',
},
},
notifyUi: {
activity: {
zappedWithAmount: 'คุณได้รับ zap {{sats}} sats',
zapped: 'คุณได้รับ zap',
liked: 'มีคนถูกใจโพสต์ของคุณ',
reposted: 'มีคนรีโพสต์คุณ',
mentioned: 'มีคนกล่าวถึงคุณ',
replied: 'มีคนตอบกลับคุณ',
rsvp: 'มีคนตอบรับเข้าร่วมอีเวนต์ของคุณ',
eventUpdated: 'อีเวนต์ที่คุณจะไปมีการอัปเดต',
newActivity: 'กิจกรรมใหม่',
tapToOpen: 'แตะเพื่อเปิด SkateSpots',
},
crew: {
inviteTitle: 'คำเชิญเข้า crew',
inviteBody: 'คุณได้รับเชิญไปที่ {{crew}}',
addedTitle: 'คุณถูกเพิ่มเข้า crew',
addedBody: '{{crew}} — ยืนยันเพื่อเข้าร่วม',
joinRequestTitle: 'คำขอเข้าร่วม',
joinRequestBody: 'มีคนต้องการเข้าร่วม {{name}}',
},
dm: {
title: 'ข้อความส่วนตัวใหม่',
body: 'แตะเพื่ออ่าน',
},
trade: {
confirm: 'ยืนยันการแลกเปลี่ยน',
confirmed: 'ยืนยันการแลกเปลี่ยนแล้ว',
newOffer: 'มีข้อเสนอใหม่ในประกาศของคุณ',
counter: 'ข้อเสนอโต้กลับ',
accepted: 'ข้อเสนอได้รับการยอมรับ 🎉',
declined: 'ข้อเสนอถูกปฏิเสธ',
withdrawn: 'ข้อเสนอถูกถอน',
},
newsletter: {
drop: 'ข่าวใหม่จาก SkateSpots',
},
summary: {
moreUpdates: 'อีก {{count}} รายการ',
openToCatchUp: 'เปิด SkateSpots เพื่อติดตาม',
},
},
};
export default thBatch18;

76
src/i18n/th/batch19.ts Normal file
View file

@ -0,0 +1,76 @@
/**
* Thai (ไทย) — batch 19: legacyMiscUi.
*
* Scattered leftovers routed through the new `legacyMiscUi.*` namespace:
* hosting dashboard, sponsor slots, report inbox, set-status dialog, a few
* SEO/aria labels and one zap error.
*
* Skate terms stay English (spot, park, crew, jam, Stash, The Lab, Nostr,
* Lightning, Zap, Relay, sats, RSVP, Cashu) and brand names (SkateSpots, Cordn,
* Cashu) are kept. `{{placeholders}}` are preserved exactly.
*/
const thBatch19 = {
legacyMiscUi: {
// Hosting dashboard
hostingGoing: 'จะไป {{count}} คน',
hostingInterested: 'สนใจ {{count}} คน',
hostingPostUpdate: 'โพสต์อัปเดต',
hostingEdit: 'แก้ไข',
hostingLoginToSee: 'เข้าสู่ระบบเพื่อดูอีเวนต์ที่คุณเป็นเจ้าภาพ',
hostingEmptyTitle: 'คุณยังไม่ได้เป็นเจ้าภาพอีเวนต์ใด',
hostingEmptyBody:
'จัด jam, การแข่งขัน หรือเซสชัน สร้างอีเวนต์แล้วสเกตเตอร์จะกด RSVP รับอัปเดต และแวะมาได้',
hostingCreateEvent: 'สร้างอีเวนต์',
hostingUpcoming: 'กำลังจะมาถึง · {{count}}',
hostingPast: 'ที่ผ่านมา · {{count}}',
// Sponsor slots
sponsorPartners: 'พาร์ตเนอร์',
sponsorTitleSponsor: 'ผู้สนับสนุนหลัก',
sponsorPlatformPartners: 'พาร์ตเนอร์ของแพลตฟอร์ม',
// Reports inbox
reportsUntitled: '(ไม่มีชื่อ)',
reportsResolveFailed:
'ไม่สามารถแสดงอีเวนต์ที่ถูกรายงานได้ (อาจถูกลบไปแล้ว หรือรายงานนั้นชี้ไปที่โปรไฟล์แทนโพสต์)',
reportsLookAtIt: 'ดู',
reportsNoReports: 'ยังไม่มีรายงาน อาจเป็นเพราะทุกอย่างเรียบร้อย หรือไม่มีใครหาปุ่มเจอ',
// Set status dialog
statusSetYourStatus: 'ตั้งสถานะของคุณ',
statusSetDesc:
'บอกให้คนอื่นรู้ว่าคุณกำลังทำอะไร สถานะของคุณจะแสดงบนโปรไฟล์และที่ spot ที่เชื่อมไว้',
statusClear: 'ล้าง',
statusOrWriteOwn: 'หรือพิมพ์ของคุณเอง',
statusCustomPlaceholder: 'คุณกำลังทำอะไรอยู่?',
statusLinkToSpot: 'เชื่อมกับ spot (ไม่บังคับ)',
statusSpotSelected: 'เลือก spot แล้ว',
statusSearchSpots: 'ค้นหา spot...',
statusNoSpotsFound: 'ไม่พบ spot',
statusSelectSpot: 'เลือก spot',
statusAutoClearAfter: 'ล้างอัตโนมัติใน: {{time}}',
statusExpMin: '{{count}} นาที',
statusExpOneHour: '1 ชั่วโมง',
statusExpHours: '{{count}} ชั่วโมง',
statusExpOneDay: '1 วัน',
statusExpDays: '{{count}} วัน',
statusSlider15: '15 นาที',
statusSlider1h: '1 ชม.',
statusSlider4h: '4 ชม.',
statusSlider24h: '24 ชม.',
statusSetting: 'กำลังตั้งสถานะ...',
statusSet: 'ตั้งสถานะ',
statusSomethingWrong: 'เกิดข้อผิดพลาดบางอย่าง',
// SEO + aria labels
seoProfileTitle: 'โปรไฟล์ - SkateSpots',
seoViewProfile: 'ดูโปรไฟล์',
bannerAlt: 'แบนเนอร์',
openInCordn: 'เปิดใน Cordn',
// Zap errors
zapNoActiveMint: 'ไม่พบ Cashu mint ที่ใช้งานอยู่',
},
};
export default thBatch19;

View file

@ -12,6 +12,11 @@ import batch11 from './batch11';
import batch12 from './batch12';
import batch13 from './batch13';
import batch14 from './batch14';
import batch15 from './batch15';
import batch16 from './batch16';
import batch17 from './batch17';
import batch18 from './batch18';
import batch19 from './batch19';
/**
* Thai (ไทย) translations — composed from fragments so parallel translation
@ -36,6 +41,11 @@ const th = {
...batch12,
...batch13,
...batch14,
...batch15,
...batch16,
...batch17,
...batch18,
...batch19,
};
export default th;

View file

@ -45,6 +45,9 @@ interface CraftRole {
kind: 'craft';
seoTitle: string;
seoDescription: string;
/** Optional i18n keys (take precedence over the raw English fallbacks). */
seoTitleKey?: string;
seoDescriptionKey?: string;
config: CraftFormConfig;
}
@ -70,13 +73,18 @@ export const BECOME_ROLES: Record<string, BecomeRole> = {
kind: 'craft',
seoTitle: 'Become a Designer / Artist - SkateSpots',
seoDescription: 'List your skate design & art — board graphics, logos, posters, merch and more.',
seoTitleKey: 'becomeRolesUi.artist.seoTitle',
seoDescriptionKey: 'becomeRolesUi.artist.seoDescription',
config: {
kind: SKATE_ARTIST_KIND,
selfTag: 'skateartist',
identifier: 'artist-profile',
heading: 'Designer / Artist',
headingKey: 'becomeRolesUi.artist.heading',
tagline: 'Board graphics, logos, posters, merch — put your art on the scene',
taglineKey: 'becomeRolesUi.artist.tagline',
noun: 'artist',
nounKey: 'becomeRolesUi.artist.noun',
accentText: 'text-fuchsia-400',
accentBg: 'bg-fuchsia-600',
accentChip: 'bg-fuchsia-600 text-white border-fuchsia-600',
@ -84,15 +92,17 @@ export const BECOME_ROLES: Record<string, BecomeRole> = {
icon: Palette,
rateUnits: pickRateUnits('piece', 'project', 'hour', 'day'),
extraFields: [
{ tag: 'deliverables', label: 'Deliverables', labelKey: 'labUi.craftFields.deliverables', placeholder: 'e.g. print-ready files, source files, deck graphics' },
{ tag: 'turnaround', label: 'Turnaround', labelKey: 'labUi.craftFields.turnaround', placeholder: 'e.g. 3–5 days' },
{ tag: 'deliverables', label: 'Deliverables', labelKey: 'labUi.craftFields.deliverables', placeholder: 'e.g. print-ready files, source files, deck graphics', placeholderKey: 'becomeRolesUi.artist.deliverablesPlaceholder' },
{ tag: 'turnaround', label: 'Turnaround', labelKey: 'labUi.craftFields.turnaround', placeholder: 'e.g. 3–5 days', placeholderKey: 'becomeRolesUi.artist.turnaroundPlaceholder' },
],
rateLabel: 'Rate',
rateLabelKey: 'becomeRolesUi.artist.rateLabel',
portfolioLabel: 'Portfolio / Shop link',
portfolioLabelKey: 'becomeRolesUi.artist.portfolioLabel',
skillSections: [
{ 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 },
{ title: 'What You Make', titleKey: 'becomeRolesUi.artist.sectionWhatYouMake', entries: Object.entries(ARTIST_SERVICE_LABELS), keyMap: ARTIST_SERVICE_KEYS, required: true },
{ title: 'Medium', titleKey: 'becomeRolesUi.artist.sectionMedium', entries: Object.entries(ARTIST_MEDIUM_LABELS), keyMap: ARTIST_MEDIUM_KEYS },
{ title: 'Software & Tools', titleKey: 'becomeRolesUi.artist.sectionSoftwareTools', entries: Object.entries(ARTIST_TOOL_LABELS), keyMap: ARTIST_TOOL_KEYS },
],
},
},
@ -100,13 +110,18 @@ export const BECOME_ROLES: Record<string, BecomeRole> = {
kind: 'craft',
seoTitle: 'Offer Graffiti / Murals - SkateSpots',
seoDescription: 'List your graffiti & mural work — pieces, murals, skatepark art and more.',
seoTitleKey: 'becomeRolesUi.graffiti.seoTitle',
seoDescriptionKey: 'becomeRolesUi.graffiti.seoDescription',
config: {
kind: SKATE_GRAFFITI_KIND,
selfTag: 'skategraffiti',
identifier: 'graffiti-profile',
heading: 'Graffiti / Mural Artist',
headingKey: 'becomeRolesUi.graffiti.heading',
tagline: 'Pieces, murals, skatepark art — paint the scene',
taglineKey: 'becomeRolesUi.graffiti.tagline',
noun: 'graffiti artist',
nounKey: 'becomeRolesUi.graffiti.noun',
accentText: 'text-lime-400',
accentBg: 'bg-lime-500',
accentChip: 'bg-lime-500 text-black border-lime-500',
@ -114,16 +129,18 @@ export const BECOME_ROLES: Record<string, BecomeRole> = {
icon: SprayCan,
rateUnits: pickRateUnits('piece', 'project', 'day', 'hour'),
rateLabel: 'Rate',
rateLabelKey: 'becomeRolesUi.graffiti.rateLabel',
portfolioLabel: 'Portfolio link (IG, site)',
portfolioLabelKey: 'becomeRolesUi.graffiti.portfolioLabel',
skillSections: [
{ 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 },
{ title: 'Style', titleKey: 'becomeRolesUi.graffiti.sectionStyle', entries: Object.entries(GRAFFITI_STYLE_LABELS), keyMap: GRAFFITI_STYLE_KEYS, required: true },
{ title: 'Medium', titleKey: 'becomeRolesUi.graffiti.sectionMedium', entries: Object.entries(GRAFFITI_MEDIUM_LABELS), keyMap: GRAFFITI_MEDIUM_KEYS },
{ title: 'Work Context', titleKey: 'becomeRolesUi.graffiti.sectionWorkContext', 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.' },
{ tag: 'surfaces', label: 'Surfaces / Walls', labelKey: 'labUi.craftFields.surfaces', placeholder: 'e.g. concrete, brick, permitted walls' },
{ tag: 'max_size', label: 'Max Size', labelKey: 'labUi.craftFields.maxSize', placeholder: 'e.g. up to 10 m' },
{ tag: 'crew', label: 'Graffiti Crew', labelKey: 'becomeRolesUi.graffiti.crewLabel', placeholder: 'Your writing crew (optional)', placeholderKey: 'becomeRolesUi.graffiti.crewPlaceholder', helper: 'Separate from your skate crew — the writers you paint with.', helperKey: 'becomeRolesUi.graffiti.crewHelper' },
{ tag: 'surfaces', label: 'Surfaces / Walls', labelKey: 'labUi.craftFields.surfaces', placeholder: 'e.g. concrete, brick, permitted walls', placeholderKey: 'becomeRolesUi.graffiti.surfacesPlaceholder' },
{ tag: 'max_size', label: 'Max Size', labelKey: 'labUi.craftFields.maxSize', placeholder: 'e.g. up to 10 m', placeholderKey: 'becomeRolesUi.graffiti.maxSizePlaceholder' },
],
},
},
@ -131,13 +148,18 @@ export const BECOME_ROLES: Record<string, BecomeRole> = {
kind: 'craft',
seoTitle: 'Add a Music Profile - SkateSpots',
seoDescription: 'Skate and make music? List your band or solo project — genres, instruments and links.',
seoTitleKey: 'becomeRolesUi.musician.seoTitle',
seoDescriptionKey: 'becomeRolesUi.musician.seoDescription',
config: {
kind: SKATE_MUSICIAN_KIND,
selfTag: 'skatemusician',
identifier: 'musician-profile',
heading: 'Musician / Band',
headingKey: 'becomeRolesUi.musician.heading',
tagline: 'Skate and make music? Get booked for demos, jams & after-parties',
taglineKey: 'becomeRolesUi.musician.tagline',
noun: 'musician',
nounKey: 'becomeRolesUi.musician.noun',
accentText: 'text-sky-400',
accentBg: 'bg-sky-500',
accentChip: 'bg-sky-500 text-white border-sky-500',
@ -145,19 +167,21 @@ export const BECOME_ROLES: Record<string, BecomeRole> = {
icon: Music,
rateUnits: pickRateUnits('gig', 'event', 'hour', 'day'),
rateLabel: 'Booking Rate',
rateLabelKey: 'becomeRolesUi.musician.rateLabel',
portfolioLabel: 'Website / EPK link',
portfolioLabelKey: 'becomeRolesUi.musician.portfolioLabel',
skillSections: [
{ 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 },
{ title: 'Genre', titleKey: 'becomeRolesUi.musician.sectionGenre', entries: Object.entries(MUSICIAN_GENRE_LABELS), keyMap: MUSICIAN_GENRE_KEYS, required: true },
{ title: 'Instruments & Role', titleKey: 'becomeRolesUi.musician.sectionInstruments', entries: Object.entries(MUSICIAN_INSTRUMENT_LABELS), keyMap: MUSICIAN_INSTRUMENT_KEYS },
],
extraFields: [
{ tag: 'act', label: 'Act Type', placeholder: 'solo / band / dj / producer' },
{ tag: 'spotify', label: 'Spotify', type: 'url', placeholder: 'https://open.spotify.com/…' },
{ tag: 'bandcamp', label: 'Bandcamp', type: 'url', placeholder: 'https://…bandcamp.com' },
{ tag: 'soundcloud', label: 'SoundCloud', type: 'url', placeholder: 'https://soundcloud.com/…' },
{ tag: 'youtube', label: 'YouTube', type: 'url', placeholder: 'https://youtube.com/…' },
{ tag: 'set_length', label: 'Set Length', labelKey: 'labUi.craftFields.setLength', placeholder: 'e.g. 30–45 min' },
{ tag: 'tech_rider', label: 'Tech Rider', labelKey: 'labUi.craftFields.techRider', placeholder: 'e.g. PA, mics, backline' },
{ tag: 'act', label: 'Act Type', labelKey: 'becomeRolesUi.musician.actLabel', placeholder: 'solo / band / dj / producer', placeholderKey: 'becomeRolesUi.musician.actPlaceholder' },
{ tag: 'spotify', label: 'Spotify', labelKey: 'becomeRolesUi.musician.spotifyLabel', type: 'url', placeholder: 'https://open.spotify.com/…', placeholderKey: 'becomeRolesUi.musician.spotifyPlaceholder' },
{ tag: 'bandcamp', label: 'Bandcamp', labelKey: 'becomeRolesUi.musician.bandcampLabel', type: 'url', placeholder: 'https://…bandcamp.com', placeholderKey: 'becomeRolesUi.musician.bandcampPlaceholder' },
{ tag: 'soundcloud', label: 'SoundCloud', labelKey: 'becomeRolesUi.musician.soundcloudLabel', type: 'url', placeholder: 'https://soundcloud.com/…', placeholderKey: 'becomeRolesUi.musician.soundcloudPlaceholder' },
{ tag: 'youtube', label: 'YouTube', labelKey: 'becomeRolesUi.musician.youtubeLabel', type: 'url', placeholder: 'https://youtube.com/…', placeholderKey: 'becomeRolesUi.musician.youtubePlaceholder' },
{ tag: 'set_length', label: 'Set Length', labelKey: 'labUi.craftFields.setLength', placeholder: 'e.g. 30–45 min', placeholderKey: 'becomeRolesUi.musician.setLengthPlaceholder' },
{ tag: 'tech_rider', label: 'Tech Rider', labelKey: 'labUi.craftFields.techRider', placeholder: 'e.g. PA, mics, backline', placeholderKey: 'becomeRolesUi.musician.techRiderPlaceholder' },
],
},
},
@ -165,13 +189,18 @@ export const BECOME_ROLES: Record<string, BecomeRole> = {
kind: 'craft',
seoTitle: 'Judge Skate Events - SkateSpots',
seoDescription: 'List yourself as a skate event judge — Game of S.K.A.T.E., best trick jams, contests, am series and world tour stops. Get booked to run fair, credible events.',
seoTitleKey: 'becomeRolesUi.judge.seoTitle',
seoDescriptionKey: 'becomeRolesUi.judge.seoDescription',
config: {
kind: SKATE_JUDGE_KIND,
selfTag: 'skatejudge',
identifier: 'judge-profile',
heading: 'Skate Event Judge',
headingKey: 'becomeRolesUi.judge.heading',
tagline: 'From flat-ground Game of S.K.A.T.E. to the World Destruction Tour — run it fair, run it credible',
taglineKey: 'becomeRolesUi.judge.tagline',
noun: 'judge',
nounKey: 'becomeRolesUi.judge.noun',
accentText: 'text-yellow-400',
accentBg: 'bg-yellow-500',
accentChip: 'bg-yellow-500 text-black border-yellow-500',
@ -179,16 +208,18 @@ export const BECOME_ROLES: Record<string, BecomeRole> = {
icon: Gavel,
rateUnits: pickRateUnits('event', 'day', 'hour'),
rateLabel: 'Day Rate / Fee',
rateLabelKey: 'becomeRolesUi.judge.rateLabel',
portfolioLabel: 'Results archive / profile link',
portfolioLabelKey: 'becomeRolesUi.judge.portfolioLabel',
skillSections: [
{ 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 },
{ title: 'Event Formats', titleKey: 'becomeRolesUi.judge.sectionEventFormats', entries: Object.entries(JUDGE_FORMAT_LABELS), keyMap: JUDGE_FORMAT_KEYS, required: true },
{ title: 'Specialty', titleKey: 'becomeRolesUi.judge.sectionSpecialty', entries: Object.entries(JUDGE_SPECIALTY_LABELS), keyMap: JUDGE_SPECIALTY_KEYS },
{ title: 'Level / Circuit', titleKey: 'becomeRolesUi.judge.sectionLevelCircuit', 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.' },
{ tag: 'travel', label: 'Travel', labelKey: 'labUi.craftFields.travel', placeholder: 'e.g. regional / nationwide / international' },
{ tag: 'certifications', label: 'Certifications', labelKey: 'labUi.craftFields.certifications', placeholder: 'e.g. national judging course' },
{ tag: 'events_judged', label: 'Events Judged', labelKey: 'becomeRolesUi.judge.eventsJudgedLabel', placeholder: 'e.g. 40+ contests, 3 World Tour stops', placeholderKey: 'becomeRolesUi.judge.eventsJudgedPlaceholder', helper: 'Notable events, comps or tours you have officiated.', helperKey: 'becomeRolesUi.judge.eventsJudgedHelper' },
{ tag: 'travel', label: 'Travel', labelKey: 'labUi.craftFields.travel', placeholder: 'e.g. regional / nationwide / international', placeholderKey: 'becomeRolesUi.judge.travelPlaceholder' },
{ tag: 'certifications', label: 'Certifications', labelKey: 'labUi.craftFields.certifications', placeholder: 'e.g. national judging course', placeholderKey: 'becomeRolesUi.judge.certificationsPlaceholder' },
],
},
},
@ -196,13 +227,18 @@ export const BECOME_ROLES: Record<string, BecomeRole> = {
kind: 'craft',
seoTitle: 'Organize Skate Events - SkateSpots',
seoDescription: 'List yourself as a skate event organizer — street jams, best trick, contests, meetups, demos and tour stops. Get discovered and booked to run the session.',
seoTitleKey: 'becomeRolesUi.organizer.seoTitle',
seoDescriptionKey: 'becomeRolesUi.organizer.seoDescription',
config: {
kind: SKATE_ORGANIZER_KIND,
selfTag: 'skateorganizer',
identifier: 'organizer-profile',
heading: 'Event Organizer',
headingKey: 'becomeRolesUi.organizer.heading',
tagline: 'Jams, contests, meetups & demos — put on the session and bring the scene together',
taglineKey: 'becomeRolesUi.organizer.tagline',
noun: 'organizer',
nounKey: 'becomeRolesUi.organizer.noun',
accentText: 'text-teal-400',
accentBg: 'bg-teal-600',
accentChip: 'bg-teal-600 text-white border-teal-600',
@ -210,16 +246,18 @@ export const BECOME_ROLES: Record<string, BecomeRole> = {
icon: Tent,
rateUnits: pickRateUnits('event', 'day', 'project'),
rateLabel: 'Event Fee / Budget',
rateLabelKey: 'becomeRolesUi.organizer.rateLabel',
portfolioLabel: 'Past events / results link',
portfolioLabelKey: 'becomeRolesUi.organizer.portfolioLabel',
skillSections: [
{ 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 },
{ title: 'Event Types', titleKey: 'becomeRolesUi.organizer.sectionEventTypes', entries: Object.entries(ORGANIZER_EVENT_LABELS), keyMap: ORGANIZER_EVENT_KEYS, required: true },
{ title: 'What You Handle', titleKey: 'becomeRolesUi.organizer.sectionWhatYouHandle', entries: Object.entries(ORGANIZER_SERVICE_LABELS), keyMap: ORGANIZER_SERVICE_KEYS },
{ title: 'Scale', titleKey: 'becomeRolesUi.organizer.sectionScale', 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.' },
{ tag: 'insurance', label: 'Insurance', labelKey: 'labUi.craftFields.insurance', placeholder: 'e.g. public liability included' },
{ tag: 'permits', label: 'Permits', labelKey: 'labUi.craftFields.permits', placeholder: 'e.g. council permits handled' },
{ tag: 'events_hosted', label: 'Events Organized', labelKey: 'becomeRolesUi.organizer.eventsHostedLabel', placeholder: 'e.g. 25 jams, 3 city comps', placeholderKey: 'becomeRolesUi.organizer.eventsHostedPlaceholder', helper: 'Notable events, comps or meetups you have run.', helperKey: 'becomeRolesUi.organizer.eventsHostedHelper' },
{ tag: 'insurance', label: 'Insurance', labelKey: 'labUi.craftFields.insurance', placeholder: 'e.g. public liability included', placeholderKey: 'becomeRolesUi.organizer.insurancePlaceholder' },
{ tag: 'permits', label: 'Permits', labelKey: 'labUi.craftFields.permits', placeholder: 'e.g. council permits handled', placeholderKey: 'becomeRolesUi.organizer.permitsPlaceholder' },
],
},
},
@ -227,13 +265,18 @@ export const BECOME_ROLES: Record<string, BecomeRole> = {
kind: 'craft',
seoTitle: 'Become a Tattoo Artist - SkateSpots',
seoDescription: 'List your tattoo work for the skate scene — styles, flash, custom and more.',
seoTitleKey: 'becomeRolesUi.tattoo.seoTitle',
seoDescriptionKey: 'becomeRolesUi.tattoo.seoDescription',
config: {
kind: SKATE_TATTOO_KIND,
selfTag: 'skatetattoo',
identifier: 'tattoo-profile',
heading: 'Tattoo Artist',
headingKey: 'becomeRolesUi.tattoo.heading',
tagline: 'Ink for the scene — flash, custom, fine line & more',
taglineKey: 'becomeRolesUi.tattoo.tagline',
noun: 'tattoo artist',
nounKey: 'becomeRolesUi.tattoo.noun',
accentText: 'text-rose-400',
accentBg: 'bg-rose-600',
accentChip: 'bg-rose-600 text-white border-rose-600',
@ -241,16 +284,18 @@ export const BECOME_ROLES: Record<string, BecomeRole> = {
icon: Brush,
rateUnits: pickRateUnits('hour', 'piece', 'session'),
rateLabel: 'Rate / Hourly',
rateLabelKey: 'becomeRolesUi.tattoo.rateLabel',
portfolioLabel: 'Portfolio link (IG, site)',
portfolioLabelKey: 'becomeRolesUi.tattoo.portfolioLabel',
skillSections: [
{ 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 },
{ title: 'Styles', titleKey: 'becomeRolesUi.tattoo.sectionStyles', entries: Object.entries(TATTOO_STYLE_LABELS), keyMap: TATTOO_STYLE_KEYS, required: true },
{ title: 'Method', titleKey: 'becomeRolesUi.tattoo.sectionMethod', entries: Object.entries(TATTOO_METHOD_LABELS), keyMap: TATTOO_METHOD_KEYS },
],
extraFields: [
{ tag: 'studio', label: 'Studio / Shop', placeholder: 'Studio name (optional)' },
{ tag: 'books_open', label: 'Books Open', type: 'toggle', helper: 'Are you currently taking bookings?' },
{ tag: 'deposit', label: 'Deposit', labelKey: 'labUi.craftFields.deposit', placeholder: 'e.g. 50 EUR non-refundable' },
{ tag: 'minimum', label: 'Minimum', labelKey: 'labUi.craftFields.minimum', placeholder: 'e.g. 80 EUR' },
{ tag: 'studio', label: 'Studio / Shop', labelKey: 'becomeRolesUi.tattoo.studioLabel', placeholder: 'Studio name (optional)', placeholderKey: 'becomeRolesUi.tattoo.studioPlaceholder' },
{ tag: 'books_open', label: 'Books Open', labelKey: 'becomeRolesUi.tattoo.booksOpenLabel', type: 'toggle', helper: 'Are you currently taking bookings?', helperKey: 'becomeRolesUi.tattoo.booksOpenHelper' },
{ tag: 'deposit', label: 'Deposit', labelKey: 'labUi.craftFields.deposit', placeholder: 'e.g. 50 EUR non-refundable', placeholderKey: 'becomeRolesUi.tattoo.depositPlaceholder' },
{ tag: 'minimum', label: 'Minimum', labelKey: 'labUi.craftFields.minimum', placeholder: 'e.g. 80 EUR', placeholderKey: 'becomeRolesUi.tattoo.minimumPlaceholder' },
],
},
},
@ -258,13 +303,18 @@ export const BECOME_ROLES: Record<string, BecomeRole> = {
kind: 'craft',
seoTitle: 'Become a Trainer / Physio - SkateSpots',
seoDescription: 'Help skaters get strong and stay healthy — strength, mobility, injury prevention and recovery.',
seoTitleKey: 'becomeRolesUi.trainer.seoTitle',
seoDescriptionKey: 'becomeRolesUi.trainer.seoDescription',
config: {
kind: SKATE_TRAINER_KIND,
selfTag: 'skatetrainer',
identifier: 'trainer-profile',
heading: 'Trainer / Physio',
headingKey: 'becomeRolesUi.trainer.heading',
tagline: 'Strength, mobility, injury prevention & recovery for skaters',
taglineKey: 'becomeRolesUi.trainer.tagline',
noun: 'trainer',
nounKey: 'becomeRolesUi.trainer.noun',
accentText: 'text-emerald-400',
accentBg: 'bg-emerald-600',
accentChip: 'bg-emerald-600 text-white border-emerald-600',
@ -272,16 +322,18 @@ export const BECOME_ROLES: Record<string, BecomeRole> = {
icon: Dumbbell,
rateUnits: pickRateUnits('session', 'month', 'hour'),
rateLabel: 'Rate / Session',
rateLabelKey: 'becomeRolesUi.trainer.rateLabel',
portfolioLabel: 'Profile / booking link',
portfolioLabelKey: 'becomeRolesUi.trainer.portfolioLabel',
skillSections: [
{ 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 },
{ title: 'Focus', titleKey: 'becomeRolesUi.trainer.sectionFocus', entries: Object.entries(TRAINER_FOCUS_LABELS), keyMap: TRAINER_FOCUS_KEYS, required: true },
{ title: 'Injuries You Work With', titleKey: 'becomeRolesUi.trainer.sectionInjuries', entries: Object.entries(TRAINER_INJURY_LABELS), keyMap: TRAINER_INJURY_KEYS },
{ title: 'Credentials & Setting', titleKey: 'becomeRolesUi.trainer.sectionCredentialsSetting', 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.' },
{ tag: 'remote', label: 'Remote / Online', type: 'toggle', helper: 'Do you offer online coaching or consultations?' },
{ tag: 'modalities', label: 'Modalities', labelKey: 'labUi.craftFields.modalities', placeholder: 'e.g. hands-on, S&C, dry needling' },
{ tag: 'credentials', label: 'Certifications', labelKey: 'labUi.craftFields.certifications', placeholder: 'e.g. BSc Physiotherapy, CSCS…', placeholderKey: 'becomeRolesUi.trainer.credentialsPlaceholder', helper: 'List real qualifications — skaters trust verifiable credentials.', helperKey: 'becomeRolesUi.trainer.credentialsHelper' },
{ tag: 'remote', label: 'Remote / Online', labelKey: 'becomeRolesUi.trainer.remoteLabel', type: 'toggle', helper: 'Do you offer online coaching or consultations?', helperKey: 'becomeRolesUi.trainer.remoteHelper' },
{ tag: 'modalities', label: 'Modalities', labelKey: 'labUi.craftFields.modalities', placeholder: 'e.g. hands-on, S&C, dry needling', placeholderKey: 'becomeRolesUi.trainer.modalitiesPlaceholder' },
],
},
},
@ -289,13 +341,18 @@ export const BECOME_ROLES: Record<string, BecomeRole> = {
kind: 'craft',
seoTitle: 'Become a Barber / Hairstylist - SkateSpots',
seoDescription: 'List your barbershop or chair for the skate scene — cuts, fades, beard trims, colour and more. Get booked by skaters.',
seoTitleKey: 'becomeRolesUi.barber.seoTitle',
seoDescriptionKey: 'becomeRolesUi.barber.seoDescription',
config: {
kind: SKATE_BARBER_KIND,
selfTag: 'skatebarber',
identifier: 'barber-profile',
heading: 'Barber / Hairstylist',
headingKey: 'becomeRolesUi.barber.heading',
tagline: 'Cuts, fades & beard trims for the scene — shop chair or mobile',
taglineKey: 'becomeRolesUi.barber.tagline',
noun: 'barber',
nounKey: 'becomeRolesUi.barber.noun',
accentText: 'text-cyan-400',
accentBg: 'bg-cyan-600',
accentChip: 'bg-cyan-600 text-white border-cyan-600',
@ -303,16 +360,18 @@ export const BECOME_ROLES: Record<string, BecomeRole> = {
icon: Sparkles,
rateUnits: pickRateUnits('cut', 'hour', 'session'),
rateLabel: 'Price / Cut',
rateLabelKey: 'becomeRolesUi.barber.rateLabel',
portfolioLabel: 'Cuts gallery / IG link',
portfolioLabelKey: 'becomeRolesUi.barber.portfolioLabel',
skillSections: [
{ 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 },
{ title: 'What You Offer', titleKey: 'becomeRolesUi.barber.sectionWhatYouOffer', entries: Object.entries(BARBER_SERVICE_LABELS), keyMap: BARBER_SERVICE_KEYS, required: true },
{ title: 'Signature Styles', titleKey: 'becomeRolesUi.barber.sectionSignatureStyles', entries: Object.entries(BARBER_STYLE_LABELS), keyMap: BARBER_STYLE_KEYS },
{ title: 'Setting', titleKey: 'becomeRolesUi.barber.sectionSetting', entries: Object.entries(BARBER_SETTING_LABELS), keyMap: BARBER_SETTING_KEYS },
],
extraFields: [
{ tag: 'studio', label: 'Shop / Studio', placeholder: 'Shop or studio name (optional)' },
{ tag: 'books_open', label: 'Books Open', type: 'toggle', helper: 'Are you currently taking bookings?' },
{ tag: 'duration', label: 'Appointment Length', labelKey: 'labUi.craftFields.duration', placeholder: 'e.g. 30–45 min' },
{ tag: 'studio', label: 'Shop / Studio', labelKey: 'becomeRolesUi.barber.studioLabel', placeholder: 'Shop or studio name (optional)', placeholderKey: 'becomeRolesUi.barber.studioPlaceholder' },
{ tag: 'books_open', label: 'Books Open', labelKey: 'becomeRolesUi.barber.booksOpenLabel', type: 'toggle', helper: 'Are you currently taking bookings?', helperKey: 'becomeRolesUi.barber.booksOpenHelper' },
{ tag: 'duration', label: 'Appointment Length', labelKey: 'labUi.craftFields.duration', placeholder: 'e.g. 30–45 min', placeholderKey: 'becomeRolesUi.barber.durationPlaceholder' },
],
},
},

View file

@ -52,9 +52,13 @@ export interface PlannerCrew {
export interface WeatherLite {
temperature_c?: number;
condition?: string;
/** `weatherUi.condition.*` key for `condition` (UI only). */
conditionKey?: string;
weatherIcon?: string;
precipitation_mm?: number;
skateability_label?: string;
/** `weatherUi.skateability.*` key for `skateability_label` (UI only). */
skateabilityLabelKey?: string;
skateability_score?: number;
}

View file

@ -596,30 +596,30 @@ export type SkaterStance = 'regular' | 'goofy';
export type SkaterStyle = 'street' | 'park' | 'vert' | 'transition' | 'bowl' | 'cruising' | 'downhill' | 'freestyle' | 'ledges' | 'stairs' | 'diy';
export type SkaterLevel = 'grom' | 'intermediate' | 'advanced' | 'ripper';
export const SKATER_STANCES: Record<SkaterStance, { label: string; emoji: string }> = {
regular: { label: 'Regular', emoji: '🛹' },
goofy: { label: 'Goofy', emoji: '🤙' },
export const SKATER_STANCES: Record<SkaterStance, { label: string; labelKey: string; emoji: string }> = {
regular: { label: 'Regular', labelKey: 'skaterLabelsUi.stance.regular', emoji: '🛹' },
goofy: { label: 'Goofy', labelKey: 'skaterLabelsUi.stance.goofy', emoji: '🤙' },
};
export const SKATER_STYLES: Record<SkaterStyle, { label: string; emoji: string }> = {
street: { label: 'Street', emoji: '🛹' },
park: { label: 'Park', emoji: '🏞️' },
vert: { label: 'Vert', emoji: '📐' },
transition: { label: 'Transition', emoji: '🌊' },
bowl: { label: 'Bowl', emoji: '🥣' },
cruising: { label: 'Cruising', emoji: '🛴' },
downhill: { label: 'Downhill', emoji: '⛰️' },
freestyle: { label: 'Freestyle', emoji: '🕺' },
ledges: { label: 'Ledges', emoji: '🧱' },
stairs: { label: 'Stairs', emoji: '🪜' },
diy: { label: 'DIY', emoji: '🔨' },
export const SKATER_STYLES: Record<SkaterStyle, { label: string; labelKey: string; emoji: string }> = {
street: { label: 'Street', labelKey: 'skaterLabelsUi.style.street', emoji: '🛹' },
park: { label: 'Park', labelKey: 'skaterLabelsUi.style.park', emoji: '🏞️' },
vert: { label: 'Vert', labelKey: 'skaterLabelsUi.style.vert', emoji: '📐' },
transition: { label: 'Transition', labelKey: 'skaterLabelsUi.style.transition', emoji: '🌊' },
bowl: { label: 'Bowl', labelKey: 'skaterLabelsUi.style.bowl', emoji: '🥣' },
cruising: { label: 'Cruising', labelKey: 'skaterLabelsUi.style.cruising', emoji: '🛴' },
downhill: { label: 'Downhill', labelKey: 'skaterLabelsUi.style.downhill', emoji: '⛰️' },
freestyle: { label: 'Freestyle', labelKey: 'skaterLabelsUi.style.freestyle', emoji: '🕺' },
ledges: { label: 'Ledges', labelKey: 'skaterLabelsUi.style.ledges', emoji: '🧱' },
stairs: { label: 'Stairs', labelKey: 'skaterLabelsUi.style.stairs', emoji: '🪜' },
diy: { label: 'DIY', labelKey: 'skaterLabelsUi.style.diy', emoji: '🔨' },
};
export const SKATER_LEVELS: Record<SkaterLevel, { label: string; emoji: string; color: string }> = {
grom: { label: 'Grom', emoji: '🌱', color: '#22c55e' },
intermediate: { label: 'Intermediate', emoji: '⚡', color: '#3b82f6' },
advanced: { label: 'Advanced', emoji: '🔥', color: '#f59e0b' },
ripper: { label: 'Ripper', emoji: '👑', color: '#ff0040' },
export const SKATER_LEVELS: Record<SkaterLevel, { label: string; labelKey: string; emoji: string; color: string }> = {
grom: { label: 'Grom', labelKey: 'skaterLabelsUi.level.grom', emoji: '🌱', color: '#22c55e' },
intermediate: { label: 'Intermediate', labelKey: 'skaterLabelsUi.level.intermediate', emoji: '⚡', color: '#3b82f6' },
advanced: { label: 'Advanced', labelKey: 'skaterLabelsUi.level.advanced', emoji: '🔥', color: '#f59e0b' },
ripper: { label: 'Ripper', labelKey: 'skaterLabelsUi.level.ripper', emoji: '👑', color: '#ff0040' },
};
/** The `t` marker tag that identifies a SkateSpots skater profile. */
@ -762,12 +762,12 @@ export interface EventSponsor {
/** Sponsor tier display metadata (ordering + label). */
export const EVENT_SPONSOR_TIERS: Record<
NonNullable<EventSponsor['tier']>,
{ label: string; order: number }
{ label: string; labelKey: string; order: number }
> = {
title: { label: 'Title Sponsor', order: 0 },
gold: { label: 'Gold', order: 1 },
silver: { label: 'Silver', order: 2 },
partner: { label: 'Partner', order: 3 },
title: { label: 'Title Sponsor', labelKey: 'skaterLabelsUi.sponsor.title', order: 0 },
gold: { label: 'Gold', labelKey: 'skaterLabelsUi.sponsor.gold', order: 1 },
silver: { label: 'Silver', labelKey: 'skaterLabelsUi.sponsor.silver', order: 2 },
partner: { label: 'Partner', labelKey: 'skaterLabelsUi.sponsor.partner', order: 3 },
};
export interface FoodSpot {

View file

@ -10,6 +10,7 @@ export interface WeatherData {
feelsLikeFahrenheit: number; // Fahrenheit
weatherCode: number; // WMO Weather Code
weatherDescription: string;
weatherDescriptionKey: string; // `weatherUi.condition.*` i18n key for weatherDescription
weatherIcon: string; // Emoji
precipitation: number; // mm
precipitationProbability: number; // %
@ -27,6 +28,7 @@ export interface WeatherData {
export interface AirQualityData {
aqi: number; // Air Quality Index (US EPA standard)
aqiLabel: string; // Good, Moderate, Unhealthy, etc.
aqiLabelKey: string; // `weatherUi.aqi.*` i18n key for aqiLabel
aqiColor: string; // Color for UI
pm25: number; // PM2.5 concentration
pm10: number; // PM10 concentration
@ -47,44 +49,48 @@ export interface WeatherForecast {
export interface SkateabilityScore {
score: number; // 0-100
label: string; // Perfect, Good, Okay, Poor, Unrideable
labelKey: string; // `weatherUi.skateability.*` i18n key for label
color: string; // green, yellow, orange, red
reasons: string[];
reasonKeys: string[]; // `weatherUi.reason.*` i18n keys, parallel to `reasons`
}
// WMO Weather Codes to emoji and description
const weatherCodes: Record<number, { icon: string; description: string }> = {
0: { icon: '☀️', description: 'Clear sky' },
1: { icon: '🌤️', description: 'Mainly clear' },
2: { icon: '⛅', description: 'Partly cloudy' },
3: { icon: '☁️', description: 'Overcast' },
45: { icon: '🌫️', description: 'Foggy' },
48: { icon: '🌫️', description: 'Depositing rime fog' },
51: { icon: '🌦️', description: 'Light drizzle' },
53: { icon: '🌦️', description: 'Moderate drizzle' },
55: { icon: '🌧️', description: 'Dense drizzle' },
56: { icon: '🌧️', description: 'Light freezing drizzle' },
57: { icon: '🌧️', description: 'Dense freezing drizzle' },
61: { icon: '🌧️', description: 'Slight rain' },
63: { icon: '🌧️', description: 'Moderate rain' },
65: { icon: '🌧️', description: 'Heavy rain' },
66: { icon: '🌧️', description: 'Light freezing rain' },
67: { icon: '🌧️', description: 'Heavy freezing rain' },
71: { icon: '🌨️', description: 'Slight snow' },
73: { icon: '🌨️', description: 'Moderate snow' },
75: { icon: '🌨️', description: 'Heavy snow' },
77: { icon: '🌨️', description: 'Snow grains' },
80: { icon: '🌦️', description: 'Slight rain showers' },
81: { icon: '🌧️', description: 'Moderate rain showers' },
82: { icon: '⛈️', description: 'Violent rain showers' },
85: { icon: '🌨️', description: 'Slight snow showers' },
86: { icon: '🌨️', description: 'Heavy snow showers' },
95: { icon: '⛈️', description: 'Thunderstorm' },
96: { icon: '⛈️', description: 'Thunderstorm with slight hail' },
99: { icon: '⛈️', description: 'Thunderstorm with heavy hail' },
// WMO Weather Codes to emoji and description. The English `description` stays on
// the object for non-UI (model/API) consumers; the UI renders `descriptionKey`
// through `t()` (see src/i18n/legacy/weather.ts → `weatherUi.condition.*`).
const weatherCodes: Record<number, { icon: string; description: string; descriptionKey: string }> = {
0: { icon: '☀️', description: 'Clear sky', descriptionKey: 'weatherUi.condition.clearSky' },
1: { icon: '🌤️', description: 'Mainly clear', descriptionKey: 'weatherUi.condition.mainlyClear' },
2: { icon: '⛅', description: 'Partly cloudy', descriptionKey: 'weatherUi.condition.partlyCloudy' },
3: { icon: '☁️', description: 'Overcast', descriptionKey: 'weatherUi.condition.overcast' },
45: { icon: '🌫️', description: 'Foggy', descriptionKey: 'weatherUi.condition.foggy' },
48: { icon: '🌫️', description: 'Depositing rime fog', descriptionKey: 'weatherUi.condition.depositingRimeFog' },
51: { icon: '🌦️', description: 'Light drizzle', descriptionKey: 'weatherUi.condition.lightDrizzle' },
53: { icon: '🌦️', description: 'Moderate drizzle', descriptionKey: 'weatherUi.condition.moderateDrizzle' },
55: { icon: '🌧️', description: 'Dense drizzle', descriptionKey: 'weatherUi.condition.denseDrizzle' },
56: { icon: '🌧️', description: 'Light freezing drizzle', descriptionKey: 'weatherUi.condition.lightFreezingDrizzle' },
57: { icon: '🌧️', description: 'Dense freezing drizzle', descriptionKey: 'weatherUi.condition.denseFreezingDrizzle' },
61: { icon: '🌧️', description: 'Slight rain', descriptionKey: 'weatherUi.condition.slightRain' },
63: { icon: '🌧️', description: 'Moderate rain', descriptionKey: 'weatherUi.condition.moderateRain' },
65: { icon: '🌧️', description: 'Heavy rain', descriptionKey: 'weatherUi.condition.heavyRain' },
66: { icon: '🌧️', description: 'Light freezing rain', descriptionKey: 'weatherUi.condition.lightFreezingRain' },
67: { icon: '🌧️', description: 'Heavy freezing rain', descriptionKey: 'weatherUi.condition.heavyFreezingRain' },
71: { icon: '🌨️', description: 'Slight snow', descriptionKey: 'weatherUi.condition.slightSnow' },
73: { icon: '🌨️', description: 'Moderate snow', descriptionKey: 'weatherUi.condition.moderateSnow' },
75: { icon: '🌨️', description: 'Heavy snow', descriptionKey: 'weatherUi.condition.heavySnow' },
77: { icon: '🌨️', description: 'Snow grains', descriptionKey: 'weatherUi.condition.snowGrains' },
80: { icon: '🌦️', description: 'Slight rain showers', descriptionKey: 'weatherUi.condition.slightRainShowers' },
81: { icon: '🌧️', description: 'Moderate rain showers', descriptionKey: 'weatherUi.condition.moderateRainShowers' },
82: { icon: '⛈️', description: 'Violent rain showers', descriptionKey: 'weatherUi.condition.violentRainShowers' },
85: { icon: '🌨️', description: 'Slight snow showers', descriptionKey: 'weatherUi.condition.slightSnowShowers' },
86: { icon: '🌨️', description: 'Heavy snow showers', descriptionKey: 'weatherUi.condition.heavySnowShowers' },
95: { icon: '⛈️', description: 'Thunderstorm', descriptionKey: 'weatherUi.condition.thunderstorm' },
96: { icon: '⛈️', description: 'Thunderstorm with slight hail', descriptionKey: 'weatherUi.condition.thunderstormSlightHail' },
99: { icon: '⛈️', description: 'Thunderstorm with heavy hail', descriptionKey: 'weatherUi.condition.thunderstormHeavyHail' },
};
function getWeatherInfo(code: number): { icon: string; description: string } {
return weatherCodes[code] || { icon: '❓', description: 'Unknown' };
function getWeatherInfo(code: number): { icon: string; description: string; descriptionKey: string } {
return weatherCodes[code] || { icon: '❓', description: 'Unknown', descriptionKey: 'weatherUi.condition.unknown' };
}
function getWindDirection(degrees: number): string {
@ -97,13 +103,13 @@ function celsiusToFahrenheit(celsius: number): number {
return Math.round((celsius * 9 / 5) + 32);
}
function getAQIInfo(aqi: number): { label: string; color: string } {
if (aqi <= 50) return { label: 'Good', color: '#00e400' };
if (aqi <= 100) return { label: 'Moderate', color: '#ffff00' };
if (aqi <= 150) return { label: 'Unhealthy for Sensitive Groups', color: '#ff7e00' };
if (aqi <= 200) return { label: 'Unhealthy', color: '#ff0000' };
if (aqi <= 300) return { label: 'Very Unhealthy', color: '#8f3f97' };
return { label: 'Hazardous', color: '#7e0023' };
function getAQIInfo(aqi: number): { label: string; labelKey: string; color: string } {
if (aqi <= 50) return { label: 'Good', labelKey: 'weatherUi.aqi.good', color: '#00e400' };
if (aqi <= 100) return { label: 'Moderate', labelKey: 'weatherUi.aqi.moderate', color: '#ffff00' };
if (aqi <= 150) return { label: 'Unhealthy for Sensitive Groups', labelKey: 'weatherUi.aqi.unhealthySensitive', color: '#ff7e00' };
if (aqi <= 200) return { label: 'Unhealthy', labelKey: 'weatherUi.aqi.unhealthy', color: '#ff0000' };
if (aqi <= 300) return { label: 'Very Unhealthy', labelKey: 'weatherUi.aqi.veryUnhealthy', color: '#8f3f97' };
return { label: 'Hazardous', labelKey: 'weatherUi.aqi.hazardous', color: '#7e0023' };
}
/**
@ -135,6 +141,7 @@ export async function fetchWeather(lat: number, lng: number): Promise<WeatherDat
feelsLikeFahrenheit: celsiusToFahrenheit(current.apparent_temperature),
weatherCode: current.weather_code,
weatherDescription: weatherInfo.description,
weatherDescriptionKey: weatherInfo.descriptionKey,
weatherIcon: weatherInfo.icon,
precipitation: current.precipitation || 0,
precipitationProbability: 0, // Not available in current weather
@ -173,6 +180,7 @@ export async function fetchAirQuality(lat: number, lng: number): Promise<AirQual
return {
aqi,
aqiLabel: aqiInfo.label,
aqiLabelKey: aqiInfo.labelKey,
aqiColor: aqiInfo.color,
pm25: current.pm2_5 || 0,
pm10: current.pm10 || 0,
@ -222,16 +230,23 @@ export function calculateSkateability(
): SkateabilityScore {
let score = 100;
const reasons: string[] = [];
const reasonKeys: string[] = [];
const addReason = (en: string, key: string) => {
reasons.push(en);
reasonKeys.push(key);
};
// Precipitation (dealbreaker)
if (weather.precipitation > 0) {
score = 0;
reasons.push('Currently raining');
addReason('Currently raining', 'weatherUi.reason.currentlyRaining');
return {
score,
label: 'Unrideable',
labelKey: 'weatherUi.skateability.unrideable',
color: '#ff0000',
reasons,
reasonKeys,
};
}
@ -242,72 +257,76 @@ export function calculateSkateability(
if (isRaining || isSnowing || isThunderstorm) {
score = 0;
reasons.push(isSnowing ? 'Snowing' : isThunderstorm ? 'Thunderstorm' : 'Raining');
if (isSnowing) addReason('Snowing', 'weatherUi.reason.snowing');
else if (isThunderstorm) addReason('Thunderstorm', 'weatherUi.reason.thunderstorm');
else addReason('Raining', 'weatherUi.reason.raining');
return {
score,
label: 'Unrideable',
labelKey: 'weatherUi.skateability.unrideable',
color: '#ff0000',
reasons,
reasonKeys,
};
}
// Temperature
if (weather.temperature < 0) {
score -= 50;
reasons.push('Freezing temperature');
addReason('Freezing temperature', 'weatherUi.reason.freezingTemperature');
} else if (weather.temperature < 5) {
score -= 30;
reasons.push('Very cold');
addReason('Very cold', 'weatherUi.reason.veryCold');
} else if (weather.temperature > 38) {
score -= 40;
reasons.push('Extremely hot');
addReason('Extremely hot', 'weatherUi.reason.extremelyHot');
} else if (weather.temperature > 32) {
score -= 20;
reasons.push('Very hot');
addReason('Very hot', 'weatherUi.reason.veryHot');
}
// Wind
if (weather.windSpeed > 40) {
score -= 40;
reasons.push('Very strong wind');
addReason('Very strong wind', 'weatherUi.reason.veryStrongWind');
} else if (weather.windSpeed > 25) {
score -= 25;
reasons.push('Strong wind');
addReason('Strong wind', 'weatherUi.reason.strongWind');
} else if (weather.windSpeed > 15) {
score -= 10;
reasons.push('Moderate wind');
addReason('Moderate wind', 'weatherUi.reason.moderateWind');
}
// Air quality
if (airQuality) {
if (airQuality.aqi > 200) {
score -= 40;
reasons.push('Very unhealthy air');
addReason('Very unhealthy air', 'weatherUi.reason.veryUnhealthyAir');
} else if (airQuality.aqi > 150) {
score -= 25;
reasons.push('Unhealthy air');
addReason('Unhealthy air', 'weatherUi.reason.unhealthyAir');
} else if (airQuality.aqi > 100) {
score -= 10;
reasons.push('Moderate air quality');
addReason('Moderate air quality', 'weatherUi.reason.moderateAirQuality');
}
}
// Humidity (affects grip)
if (weather.humidity > 90) {
score -= 15;
reasons.push('Very humid');
addReason('Very humid', 'weatherUi.reason.veryHumid');
} else if (weather.humidity > 75) {
score -= 5;
reasons.push('Humid');
addReason('Humid', 'weatherUi.reason.humid');
}
// UV Index (safety concern)
if (weather.uvIndex > 10) {
score -= 10;
reasons.push('Extreme UV - wear sunscreen');
addReason('Extreme UV - wear sunscreen', 'weatherUi.reason.extremeUv');
} else if (weather.uvIndex > 7) {
score -= 5;
reasons.push('High UV - sun protection recommended');
addReason('High UV - sun protection recommended', 'weatherUi.reason.highUv');
}
// Ensure score doesn't go below 0
@ -315,33 +334,41 @@ export function calculateSkateability(
// If no negative reasons, add positive feedback
if (reasons.length === 0) {
reasons.push('Perfect skating conditions');
addReason('Perfect skating conditions', 'weatherUi.reason.perfectConditions');
}
let label: string;
let labelKey: string;
let color: string;
if (score >= 85) {
label = 'Perfect';
labelKey = 'weatherUi.skateability.perfect';
color = '#00e400';
} else if (score >= 70) {
label = 'Good';
labelKey = 'weatherUi.skateability.good';
color = '#92d050';
} else if (score >= 50) {
label = 'Okay';
labelKey = 'weatherUi.skateability.okay';
color = '#ffff00';
} else if (score >= 25) {
label = 'Poor';
labelKey = 'weatherUi.skateability.poor';
color = '#ff7e00';
} else {
label = 'Bad';
labelKey = 'weatherUi.skateability.bad';
color = '#ff0000';
}
return {
score,
label,
labelKey,
color,
reasons,
reasonKeys,
};
}

View file

@ -3,6 +3,7 @@ import { useParams } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { CraftProfileForm } from '@/components/CraftProfileForm';
import { getBecomeRole } from '@/lib/becomeRoles';
import { useTranslation } from '@/contexts/language';
import NotFound from '@/pages/NotFound';
/**
@ -48,9 +49,11 @@ function CraftRole({
}: {
role: Extract<ReturnType<typeof getBecomeRole>, { kind: 'craft' }>;
}) {
const { t } = useTranslation();
useSeoMeta({
title: role.seoTitle,
description: role.seoDescription,
title: role.seoTitleKey ? t(role.seoTitleKey) : role.seoTitle,
description: role.seoDescriptionKey ? t(role.seoDescriptionKey) : role.seoDescription,
});
return <CraftProfileForm config={role.config} />;

View file

@ -228,9 +228,9 @@ export default function BecomeSkaterPage() {
);
}
const styleEntries = Object.entries(SKATER_STYLES) as [SkaterStyle, { label: string; emoji: string }][];
const stanceEntries = Object.entries(SKATER_STANCES) as [SkaterStance, { label: string; emoji: string }][];
const levelEntries = Object.entries(SKATER_LEVELS) as [SkaterLevel, { label: string; emoji: string; color: string }][];
const styleEntries = Object.entries(SKATER_STYLES) as [SkaterStyle, { label: string; labelKey: string; emoji: string }][];
const stanceEntries = Object.entries(SKATER_STANCES) as [SkaterStance, { label: string; labelKey: string; emoji: string }][];
const levelEntries = Object.entries(SKATER_LEVELS) as [SkaterLevel, { label: string; labelKey: string; emoji: string; color: string }][];
return (
<>
@ -380,7 +380,7 @@ export default function BecomeSkaterPage() {
<p className="text-xs text-muted-foreground mt-0.5">{t('labUi.skaterForm.pickEverything')}</p>
</div>
<div className="flex flex-wrap gap-2">
{styleEntries.map(([key, { label, emoji }]) => {
{styleEntries.map(([key, { labelKey, emoji }]) => {
const active = styles.includes(key);
return (
<button
@ -394,7 +394,7 @@ export default function BecomeSkaterPage() {
: 'bg-white/5 border-white/10 text-gray-300 hover:border-[#ff0040]/40 hover:text-white',
)}
>
<span>{emoji}</span>{label}
<span>{emoji}</span>{t(labelKey)}
</button>
);
})}
@ -408,7 +408,7 @@ export default function BecomeSkaterPage() {
<div className="space-y-3">
<Label className="text-sm font-bold uppercase tracking-wide text-gray-400">{t('labUi.skaterForm.stance')}</Label>
<div className="flex flex-wrap gap-2">
{stanceEntries.map(([key, { label, emoji }]) => (
{stanceEntries.map(([key, { labelKey, emoji }]) => (
<button
key={key}
type="button"
@ -418,7 +418,7 @@ export default function BecomeSkaterPage() {
stance === key ? 'bg-[#ccff00] border-[#ccff00] text-black' : 'bg-white/5 border-white/10 text-gray-300 hover:border-[#ccff00]/40',
)}
>
<span>{emoji}</span>{label}
<span>{emoji}</span>{t(labelKey)}
</button>
))}
</div>
@ -427,7 +427,7 @@ export default function BecomeSkaterPage() {
<div className="space-y-3">
<Label className="text-sm font-bold uppercase tracking-wide text-gray-400">{t('labUi.skaterForm.level')}</Label>
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2">
{levelEntries.map(([key, { label, emoji, color }]) => {
{levelEntries.map(([key, { labelKey, emoji, color }]) => {
const active = levelMode === 'preset' && level === key;
return (
<button
@ -444,7 +444,7 @@ export default function BecomeSkaterPage() {
style={active ? { backgroundColor: color, borderColor: color } : undefined}
>
<div className="text-lg leading-none mb-0.5">{emoji}</div>
{label}
{t(labelKey)}
</button>
);
})}

View file

@ -141,8 +141,8 @@ export default function CoachDetailPage() {
const isBarber = profile?.profileType === 'barber';
useSeoMeta({
title: profile ? `${profile.title} - SkateSpots` : 'Profile - SkateSpots',
description: profile?.bio || 'View profile.',
title: profile ? `${profile.title} - SkateSpots` : t('legacyMiscUi.seoProfileTitle'),
description: profile?.bio || t('legacyMiscUi.seoViewProfile'),
});
const metadata = author.data?.metadata;

View file

@ -1207,9 +1207,9 @@ export default function CreateEventPage() {
onChange={(e) => updateSponsor(i, { tier: e.target.value as EventSponsor['tier'] })}
className="h-9 rounded-md border border-white/20 bg-black/50 px-2 text-sm text-white"
>
{Object.entries(EVENT_SPONSOR_TIERS).map(([key, { label }]) => (
{Object.entries(EVENT_SPONSOR_TIERS).map(([key, { labelKey }]) => (
<option key={key} value={key}>
{label}
{t(labelKey)}
</option>
))}
</select>

View file

@ -645,7 +645,7 @@ export default function Index() {
<SponsorSlot
size="banner"
sponsors={platformSponsors}
title="Platform Partners"
title={t('legacyMiscUi.sponsorPlatformPartners')}
/>
<Footer />
</>

View file

@ -220,7 +220,7 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
{/* Banner */}
<div className="relative h-32 sm:h-48 rounded-xl overflow-hidden bg-gradient-to-br from-[#ff0040]/30 to-[#ccff00]/20 mb-4">
{banner && (
<ImageWithFallback src={banner} alt="Banner" className="w-full h-full object-cover" fallback={null} />
<ImageWithFallback src={banner} alt={t('legacyMiscUi.bannerAlt')} className="w-full h-full object-cover" fallback={null} />
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent" />
</div>
@ -419,7 +419,7 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
className="text-[10px] font-black uppercase tracking-wide px-2.5 py-1 rounded-full text-black"
style={{ backgroundColor: levelMeta.color }}
>
{levelMeta.emoji} {levelMeta.label}
{levelMeta.emoji} {t(levelMeta.labelKey)}
</span>
) : skater?.customLevel ? (
<span className="text-[10px] font-black uppercase tracking-wide px-2.5 py-1 rounded-full bg-[#ccff00] text-black">
@ -444,7 +444,7 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
<span className="text-lg leading-none">{stanceMeta.emoji}</span>
<div className="leading-tight">
<p className="text-[10px] uppercase tracking-wide text-gray-500 font-bold">{t('walletUi.profile.stance')}</p>
<p className="text-white font-bold">{stanceMeta.label}</p>
<p className="text-white font-bold">{t(stanceMeta.labelKey)}</p>
</div>
</div>
)}
@ -497,7 +497,7 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
key={s}
className="text-[11px] font-bold px-2.5 py-1 rounded-full bg-white/8 text-gray-200 border border-white/10"
>
{SKATER_STYLES[s].emoji} {SKATER_STYLES[s].label}
{SKATER_STYLES[s].emoji} {t(SKATER_STYLES[s].labelKey)}
</span>
))}
</div>
@ -817,7 +817,7 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
target="_blank"
rel="noopener noreferrer"
className="flex-shrink-0 text-gray-500 transition-colors hover:text-teal-300"
title="Open in Cordn"
title={t('legacyMiscUi.openInCordn')}
>
<ExternalLink className="h-3.5 w-3.5" />
</a>

View file

@ -46,9 +46,9 @@ export default function PublicCardPage() {
const { memberships } = useUserCrews(pubkey ?? undefined);
const { data: skaterProfile } = useUserSkaterProfile(pubkey ?? undefined);
const derivedStyle = (skaterProfile?.styles ?? []).map((s) => SKATER_STYLES[s]?.label ?? s).join(' · ');
const derivedStyle = (skaterProfile?.styles ?? []).map((s) => (SKATER_STYLES[s] ? t(SKATER_STYLES[s].labelKey) : s)).join(' · ');
const derivedLevel = skaterProfile?.level
? (SKATER_LEVELS[skaterProfile.level]?.label ?? '')
? (SKATER_LEVELS[skaterProfile.level] ? t(SKATER_LEVELS[skaterProfile.level].labelKey) : '')
: (skaterProfile?.customLevel ?? '');
const [side, setSide] = useState<'front' | 'back'>('front');

View file

@ -186,11 +186,11 @@ export default function RiderCardPage() {
const local = skaterProfile?.location || skaterProfile?.homeSpot || '';
// Style + level are DERIVED from the Skater profile — no manual typing.
const derivedStyle = useMemo(
() => (skaterProfile?.styles ?? []).map(s => SKATER_STYLES[s]?.label ?? s).join(' · '),
[skaterProfile],
() => (skaterProfile?.styles ?? []).map(s => (SKATER_STYLES[s] ? t(SKATER_STYLES[s].labelKey) : s)).join(' · '),
[skaterProfile, t],
);
const derivedLevel = skaterProfile?.level
? (SKATER_LEVELS[skaterProfile.level]?.label ?? '')
? (SKATER_LEVELS[skaterProfile.level] ? t(SKATER_LEVELS[skaterProfile.level].labelKey) : '')
: (skaterProfile?.customLevel ?? '');
// "Finish your card" checklist — a card is only as good as what's on it.

View file

@ -56,7 +56,7 @@ function SkaterCard({ skater, verified }: { skater: Skater; verified?: boolean }
className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 text-[9px] font-black uppercase tracking-wide px-1.5 py-0.5 rounded-full text-black whitespace-nowrap"
style={{ backgroundColor: level.color }}
>
{level.emoji} {level.label}
{level.emoji} {t(level.labelKey)}
</span>
) : skater.customLevel ? (
<span className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 text-[9px] font-black uppercase tracking-wide px-1.5 py-0.5 rounded-full text-black whitespace-nowrap bg-[#ccff00] max-w-[90px] truncate">
@ -82,7 +82,7 @@ function SkaterCard({ skater, verified }: { skater: Skater; verified?: boolean }
)}
{skater.stance && (
<p className="text-[11px] text-gray-500 mt-0.5">
{SKATER_STANCES[skater.stance].emoji} {SKATER_STANCES[skater.stance].label}
{SKATER_STANCES[skater.stance].emoji} {t(SKATER_STANCES[skater.stance].labelKey)}
{skater.yearsSkating ? ` · ${skater.yearsSkating}y` : ''}
</p>
)}
@ -94,7 +94,7 @@ function SkaterCard({ skater, verified }: { skater: Skater; verified?: boolean }
<div className="flex flex-wrap gap-1 mt-3">
{skater.styles.slice(0, 4).map((s) => (
<span key={s} className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-white/8 text-gray-300 border border-white/10">
{SKATER_STYLES[s].emoji} {SKATER_STYLES[s].label}
{SKATER_STYLES[s].emoji} {t(SKATER_STYLES[s].labelKey)}
</span>
))}
{skater.styles.length > 4 && (
@ -292,7 +292,7 @@ export default function SkatersPage() {
styleFilter === s ? 'bg-[#ff0040] border-[#ff0040] text-white' : 'bg-white/5 border-white/10 text-gray-400 hover:text-white hover:border-[#ff0040]/40',
)}
>
{SKATER_STYLES[s].emoji} {SKATER_STYLES[s].label}
{SKATER_STYLES[s].emoji} {t(SKATER_STYLES[s].labelKey)}
</button>
))}
</div>

View file

@ -1030,7 +1030,7 @@ export default function SpotDetailPage() {
<span className="text-5xl">{weatherData.weather.weatherIcon}</span>
<div>
<div className="text-3xl font-bold">{weatherData.weather.temperature}°C</div>
<div className="text-sm text-muted-foreground">{weatherData.weather.weatherDescription}</div>
<div className="text-sm text-muted-foreground">{t(weatherData.weather.weatherDescriptionKey)}</div>
</div>
</div>
</div>
@ -1053,10 +1053,10 @@ export default function SpotDetailPage() {
</span>
</div>
<div className="text-sm font-bold uppercase" style={{ color: weatherData.skateability.color }}>
{weatherData.skateability.label}
{t(weatherData.skateability.labelKey)}
</div>
<div className="mt-2 text-xs text-muted-foreground">
{weatherData.skateability.reasons.join(' • ')}
{weatherData.skateability.reasonKeys.map((key) => t(key)).join(' • ')}
</div>
</div>
) : (
@ -1095,7 +1095,7 @@ export default function SpotDetailPage() {
<Cloud className="h-4 w-4" style={{ color: weatherData.airQuality.aqiColor }} />
<div>
<div className="text-xs text-muted-foreground">{t('spotUi.airQuality')}</div>
<div className="font-medium">{weatherData.airQuality.aqiLabel}</div>
<div className="font-medium">{t(weatherData.airQuality.aqiLabelKey)}</div>
</div>
</div>
)}