diff --git a/src/components/CameraCapture.tsx b/src/components/CameraCapture.tsx
index 13a9538..9ccdf95 100644
--- a/src/components/CameraCapture.tsx
+++ b/src/components/CameraCapture.tsx
@@ -333,7 +333,7 @@ export function CameraCapture({
{/* Captured preview */}
{capturedImage && (
-
+
)}
{/* Switch camera button */}
diff --git a/src/components/CraftProfileForm.tsx b/src/components/CraftProfileForm.tsx
index a842b97..4ccaade 100644
--- a/src/components/CraftProfileForm.tsx
+++ b/src/components/CraftProfileForm.tsx
@@ -482,7 +482,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
{image ? (
<>
-

+
diff --git a/src/components/DeckCostCalculator.tsx b/src/components/DeckCostCalculator.tsx
index 52a1ffa..ca0b5f8 100644
--- a/src/components/DeckCostCalculator.tsx
+++ b/src/components/DeckCostCalculator.tsx
@@ -733,7 +733,7 @@ export function DeckCostCalculator() {
// ── Header ──
doc.setFillColor(255, 0, 64); doc.rect(M, y - 12, 34, 3, 'F');
- setText(17, true, 15); doc.text('SkateSpots — Deck cost calculator', M, y + 8); y += 26;
+ setText(17, true, 15); doc.text(t('legacyMiscUi.exportTitle'), M, y + 8); y += 26;
setText(9, false, 120);
doc.text(
`${new Date().toLocaleDateString(dateLocaleCode(), { year: 'numeric', month: 'long', day: 'numeric' })} · ${cur === 'sats' ? 'sats (Bitcoin)' : cur === 'BTC' ? 'BTC (Bitcoin)' : cur}`,
@@ -741,52 +741,56 @@ export function DeckCostCalculator() {
);
y += 10;
- section('Labor');
- kv('Hourly rate', fmt(s.hourlyRate));
- kv('Labour burden', `${s.overheadFactor}\u00d7`);
- kv('Loaded rate', `${fmt(s.hourlyRate * s.overheadFactor)}/hr`);
+ section(t('legacyMiscUi.exportSectionLabor'));
+ kv(t('legacyMiscUi.exportKvHourlyRate'), fmt(s.hourlyRate));
+ kv(t('legacyMiscUi.exportKvLabourBurden'), `${s.overheadFactor}\u00d7`);
+ kv(t('legacyMiscUi.exportKvLoadedRate'), `${fmt(s.hourlyRate * s.overheadFactor)}/hr`);
- section('Equipment');
+ section(t('legacyMiscUi.exportSectionEquipment'));
s.equipment.forEach((e) => {
const per = e.boards > 0 ? (e.cost + e.setupHrs * r.laborPerHr) / e.boards : 0;
- item(e.name, fmt(per), `${fmt(e.cost)}${e.setupHrs ? ` + ${e.setupHrs}h setup` : ''} \u00b7 ${e.boards.toLocaleString()} boards`);
+ const setup = e.setupHrs ? ` ${t('legacyMiscUi.exportDetailSetup', { hours: e.setupHrs })}` : '';
+ item(e.name, fmt(per), `${fmt(e.cost)}${setup} \u00b7 ${t('legacyMiscUi.exportDetailBoards', { boards: e.boards.toLocaleString() })}`);
});
- kv('Equipment per board', fmt(r.equipment), true);
+ kv(t('legacyMiscUi.exportKvEquipmentPerBoard'), fmt(r.equipment), true);
- section('Operation expenses');
- kv('Boards / month', String(s.boardsPerMonth));
- kv('Rent / month', fmt(s.rent));
- kv('Utilities / month', fmt(s.utilities));
- kv('Subscriptions / month', fmt(s.subscriptions));
- kv('Per board', fmt(r.operations), true);
+ section(t('legacyMiscUi.exportSectionOperations'));
+ kv(t('legacyMiscUi.exportKvBoardsPerMonth'), String(s.boardsPerMonth));
+ kv(t('legacyMiscUi.exportKvRentPerMonth'), fmt(s.rent));
+ kv(t('legacyMiscUi.exportKvUtilitiesPerMonth'), fmt(s.utilities));
+ kv(t('legacyMiscUi.exportKvSubscriptionsPerMonth'), fmt(s.subscriptions));
+ kv(t('legacyMiscUi.exportKvPerBoard'), fmt(r.operations), true);
- section('Materials');
+ section(t('legacyMiscUi.exportSectionMaterials'));
s.materials.forEach((m) => {
const per = m.boards > 0 ? (m.orderCost + m.shipping + (m.orderMin + m.unloadMin) * r.laborPerMin) / m.boards : 0;
- item(m.name, fmt(per), `${fmt(m.orderCost)}${m.shipping ? ` + ${fmt(m.shipping)} ship` : ''} \u00b7 ${m.boards.toLocaleString()} boards`);
+ const ship = m.shipping ? ` ${t('legacyMiscUi.exportDetailShip', { amount: fmt(m.shipping) })}` : '';
+ item(m.name, fmt(per), `${fmt(m.orderCost)}${ship} \u00b7 ${t('legacyMiscUi.exportDetailBoards', { boards: m.boards.toLocaleString() })}`);
});
- kv('Materials per board', fmt(r.materials), true);
+ kv(t('legacyMiscUi.exportKvMaterialsPerBoard'), fmt(r.materials), true);
- section('Process costs');
+ section(t('legacyMiscUi.exportSectionProcesses'));
s.processes.forEach((p) => {
const rate = p.rate > 0 ? p.rate : r.laborPerHr;
- item(p.name, fmt(p.boards > 0 ? (p.minutes * (rate / 60)) / p.boards : 0), `${p.minutes} min${p.boards > 1 ? ` / ${p.boards} boards` : ''}${p.scrapPct ? ` \u00b7 ${p.scrapPct}% scrap` : ''}`);
+ const perBoards = p.boards > 1 ? ` ${t('legacyMiscUi.exportDetailPerBoards', { boards: p.boards })}` : '';
+ const scrap = p.scrapPct ? ` \u00b7 ${t('legacyMiscUi.exportDetailScrap', { pct: p.scrapPct })}` : '';
+ item(p.name, fmt(p.boards > 0 ? (p.minutes * (rate / 60)) / p.boards : 0), `${t('legacyMiscUi.exportDetailMinutes', { minutes: p.minutes })}${perBoards}${scrap}`);
});
- kv('Processes per board', fmt(r.processes), true);
+ kv(t('legacyMiscUi.exportKvProcessesPerBoard'), fmt(r.processes), true);
- section('Packaging');
- s.packaging.forEach((p) => item(p.name, fmt(p.materialsPerBoard + p.minutesPerBoard * r.laborPerMin), `${p.minutesPerBoard} min \u00b7 ${fmt(p.materialsPerBoard)} materials`));
- kv('Packaging per board', fmt(r.packaging), true);
+ section(t('legacyMiscUi.exportSectionPackaging'));
+ s.packaging.forEach((p) => item(p.name, fmt(p.materialsPerBoard + p.minutesPerBoard * r.laborPerMin), `${t('legacyMiscUi.exportDetailMinutes', { minutes: p.minutesPerBoard })} \u00b7 ${t('legacyMiscUi.exportDetailMaterials', { materials: fmt(p.materialsPerBoard) })}`));
+ kv(t('legacyMiscUi.exportKvPackagingPerBoard'), fmt(r.packaging), true);
// ── Result (kept together) ──
ensure(180);
- section('Result');
- kv('Markup', `${s.profitMargin}\u00d7 (\u2248 ${marginPct}% gross margin)`);
- kv('Waste (effective)', `${r.effectiveWastePct.toFixed(1)}%`);
- kv('Cost / board (no packaging)', fmt(r.costNoPackUnit));
- kv('Cost / board (with packaging)', fmt(r.costWithPackUnit));
- if (r.batchSetup > 0) kv('Batch setup / board', fmt(r.setupPerUnit));
- if (s.taxPct > 0) kv(`Tax (${s.taxPct}%${s.taxInclusive ? ', included' : ''})`, fmt(r.orderTax));
+ section(t('legacyMiscUi.exportSectionResult'));
+ kv(t('legacyMiscUi.exportKvMarkup'), t('legacyMiscUi.exportMarkupValue', { markup: s.profitMargin, pct: marginPct }));
+ kv(t('legacyMiscUi.exportKvWasteEffective'), `${r.effectiveWastePct.toFixed(1)}%`);
+ kv(t('legacyMiscUi.exportKvCostNoPack'), fmt(r.costNoPackUnit));
+ kv(t('legacyMiscUi.exportKvCostWithPack'), fmt(r.costWithPackUnit));
+ if (r.batchSetup > 0) kv(t('legacyMiscUi.exportKvBatchSetupPerBoard'), fmt(r.setupPerUnit));
+ if (s.taxPct > 0) kv(s.taxInclusive ? t('legacyMiscUi.exportKvTaxIncluded', { pct: s.taxPct }) : t('legacyMiscUi.exportKvTax', { pct: s.taxPct }), fmt(r.orderTax));
y += 6;
const priceBox = (label: string, value: string) => {
@@ -796,13 +800,13 @@ export function DeckCostCalculator() {
setText(14, true, 30); doc.text(value, rightX - 10, y + 17, { align: 'right' });
y += 36;
};
- priceBox('Selling price (no packaging)', fmt(r.priceNoPack));
- priceBox('Selling price (with packaging)', fmt(r.priceWithPack));
+ priceBox(t('legacyMiscUi.exportKvSellingNoPack'), fmt(r.priceNoPack));
+ priceBox(t('legacyMiscUi.exportKvSellingWithPack'), fmt(r.priceWithPack));
ensure(30);
y += 4;
- setText(8, false, 135); doc.text('Does not include shipping to the buyer.', M, y); y += 16;
- setText(8, false, 155); doc.text('Generated by SkateSpots \u00b7 skate-spots.org', M, y);
+ setText(8, false, 135); doc.text(t('legacyMiscUi.exportShippingNote'), M, y); y += 16;
+ setText(8, false, 155); doc.text(t('legacyMiscUi.exportGeneratedBy'), M, y);
// ── Page numbers ──
const pages = doc.getNumberOfPages();
@@ -849,35 +853,35 @@ export function DeckCostCalculator() {
doc.setDrawColor(220, 220, 220); doc.setLineWidth(0.7); doc.line(M, y, rightX, y); y += 30;
doc.setFont('helvetica', 'bold'); doc.setFontSize(26); doc.setTextColor(15, 15, 15);
- doc.text('QUOTE', M, y);
+ doc.text(t('legacyMiscUi.exportQuoteTitle'), M, y);
doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(120, 120, 120);
const now = new Date();
doc.text(now.toLocaleDateString(dateLocaleCode(), { year: 'numeric', month: 'long', day: 'numeric' }), rightX, y, { align: 'right' });
- doc.text(`Ref Q-${String(q.number).padStart(4, '0')}`, rightX, y + 12, { align: 'right' });
+ doc.text(t('legacyMiscUi.exportQuoteRef', { ref: `Q-${String(q.number).padStart(4, '0')}` }), rightX, y + 12, { align: 'right' });
y += 34;
const qty = r.qty;
doc.setDrawColor(230, 230, 230); doc.line(M, y, rightX, y); y += 20;
doc.setFont('helvetica', 'bold'); doc.setFontSize(12); doc.setTextColor(20, 20, 20);
- doc.text(`Custom skateboard deck${qty > 1 ? ` \u00d7 ${qty}` : ''}`, M, y);
+ doc.text(qty > 1 ? t('legacyMiscUi.exportQuoteLineItemQty', { qty }) : t('legacyMiscUi.exportQuoteLineItem'), M, y);
doc.text(fmt(r.priceWithPack), rightX, y, { align: 'right' }); y += 16;
doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(120, 120, 120);
- doc.text(qty > 1 ? `Hand-built, finished and packed. ${fmt(r.priceWithPack)} each.` : 'Hand-built, finished and packed. Quantity 1.', M, y); y += 16;
+ doc.text(qty > 1 ? t('legacyMiscUi.exportQuoteLineDescQty', { price: fmt(r.priceWithPack) }) : t('legacyMiscUi.exportQuoteLineDesc'), M, y); y += 16;
doc.setDrawColor(230, 230, 230); doc.line(M, y, rightX, y); y += 24;
if (s.taxPct > 0 && !s.taxInclusive) {
doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(120, 120, 120);
- doc.text(qty > 1 ? `Subtotal (${qty} decks)` : 'Subtotal', M, y);
+ doc.text(qty > 1 ? t('legacyMiscUi.exportQuoteSubtotalQty', { qty }) : t('legacyMiscUi.exportQuoteSubtotal'), M, y);
doc.text(fmt(r.orderSubtotal), rightX, y, { align: 'right' }); y += 13;
- doc.text(`Tax / VAT (${s.taxPct}%)`, M, y);
+ doc.text(t('legacyMiscUi.exportQuoteTax', { pct: s.taxPct }), M, y);
doc.text(fmt(r.orderTax), rightX, y, { align: 'right' }); y += 16;
}
doc.setFont('helvetica', 'bold'); doc.setFontSize(14); doc.setTextColor(20, 20, 20);
- doc.text(qty > 1 ? `Total (${qty} decks)` : 'Total', M, y);
+ doc.text(qty > 1 ? t('legacyMiscUi.exportQuoteTotalQty', { qty }) : t('legacyMiscUi.exportQuoteTotal'), M, y);
doc.text(fmt(r.orderTotal), rightX, y, { align: 'right' }); y += 6;
if (s.taxPct > 0 && s.taxInclusive) {
doc.setFont('helvetica', 'normal'); doc.setFontSize(8); doc.setTextColor(120, 120, 120);
- y += 6; doc.text(`Includes ${s.taxPct}% tax (${fmt(r.orderTax)})`, M, y);
+ y += 6; doc.text(t('legacyMiscUi.exportQuoteIncludesTax', { pct: s.taxPct, amount: fmt(r.orderTax) }), M, y);
}
y += 20;
@@ -886,19 +890,19 @@ export function DeckCostCalculator() {
const deposit = q.depositPct > 0 ? (r.orderTotal * q.depositPct) / 100 : 0;
if (deposit > 0) {
doc.setFont('helvetica', 'bold'); doc.setFontSize(10); doc.setTextColor(20, 20, 20);
- doc.text(`Deposit due now (${q.depositPct}%)`, M, y);
+ doc.text(t('legacyMiscUi.exportQuoteDeposit', { pct: q.depositPct }), M, y);
doc.text(fmt(deposit), rightX, y, { align: 'right' }); y += 13;
doc.setFont('helvetica', 'normal'); doc.setTextColor(120, 120, 120);
- doc.text('Balance on completion', M, y);
+ doc.text(t('legacyMiscUi.exportQuoteBalance'), M, y);
doc.text(fmt(r.orderTotal - deposit), rightX, y, { align: 'right' }); y += 18;
}
const payLines = [
- q.payMethods.length ? `Payment: ${q.payMethods.join(' \u00b7 ')}` : '',
- q.lightning.trim() ? `Lightning: ${q.lightning.trim()}` : '',
- q.paymentDetails.trim() ? `Details: ${q.paymentDetails.trim()}` : '',
- q.paymentTerms.trim() ? `Terms: ${q.paymentTerms.trim()}` : '',
- q.incoterm ? `Delivery: ${q.incoterm}` : '',
+ q.payMethods.length ? t('legacyMiscUi.exportQuotePayment', { value: q.payMethods.join(' \u00b7 ') }) : '',
+ q.lightning.trim() ? t('legacyMiscUi.exportQuoteLightning', { value: q.lightning.trim() }) : '',
+ q.paymentDetails.trim() ? t('legacyMiscUi.exportQuoteDetails', { value: q.paymentDetails.trim() }) : '',
+ q.paymentTerms.trim() ? t('legacyMiscUi.exportQuoteTerms', { value: q.paymentTerms.trim() }) : '',
+ q.incoterm ? t('legacyMiscUi.exportQuoteDelivery', { value: q.incoterm }) : '',
].filter(Boolean);
if (payLines.length) {
doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(90, 90, 90);
@@ -915,8 +919,8 @@ export function DeckCostCalculator() {
ensureRoom(30);
doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(120, 120, 120);
const until = new Date(now.getTime() + Math.max(1, q.validDays) * 86_400_000);
- doc.text(`Does not include shipping. Quote valid until ${until.toLocaleDateString(dateLocaleCode(), { year: 'numeric', month: 'long', day: 'numeric' })}.`, M, y); y += 14;
- doc.text('Generated with SkateSpots \u00b7 skate-spots.org', M, y);
+ doc.text(t('legacyMiscUi.exportQuoteValidUntil', { date: until.toLocaleDateString(dateLocaleCode(), { year: 'numeric', month: 'long', day: 'numeric' }) }), M, y); y += 14;
+ doc.text(t('legacyMiscUi.exportGeneratedWith'), M, y);
doc.save(`deck-quote-${new Date().toISOString().slice(0, 10)}.pdf`);
};
@@ -942,7 +946,7 @@ export function DeckCostCalculator() {
ctx.fillStyle = '#ff0040'; ctx.fillRect(48, 46, 64, 6);
ctx.fillStyle = '#ffffff'; ctx.font = 'bold 34px system-ui, sans-serif';
- ctx.fillText('Deck cost', 48, 110);
+ ctx.fillText(t('legacyMiscUi.exportPngTitle'), 48, 110);
ctx.fillStyle = '#9ca3af'; ctx.font = '16px system-ui, sans-serif';
ctx.fillText(`${new Date().toLocaleDateString(dateLocaleCode(), { year: 'numeric', month: 'long', day: 'numeric' })} · ${currencyLabel(cur)}`, 48, 140);
if (quote.businessName.trim()) {
@@ -956,20 +960,20 @@ export function DeckCostCalculator() {
}
ctx.fillStyle = '#6b7280'; ctx.font = '13px system-ui, sans-serif';
- ctx.fillText('SELLING PRICE · WITH PACKAGING', 48, 206);
+ ctx.fillText(t('legacyMiscUi.exportPngSellingWith'), 48, 206);
ctx.fillStyle = '#ccff00'; ctx.font = 'bold 46px system-ui, sans-serif';
ctx.fillText(fmt(r.priceWithPack), 48, 256);
ctx.fillStyle = '#6b7280'; ctx.font = '13px system-ui, sans-serif';
- ctx.fillText('SELLING PRICE · NO PACKAGING', 540, 206);
+ ctx.fillText(t('legacyMiscUi.exportPngSellingNo'), 540, 206);
ctx.fillStyle = '#e5e7eb'; ctx.font = 'bold 32px system-ui, sans-serif';
ctx.fillText(fmt(r.priceNoPack), 540, 250);
const rows: [string, number][] = [
- ['Equipment', r.equipment],
- ['Operations', r.operations],
- ['Materials', r.materials],
- ['Processes', r.processes],
- ['Packaging', r.packaging],
+ [t('legacyMiscUi.exportSectionEquipment'), r.equipment],
+ [t('legacyMiscUi.exportLabelOperations'), r.operations],
+ [t('legacyMiscUi.exportSectionMaterials'), r.materials],
+ [t('legacyMiscUi.exportLabelProcesses'), r.processes],
+ [t('legacyMiscUi.exportSectionPackaging'), r.packaging],
];
let y = 330;
ctx.font = '15px system-ui, sans-serif';
@@ -980,17 +984,17 @@ export function DeckCostCalculator() {
}
y += 12;
ctx.fillStyle = '#6b7280'; ctx.font = '13px system-ui, sans-serif';
- ctx.fillText('COST / BOARD (WITH PACKAGING)', 48, y);
+ ctx.fillText(t('legacyMiscUi.exportPngCostWith'), 48, y);
ctx.fillStyle = '#ffffff'; ctx.font = 'bold 24px system-ui, sans-serif';
ctx.fillText(fmt(r.costWithPack), 48, y + 32);
if (quote.depositPct > 0) {
ctx.textAlign = 'right';
ctx.fillStyle = '#9ca3af'; ctx.font = '15px system-ui, sans-serif';
- ctx.fillText(`Deposit ${quote.depositPct}%: ${fmt((r.priceWithPack * quote.depositPct) / 100)}`, W - 48, y + 32);
+ ctx.fillText(t('legacyMiscUi.exportPngDeposit', { pct: quote.depositPct, amount: fmt((r.priceWithPack * quote.depositPct) / 100) }), W - 48, y + 32);
ctx.textAlign = 'left';
}
ctx.fillStyle = '#6b7280'; ctx.font = '12px system-ui, sans-serif';
- ctx.fillText('Generated with SkateSpots · skate-spots.org', 48, H - 40);
+ ctx.fillText(t('legacyMiscUi.exportGeneratedWith'), 48, H - 40);
const a = document.createElement('a');
a.href = c.toDataURL('image/png');
@@ -1004,50 +1008,50 @@ export function DeckCostCalculator() {
const wb = XLSX.utils.book_new();
const num = (v: number) => Number(v.toFixed(4));
const summary: (string | number)[][] = [
- ['SkateSpots — Deck cost calculator'],
+ [t('legacyMiscUi.exportTitle')],
[],
- ['Currency', cur],
- ['Date', new Date().toISOString().slice(0, 10)],
+ [t('legacyMiscUi.exportXlsxCurrency'), cur],
+ [t('legacyMiscUi.exportXlsxDate'), new Date().toISOString().slice(0, 10)],
[],
- ['Cost / board (no packaging)', num(r.costNoPack)],
- ['Cost / board (with packaging)', num(r.costWithPack)],
- ['Selling price (no packaging)', num(r.priceNoPack)],
- ['Selling price (with packaging)', num(r.priceWithPack)],
+ [t('legacyMiscUi.exportKvCostNoPack'), num(r.costNoPack)],
+ [t('legacyMiscUi.exportKvCostWithPack'), num(r.costWithPack)],
+ [t('legacyMiscUi.exportKvSellingNoPack'), num(r.priceNoPack)],
+ [t('legacyMiscUi.exportKvSellingWithPack'), num(r.priceWithPack)],
[],
- ['Equipment per board', num(r.equipment)],
- ['Operations per board', num(r.operations)],
- ['Materials per board', num(r.materials)],
- ['Processes per board', num(r.processes)],
- ['Packaging per board', num(r.packaging)],
+ [t('legacyMiscUi.exportKvEquipmentPerBoard'), num(r.equipment)],
+ [t('legacyMiscUi.exportXlsxOperationsPerBoard'), num(r.operations)],
+ [t('legacyMiscUi.exportKvMaterialsPerBoard'), num(r.materials)],
+ [t('legacyMiscUi.exportKvProcessesPerBoard'), num(r.processes)],
+ [t('legacyMiscUi.exportKvPackagingPerBoard'), num(r.packaging)],
];
- XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(summary), 'Summary');
+ XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(summary), t('legacyMiscUi.exportXlsxSummary'));
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([
- ['Equipment', 'Cost', 'Setup (h)', 'Boards', 'Per board'],
+ [t('legacyMiscUi.exportSectionEquipment'), t('legacyMiscUi.exportColCost'), t('legacyMiscUi.exportColSetupH'), t('legacyMiscUi.exportColBoards'), t('legacyMiscUi.exportColPerBoard')],
...s.equipment.map((e) => [e.name, e.cost, e.setupHrs, e.boards, num(e.boards > 0 ? (e.cost + e.setupHrs * r.laborPerHr) / e.boards : 0)]),
- ]), 'Equipment');
+ ]), t('legacyMiscUi.exportSectionEquipment'));
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([
- ['Material', 'Order cost', 'Shipping', 'Boards', 'Order (min)', 'Unload (min)', 'Per board'],
+ [t('legacyMiscUi.exportColMaterial'), t('legacyMiscUi.exportColOrderCost'), t('legacyMiscUi.exportColShipping'), t('legacyMiscUi.exportColBoards'), t('legacyMiscUi.exportColOrderMin'), t('legacyMiscUi.exportColUnloadMin'), t('legacyMiscUi.exportColPerBoard')],
...s.materials.map((m) => [m.name, m.orderCost, m.shipping, m.boards, m.orderMin, m.unloadMin, num(m.boards > 0 ? (m.orderCost + m.shipping + (m.orderMin + m.unloadMin) * r.laborPerMin) / m.boards : 0)]),
- ]), 'Materials');
+ ]), t('legacyMiscUi.exportSectionMaterials'));
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([
- ['Process', 'Time (min)', 'Boards', 'Per board'],
+ [t('legacyMiscUi.exportColProcess'), t('legacyMiscUi.exportColTimeMin'), t('legacyMiscUi.exportColBoards'), t('legacyMiscUi.exportColPerBoard')],
...s.processes.map((p) => [p.name, p.minutes, p.boards, p.rate, p.scrapPct, num(p.boards > 0 ? (p.minutes * ((p.rate > 0 ? p.rate : r.laborPerHr) / 60)) / p.boards : 0)]),
- ]), 'Processes');
+ ]), t('legacyMiscUi.exportLabelProcesses'));
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([
- ['Packaging', 'Time/board (min)', 'Materials/board', 'Per board'],
+ [t('legacyMiscUi.exportSectionPackaging'), t('legacyMiscUi.exportColTimeBoardMin'), t('legacyMiscUi.exportColMaterialsBoard'), t('legacyMiscUi.exportColPerBoard')],
...s.packaging.map((p) => [p.name, p.minutesPerBoard, p.materialsPerBoard, num(p.materialsPerBoard + p.minutesPerBoard * r.laborPerMin)]),
- ]), 'Packaging');
+ ]), t('legacyMiscUi.exportSectionPackaging'));
XLSX.writeFile(wb, `deck-cost-${new Date().toISOString().slice(0, 10)}.xlsx`);
};
const copySummary = async () => {
const text = [
- `Deck cost — ${currencyLabel(cur)}`,
- `Cost / board (with packaging): ${fmt(r.costWithPack)}`,
- `Selling price (no packaging): ${fmt(r.priceNoPack)}`,
- `Selling price (with packaging): ${fmt(r.priceWithPack)}`,
+ t('legacyMiscUi.exportCopyTitle', { currency: currencyLabel(cur) }),
+ t('legacyMiscUi.exportCopyCostWith', { amount: fmt(r.costWithPack) }),
+ t('legacyMiscUi.exportCopySellingNo', { amount: fmt(r.priceNoPack) }),
+ t('legacyMiscUi.exportCopySellingWith', { amount: fmt(r.priceWithPack) }),
'',
- `Equipment ${fmt(r.equipment)} · Operations ${fmt(r.operations)} · Materials ${fmt(r.materials)} · Processes ${fmt(r.processes)} · Packaging ${fmt(r.packaging)}`,
+ t('legacyMiscUi.exportCopyBreakdown', { equipment: fmt(r.equipment), operations: fmt(r.operations), materials: fmt(r.materials), processes: fmt(r.processes), packaging: fmt(r.packaging) }),
].join('\n');
try {
await navigator.clipboard.writeText(text);
@@ -1068,48 +1072,48 @@ export function DeckCostCalculator() {
const exportCsv = () => download('deck-cost.csv', toCsv(calcCsvRows(s)), 'text/csv');
const exportMarkdown = () => {
const md = [
- '# Deck cost calculator',
+ `# ${t('legacyMiscUi.exportMarkdownTitle')}`,
'',
`${new Date().toLocaleDateString(dateLocaleCode(), { year: 'numeric', month: 'long', day: 'numeric' })} · ${currencyLabel(cur)}`,
'',
- '## Labor',
- `- Hourly rate: ${fmt(s.hourlyRate)}`,
- `- Labour burden: ${s.overheadFactor}×`,
- `- Loaded rate: ${fmt(s.hourlyRate * s.overheadFactor)}/hr`,
+ `## ${t('legacyMiscUi.exportSectionLabor')}`,
+ `- ${t('legacyMiscUi.exportMdHourlyRate', { value: fmt(s.hourlyRate) })}`,
+ `- ${t('legacyMiscUi.exportMdLabourBurden', { value: `${s.overheadFactor}×` })}`,
+ `- ${t('legacyMiscUi.exportMdLoadedRate', { value: `${fmt(s.hourlyRate * s.overheadFactor)}/hr` })}`,
'',
- '## Equipment',
- ...s.equipment.map((e) => `- ${e.name}: ${fmt(e.boards > 0 ? (e.cost + e.setupHrs * r.laborPerHr) / e.boards : 0)} (${fmt(e.cost)} · ${e.boards.toLocaleString()} boards)`),
- `- **Equipment per board: ${fmt(r.equipment)}**`,
+ `## ${t('legacyMiscUi.exportSectionEquipment')}`,
+ ...s.equipment.map((e) => `- ${e.name}: ${fmt(e.boards > 0 ? (e.cost + e.setupHrs * r.laborPerHr) / e.boards : 0)} (${fmt(e.cost)} · ${t('legacyMiscUi.exportDetailBoards', { boards: e.boards.toLocaleString() })})`),
+ `- **${t('legacyMiscUi.exportKvEquipmentPerBoard')}: ${fmt(r.equipment)}**`,
'',
- '## Operation expenses',
- `- Boards/month: ${s.boardsPerMonth}`,
- `- Rent: ${fmt(s.rent)}`,
- `- Utilities: ${fmt(s.utilities)}`,
- `- Subscriptions: ${fmt(s.subscriptions)}`,
- `- **Per board: ${fmt(r.operations)}**`,
+ `## ${t('legacyMiscUi.exportSectionOperations')}`,
+ `- ${t('legacyMiscUi.exportMdBoardsPerMonth', { value: s.boardsPerMonth })}`,
+ `- ${t('legacyMiscUi.exportMdRent', { value: fmt(s.rent) })}`,
+ `- ${t('legacyMiscUi.exportMdUtilities', { value: fmt(s.utilities) })}`,
+ `- ${t('legacyMiscUi.exportMdSubscriptions', { value: fmt(s.subscriptions) })}`,
+ `- **${t('legacyMiscUi.exportMdPerBoard', { value: fmt(r.operations) })}**`,
'',
- '## Materials',
- ...s.materials.map((m) => `- ${m.name}: ${fmt(m.boards > 0 ? (m.orderCost + m.shipping + (m.orderMin + m.unloadMin) * r.laborPerMin) / m.boards : 0)} (${fmt(m.orderCost)} · ${m.boards.toLocaleString()} boards)`),
- `- **Materials per board: ${fmt(r.materials)}**`,
+ `## ${t('legacyMiscUi.exportSectionMaterials')}`,
+ ...s.materials.map((m) => `- ${m.name}: ${fmt(m.boards > 0 ? (m.orderCost + m.shipping + (m.orderMin + m.unloadMin) * r.laborPerMin) / m.boards : 0)} (${fmt(m.orderCost)} · ${t('legacyMiscUi.exportDetailBoards', { boards: m.boards.toLocaleString() })})`),
+ `- **${t('legacyMiscUi.exportKvMaterialsPerBoard')}: ${fmt(r.materials)}**`,
'',
- '## Process costs',
- ...s.processes.map((p) => `- ${p.name}: ${fmt(p.boards > 0 ? (p.minutes * ((p.rate > 0 ? p.rate : r.laborPerHr) / 60)) / p.boards : 0)} (${p.minutes} min${p.scrapPct ? ` · ${p.scrapPct}% scrap` : ''})`),
- `- **Processes per board: ${fmt(r.processes)}**`,
+ `## ${t('legacyMiscUi.exportSectionProcesses')}`,
+ ...s.processes.map((p) => `- ${p.name}: ${fmt(p.boards > 0 ? (p.minutes * ((p.rate > 0 ? p.rate : r.laborPerHr) / 60)) / p.boards : 0)} (${t('legacyMiscUi.exportDetailMinutes', { minutes: p.minutes })}${p.scrapPct ? ` · ${t('legacyMiscUi.exportDetailScrap', { pct: p.scrapPct })}` : ''})`),
+ `- **${t('legacyMiscUi.exportKvProcessesPerBoard')}: ${fmt(r.processes)}**`,
'',
- '## Packaging',
+ `## ${t('legacyMiscUi.exportSectionPackaging')}`,
...s.packaging.map((p) => `- ${p.name}: ${fmt(p.materialsPerBoard + p.minutesPerBoard * r.laborPerMin)}`),
- `- **Packaging per board: ${fmt(r.packaging)}**`,
+ `- **${t('legacyMiscUi.exportKvPackagingPerBoard')}: ${fmt(r.packaging)}**`,
'',
- '## Result',
- `- Markup: ${s.profitMargin}× (≈ ${marginPct}% gross margin) · Waste: ${r.effectiveWastePct.toFixed(1)}%`,
- `- Cost/board (no packaging): ${fmt(r.costNoPackUnit)}`,
- `- Cost/board (with packaging): ${fmt(r.costWithPackUnit)}`,
- ...(r.batchSetup > 0 ? [`- Batch setup/board: ${fmt(r.setupPerUnit)}`] : []),
- ...(s.taxPct > 0 ? [`- Tax (${s.taxPct}%${s.taxInclusive ? ', included' : ''}): ${fmt(r.orderTax)}`] : []),
- `- **Selling price (no packaging): ${fmt(r.priceNoPack)}**`,
- `- **Selling price (with packaging): ${fmt(r.priceWithPack)}**`,
+ `## ${t('legacyMiscUi.exportSectionResult')}`,
+ `- ${t('legacyMiscUi.exportMdMarkupWaste', { markup: s.profitMargin, pct: marginPct, waste: r.effectiveWastePct.toFixed(1) })}`,
+ `- ${t('legacyMiscUi.exportMdCostNoPack', { value: fmt(r.costNoPackUnit) })}`,
+ `- ${t('legacyMiscUi.exportMdCostWithPack', { value: fmt(r.costWithPackUnit) })}`,
+ ...(r.batchSetup > 0 ? [`- ${t('legacyMiscUi.exportMdBatchSetup', { value: fmt(r.setupPerUnit) })}`] : []),
+ ...(s.taxPct > 0 ? [`- ${s.taxInclusive ? t('legacyMiscUi.exportKvTaxIncluded', { pct: s.taxPct }) : t('legacyMiscUi.exportKvTax', { pct: s.taxPct })}: ${fmt(r.orderTax)}`] : []),
+ `- **${t('legacyMiscUi.exportMdSellingNo', { value: fmt(r.priceNoPack) })}**`,
+ `- **${t('legacyMiscUi.exportMdSellingWith', { value: fmt(r.priceWithPack) })}**`,
'',
- '_Does not include shipping to the buyer. Generated by SkateSpots._',
+ `_${t('legacyMiscUi.exportMdDisclaimer')}_`,
].join('\n');
download('deck-cost.md', md, 'text/markdown');
};
diff --git a/src/components/ImageLightbox.tsx b/src/components/ImageLightbox.tsx
index ed9af86..1402625 100644
--- a/src/components/ImageLightbox.tsx
+++ b/src/components/ImageLightbox.tsx
@@ -8,6 +8,7 @@ import {
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { ImageWithFallback } from '@/components/ImageWithFallback';
+import { useTranslation } from '@/contexts/language';
export interface LightboxImage {
url: string;
@@ -33,6 +34,7 @@ interface ImageLightboxProps {
* so it reuses ImageWithFallback to degrade gracefully on dead URLs.
*/
export function ImageLightbox({ images, index, onIndexChange, onClose }: ImageLightboxProps) {
+ const { t } = useTranslation();
const open = index !== null && index >= 0 && index < images.length;
const current = open ? images[index as number] : undefined;
@@ -91,7 +93,7 @@ export function ImageLightbox({ images, index, onIndexChange, onClose }: ImageLi
type="button"
size="icon"
variant="ghost"
- aria-label="Previous photo"
+ aria-label={t('legacyMiscUi.ariaPrevPhoto')}
onClick={() => go(-1)}
className="absolute left-2 top-1/2 h-11 w-11 -translate-y-1/2 rounded-full bg-black/50 text-white hover:bg-black/80 hover:text-white"
>
@@ -101,7 +103,7 @@ export function ImageLightbox({ images, index, onIndexChange, onClose }: ImageLi
type="button"
size="icon"
variant="ghost"
- aria-label="Next photo"
+ aria-label={t('legacyMiscUi.ariaNextPhoto')}
onClick={() => go(1)}
className="absolute right-2 top-1/2 h-11 w-11 -translate-y-1/2 rounded-full bg-black/50 text-white hover:bg-black/80 hover:text-white"
>
diff --git a/src/components/RiderCard.tsx b/src/components/RiderCard.tsx
index fef5c94..f734893 100644
--- a/src/components/RiderCard.tsx
+++ b/src/components/RiderCard.tsx
@@ -10,6 +10,7 @@ import { useEffect, useState } from 'react';
import { Maximize2 } from 'lucide-react';
import { drawCardToCanvas, type CanvasCardData } from '@/lib/riderCardCanvas';
import type { ThemeId } from '@/lib/riderCard';
+import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils';
export interface BadgeAward {
@@ -90,6 +91,7 @@ function toCanvasData(d: CardData): CanvasCardData {
* a canvas + QR render on every keystroke.
*/
export function CardPreview({ d, className, zoomable = false }: { d: CardData; className?: string; zoomable?: boolean }) {
+ const { t } = useTranslation();
const [src, setSrc] = useState
(null);
const [zoom, setZoom] = useState(false);
@@ -120,7 +122,7 @@ export function CardPreview({ d, className, zoomable = false }: { d: CardData; c
aria-label={zoomable ? 'Zoom card' : undefined}
>
{src
- ?
+ ?
: }
{zoomable && src && (
@@ -136,7 +138,7 @@ export function CardPreview({ d, className, zoomable = false }: { d: CardData; c
role="dialog"
aria-modal="true"
>
-
+
)}
>
diff --git a/src/components/comments/Comment.tsx b/src/components/comments/Comment.tsx
index 5b963f7..1de312d 100644
--- a/src/components/comments/Comment.tsx
+++ b/src/components/comments/Comment.tsx
@@ -15,6 +15,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
import { MessageSquare, ChevronDown, ChevronRight, MoreHorizontal, Flag } from 'lucide-react';
import { formatDistanceToNow } from '@/lib/dateFnsI18n';
import { genUserName } from '@/lib/genUserName';
+import { useTranslation } from '@/contexts/language';
interface CommentProps {
root: NostrEvent | URL;
@@ -25,6 +26,7 @@ interface CommentProps {
}
export function Comment({ root, comment, depth = 0, maxDepth = 3, limit }: CommentProps) {
+ const { t } = useTranslation();
const [showReplyForm, setShowReplyForm] = useState(false);
const [showReplies, setShowReplies] = useState(depth < 2); // Auto-expand first 2 levels
const [showReportDialog, setShowReportDialog] = useState(false);
@@ -109,7 +111,7 @@ export function Comment({ root, comment, depth = 0, maxDepth = 3, limit }: Comme
variant="ghost"
size="sm"
className="h-8 px-2 text-xs"
- aria-label="Comment options"
+ aria-label={t('legacyMiscUi.ariaCommentOptions')}
>