diff --git a/src/components/CraftProfileForm.tsx b/src/components/CraftProfileForm.tsx
index 9efd946..a842b97 100644
--- a/src/components/CraftProfileForm.tsx
+++ b/src/components/CraftProfileForm.tsx
@@ -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 }) {
{t('labUi.become.loginRequiredDescCraft', { noun })} {t('labUi.become.loginRequiredDescCraft', { noun: nounText })}{t('labUi.become.loginRequired')}
-
{tagline}
+{taglineText}
- {t('labUi.craftForm.editingNote', { noun })} + {t('labUi.craftForm.editingNote', { noun: nounText })}
)} @@ -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 }) {{t('labUi.craftForm.sectionRequiredDesc', { section: section.title })}
+{t('labUi.craftForm.sectionRequiredDesc', { section: sectionLabel(section) })}
)}{f.helper}
} + {f.helper &&{f.helperKey ? t(f.helperKey) : f.helper}
}{f.helper}
} + {f.helper &&{f.helperKey ? t(f.helperKey) : f.helper}
}{portfolioError}
}Live rates unavailable — switching relabels only.
+{t('deckCostUi.ratesUnavailable')}
)} - {rates && live BTC rates · sats included} + {rates && {t('deckCostUi.liveRates')}} - {fmt(r.costWithPackUnit)} cost · {fmt(r.priceWithPack)} sell{r.qty > 1 ? <> · {fmt(r.orderTotal)} total> : null} + {fmt(r.costWithPackUnit)} {t('deckCostUi.chipCost')} · {fmt(r.priceWithPack)} {t('deckCostUi.chipSell')}{r.qty > 1 ? <> · {fmt(r.orderTotal)} {t('deckCostUi.chipTotal')}> : null} {/* Quick-start templates */}Loaded rate = wage × burden (employer taxes and insurance on labour). Rent and bills go under Operation expenses — don’t count them in both.
+{t('deckCostUi.loadedRateHintBefore')}{t('deckCostUi.sectionOperations')}{t('deckCostUi.loadedRateHintAfter')}
| Equipment | Cost ({unit}) | Setup (h) | Lifetime boards | Per board | |||||
|---|---|---|---|---|---|---|---|---|---|
| {t('deckCostUi.colEquipment')} | {t('deckCostUi.colCostUnit', { unit })} | {t('deckCostUi.colSetupH')} | {t('deckCostUi.colLifetimeBoards')} | {t('deckCostUi.colPerBoard')} | |||||
| patch({ equipment: s.equipment.map((x) => x.id === e.id ? { ...x, name: ev.target.value } : x) })} className="h-8 min-w-36" /> | -{numInput(e.cost, (n) => patch({ equipment: s.equipment.map((x) => x.id === e.id ? { ...x, cost: n } : x) }))} | -{numInput(e.setupHrs, (n) => patch({ equipment: s.equipment.map((x) => x.id === e.id ? { ...x, setupHrs: n } : x) }))} | -{numInput(e.boards, (n) => patch({ equipment: s.equipment.map((x) => x.id === e.id ? { ...x, boards: n } : x) }))} | -{fmt(e.boards > 0 ? (e.cost + e.setupHrs * r.laborPerHr) / e.boards : 0)} | +{numInput(e.cost, (n) => patch({ equipment: s.equipment.map((x) => x.id === e.id ? { ...x, cost: n } : x) }))} | +{numInput(e.setupHrs, (n) => patch({ equipment: s.equipment.map((x) => x.id === e.id ? { ...x, setupHrs: n } : x) }))} | +{numInput(e.boards, (n) => patch({ equipment: s.equipment.map((x) => x.id === e.id ? { ...x, boards: n } : x) }))} | +{fmt(e.boards > 0 ? (e.cost + e.setupHrs * r.laborPerHr) / e.boards : 0)} |
-
-
+
+
|
Equipment per board: {fmt(r.equipment)}
+{t('deckCostUi.equipmentPerBoard')} {fmt(r.equipment)}
Per board: {fmt(r.operations)}
+{t('deckCostUi.perBoard')} {fmt(r.operations)}
| Material | Order ({unit}) | Ship ({unit}) | Boards / order | Order (m) | Unload (m) | Per board | |||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| {t('deckCostUi.colMaterial')} | {t('deckCostUi.colOrderUnit', { unit })} | {t('deckCostUi.colShipUnit', { unit })} | {t('deckCostUi.colBoardsOrder')} | {t('deckCostUi.colOrderM')} | {t('deckCostUi.colUnloadM')} | {t('deckCostUi.colPerBoard')} | |||||||
| up({ name: e.target.value })} className="h-8 min-w-36" /> | -{numInput(m.orderCost, (n) => up({ orderCost: n }))} | -{numInput(m.shipping, (n) => up({ shipping: n }))} | -{numInput(m.boards, (n) => up({ boards: n }))} | -{numInput(m.orderMin, (n) => up({ orderMin: n }))} | -{numInput(m.unloadMin, (n) => up({ unloadMin: n }))} | -{fmt(per)} | +{numInput(m.orderCost, (n) => up({ orderCost: n }))} | +{numInput(m.shipping, (n) => up({ shipping: n }))} | +{numInput(m.boards, (n) => up({ boards: n }))} | +{numInput(m.orderMin, (n) => up({ orderMin: n }))} | +{numInput(m.unloadMin, (n) => up({ unloadMin: n }))} | +{fmt(per)} |
-
-
+
+
|
Materials per board: {fmt(r.materials)}
+{t('deckCostUi.materialsPerBoard')} {fmt(r.materials)}
| Process | Time (m) | Boards / run | {advanced &&Rate/hr (0 = shop) | }{advanced &&Scrap % | }Per board | ||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
| {t('deckCostUi.colProcess')} | {t('deckCostUi.colTimeM')} | {t('deckCostUi.colBoardsRun')} | {advanced &&{t('deckCostUi.colRateHr')} | }{advanced &&{t('deckCostUi.colScrapPct')} | }{t('deckCostUi.colPerBoard')} | ||||||
| up({ name: e.target.value })} className="h-8 min-w-36" /> | -{numInput(p.minutes, (n) => up({ minutes: n }))} | -{numInput(p.boards, (n) => up({ boards: n }))} | - {advanced &&{numInput(p.rate, (n) => up({ rate: n }))} | } - {advanced &&{numInput(p.scrapPct, (n) => up({ scrapPct: Math.min(99, n) }))} | } -{fmt(per)} | +{numInput(p.minutes, (n) => up({ minutes: n }))} | +{numInput(p.boards, (n) => up({ boards: n }))} | + {advanced &&{numInput(p.rate, (n) => up({ rate: n }))} | } + {advanced &&{numInput(p.scrapPct, (n) => up({ scrapPct: Math.min(99, n) }))} | } +{fmt(per)} |
-
-
+
+
|
Processes per board: {fmt(r.processes)}
+{t('deckCostUi.processesPerBoard')} {fmt(r.processes)}
| Packaging | Time/board (m) | Materials/board ({unit}) | Per board | ||||
|---|---|---|---|---|---|---|---|
| {t('deckCostUi.colPackaging')} | {t('deckCostUi.colTimeBoardM')} | {t('deckCostUi.colMaterialsBoardUnit', { unit })} | {t('deckCostUi.colPerBoard')} | ||||
| up({ name: e.target.value })} className="h-8 min-w-36" /> | -{numInput(p.minutesPerBoard, (n) => up({ minutesPerBoard: n }))} | -{numInput(p.materialsPerBoard, (n) => up({ materialsPerBoard: n }))} | -{fmt(per)} | +{numInput(p.minutesPerBoard, (n) => up({ minutesPerBoard: n }))} | +{numInput(p.materialsPerBoard, (n) => up({ materialsPerBoard: n }))} | +{fmt(per)} |
-
-
+
+
|
Packaging per board: {fmt(r.packaging)}
+{t('deckCostUi.packagingPerBoard')} {fmt(r.packaging)}
- Markup {s.profitMargin}× ≈ {marginPct}% gross margin · waste {r.effectiveWastePct.toFixed(1)}% is spread over the good boards. + {t('deckCostUi.marginNoteBefore', { markup: s.profitMargin })}{t('deckCostUi.grossMargin', { pct: marginPct })}{t('deckCostUi.marginNoteAfter', { waste: r.effectiveWastePct.toFixed(1) })}
Selling price · no packaging
+{t('deckCostUi.sellingPriceNoPackaging')}
{fmt(r.priceNoPack)}
Selling price · with packaging
+{t('deckCostUi.sellingPriceWithPackaging')}
{fmt(r.priceWithPack)}
Profit / deck
+{t('deckCostUi.profitPerDeck')}
{fmt(r.profitPerUnit)}
Wholesale / deck
+{t('deckCostUi.wholesalePerDeck')}
{fmt(r.wholesaleUnit)}
{r.qty > 1 ? `Order total (${r.qty})` : 'Order total'}
+{r.qty > 1 ? t('deckCostUi.orderTotalQty', { qty: r.qty }) : t('deckCostUi.orderTotal')}
{fmt(r.orderTotal)}
Breakeven
-{r.breakevenDecks > 0 ? `${r.breakevenDecks}/mo` : '—'}
+{t('deckCostUi.breakeven')}
+{r.breakevenDecks > 0 ? t('deckCostUi.breakevenPerMonth', { count: r.breakevenDecks }) : '—'}
- To clear {fmt(s.targetMonthlyProfit)}/month on top of fixed costs, sell {r.targetDecks} decks/month. + {t('deckCostUi.targetDecksHint', { amount: fmt(s.targetMonthlyProfit), decks: r.targetDecks })}
)} {s.taxPct > 0 && ({s.taxInclusive - ? <>Price includes {s.taxPct}% tax: {fmt(r.orderTax)} of the total.> - : <>Plus {s.taxPct}% tax: {fmt(r.orderTax)} → {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) })}>}
)} {advanced && (Cost vs quantity (setup amortised)
+{t('deckCostUi.costVsQuantity')}
Does not include shipping to the buyer.
+{t('deckCostUi.shippingNotIncluded')}
{/* Quote details — your identity on the client PDF (collapsed by default). */}Log in to see the events you're hosting.
+{t('legacyMiscUi.hostingLoginToSee')}
- Run a jam, comp or session. Create an event and skaters can RSVP, get updates, and roll through. + {t('legacyMiscUi.hostingEmptyBody')}
@@ -149,7 +152,7 @@ export function HostingDashboard() { {mine.upcoming.length > 0 && (
- {target.title || '(untitled)'}
+ {target.title || t('legacyMiscUi.reportsUntitled')}
{meta?.label && (
{meta.label}
@@ -109,8 +109,7 @@ function ReportRow({ report }: { report: RecentReport }) {
) : (
- No reports yet. Either everything is fine, or nobody's found the button.
+ {t('legacyMiscUi.reportsNoReports')}
- {title} + {heading}
- {title} + {heading}
{t('labUi.skaterForm.pickEverything')}
{t('walletUi.profile.stance')}
-{stanceMeta.label}
+{t(stanceMeta.labelKey)}
- {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` : ''}
)} @@ -94,7 +94,7 @@ function SkaterCard({ skater, verified }: { skater: Skater; verified?: boolean }