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.loginRequired')}

-

{t('labUi.become.loginRequiredDescCraft', { noun })}

+

{t('labUi.become.loginRequiredDescCraft', { noun: nounText })}

@@ -385,9 +409,9 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {

- {heading} + {headingText}

-

{tagline}

+

{taglineText}

@@ -415,7 +439,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) { {existingProfile && (

- {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 }) {
@@ -539,7 +563,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) { })}
{attempted && section.required && !section.entries.some(([k]) => selected.includes(k)) && ( -

{t('labUi.craftForm.sectionRequiredDesc', { section: section.title })}

+

{t('labUi.craftForm.sectionRequiredDesc', { section: sectionLabel(section) })}

)}
@@ -554,7 +578,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
- {f.helper &&

{f.helper}

} + {f.helper &&

{f.helperKey ? t(f.helperKey) : f.helper}

}
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 &&

{f.helper}

} + {f.helper &&

{f.helperKey ? t(f.helperKey) : f.helper}

}
) ))} @@ -602,7 +626,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) { {showRate && (
- + setRate(e.target.value)} placeholder={t('labUi.craftForm.ratePlaceholder')} className="mt-1.5 bg-white/5 border-white/10" />
)} @@ -628,7 +652,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) { setSocial(e.target.value)} placeholder={t('labUi.craftForm.socialPlaceholder')} className="bg-white/5 border-white/10" />
- setPortfolio(e.target.value)} placeholder={portfolioLabel} inputMode="url" className={cn('bg-white/5 border-white/10', portfolioError && 'border-red-500')} /> + setPortfolio(e.target.value)} placeholder={portfolioLabelText} inputMode="url" className={cn('bg-white/5 border-white/10', portfolioError && 'border-red-500')} /> {portfolioError &&

{portfolioError}

}
@@ -688,8 +712,8 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) { = { + 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('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) => 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). */}
- Currency + {t('deckCostUi.currency')}
{!rates && ( -

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 */}
- Start from + {t('deckCostUi.startFrom')} {SHEET_TEMPLATES.map((tpl) => ( ))}
@@ -1270,43 +1282,43 @@ export function DeckCostCalculator() { )} {/* Labor */} - +
- Loaded rate + {t('deckCostUi.loadedRate')}
{fmt(s.hourlyRate * s.overheadFactor)}/hr
-

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 */} - +
- + {s.equipment.map((e) => ( - - - - + + + + @@ -1314,28 +1326,28 @@ export function DeckCostCalculator() {
EquipmentCost ({unit})Setup (h)Lifetime boardsPer 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)}

{/* Operations */} - +
- - - - + + + +
-

Per board: {fmt(r.operations)}

+

{t('deckCostUi.perBoard')} {fmt(r.operations)}

{/* Materials */} - +
- + {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 ( - - - - - - + + + + + + @@ -1361,17 +1373,17 @@ export function DeckCostCalculator() {
MaterialOrder ({unit})Ship ({unit})Boards / orderOrder (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)}

{/* Processes */} - +
- {advanced && }{advanced && } + {advanced && }{advanced && } {s.processes.map((p) => { const rate = p.rate > 0 ? p.rate : r.laborPerHr; @@ -1380,15 +1392,15 @@ export function DeckCostCalculator() { return ( - - - {advanced && } - {advanced && } - + + + {advanced && } + {advanced && } + @@ -1397,17 +1409,17 @@ export function DeckCostCalculator() {
ProcessTime (m)Boards / runRate/hr (0 = shop)Scrap %Per board
{t('deckCostUi.colProcess')}{t('deckCostUi.colTimeM')}{t('deckCostUi.colBoardsRun')}{t('deckCostUi.colRateHr')}{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 }))}{numInput(p.rate, (n) => up({ rate: n }))}{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 }))}{numInput(p.rate, (n) => up({ rate: n }))}{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 */} - +
- + {s.packaging.map((p) => { const per = p.materialsPerBoard + p.minutesPerBoard * r.laborPerMin; @@ -1415,13 +1427,13 @@ export function DeckCostCalculator() { return ( - - - + + + @@ -1430,104 +1442,104 @@ export function DeckCostCalculator() {
PackagingTime/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)}

{/* Margins + results */}
-

Margins

+

{t('deckCostUi.sectionMargins')}

- - - - - -
Cost / board (no packaging)
{fmt(r.costNoPackUnit)}
-
Cost / board (with packaging)
{fmt(r.costWithPackUnit)}
+ + + + + +
{t('deckCostUi.costNoPackaging')}
{fmt(r.costNoPackUnit)}
+
{t('deckCostUi.costWithPackaging')}
{fmt(r.costWithPackUnit)}
{advanced && (
- - - -
)}

- 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 }) : '—'}

{r.targetDecks > 0 && (

- 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')}

{r.curve.map((c) => (
- {c.qty}{c.qty === 1 ? ' deck' : ' decks'} + {c.qty} {c.qty === 1 ? t('deckCostUi.deckOne') : t('deckCostUi.deckOther')} - {fmt(c.unit)} / deck - order {fmt(c.total)} + {fmt(c.unit)} {t('deckCostUi.perDeckSuffix')} + {t('deckCostUi.orderSuffix', { amount: fmt(c.total) })}
))}
)} -

Does not include shipping to the buyer.

+

{t('deckCostUi.shippingNotIncluded')}

{/* Quote details — your identity on the client PDF (collapsed by default). */}
- Client quote details (for the PDF) + {t('deckCostUi.clientQuoteDetails')}
@@ -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')} > - Fill from profile + {t('deckCostUi.fillFromProfile')}
{/* Payment details & terms */}
- Payment methods + {t('deckCostUi.paymentMethods')}
{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)} ); })}
{quote.logo && } {quote.logo && ( - + )}