i18n: translate image alt/aria + generated export documents (last English literals)

This commit is contained in:
SkateSpots 2026-10-07 18:30:52 +07:00
parent ac1b56ce76
commit a3b7f311ce
15 changed files with 608 additions and 132 deletions

View file

@ -333,7 +333,7 @@ export function CameraCapture({
{/* Captured preview */} {/* Captured preview */}
{capturedImage && ( {capturedImage && (
<img src={capturedImage} alt="Captured" className="w-full h-full object-cover" /> <img src={capturedImage} alt={t('legacyMiscUi.altCaptured')} className="w-full h-full object-cover" />
)} )}
{/* Switch camera button */} {/* Switch camera button */}

View file

@ -482,7 +482,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
<div className={cn('relative w-20 h-20 rounded-2xl border-2 flex items-center justify-center overflow-hidden flex-shrink-0', image ? 'border-white/20' : 'border-dashed border-white/20')}> <div className={cn('relative w-20 h-20 rounded-2xl border-2 flex items-center justify-center overflow-hidden flex-shrink-0', image ? 'border-white/20' : 'border-dashed border-white/20')}>
{image ? ( {image ? (
<> <>
<img src={image} alt="Profile" className="w-full h-full object-cover" /> <img src={image} alt={t('legacyMiscUi.altProfile')} className="w-full h-full object-cover" />
<button type="button" onClick={() => setImage(null)} className="absolute top-0.5 right-0.5 bg-black/70 rounded-full p-0.5"> <button type="button" onClick={() => setImage(null)} className="absolute top-0.5 right-0.5 bg-black/70 rounded-full p-0.5">
<X className="h-3 w-3 text-white" /> <X className="h-3 w-3 text-white" />
</button> </button>

View file

@ -733,7 +733,7 @@ export function DeckCostCalculator() {
// ── Header ── // ── Header ──
doc.setFillColor(255, 0, 64); doc.rect(M, y - 12, 34, 3, 'F'); 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); setText(9, false, 120);
doc.text( doc.text(
`${new Date().toLocaleDateString(dateLocaleCode(), { year: 'numeric', month: 'long', day: 'numeric' })} · ${cur === 'sats' ? 'sats (Bitcoin)' : cur === 'BTC' ? 'BTC (Bitcoin)' : cur}`, `${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; y += 10;
section('Labor'); section(t('legacyMiscUi.exportSectionLabor'));
kv('Hourly rate', fmt(s.hourlyRate)); kv(t('legacyMiscUi.exportKvHourlyRate'), fmt(s.hourlyRate));
kv('Labour burden', `${s.overheadFactor}\u00d7`); kv(t('legacyMiscUi.exportKvLabourBurden'), `${s.overheadFactor}\u00d7`);
kv('Loaded rate', `${fmt(s.hourlyRate * s.overheadFactor)}/hr`); kv(t('legacyMiscUi.exportKvLoadedRate'), `${fmt(s.hourlyRate * s.overheadFactor)}/hr`);
section('Equipment'); section(t('legacyMiscUi.exportSectionEquipment'));
s.equipment.forEach((e) => { s.equipment.forEach((e) => {
const per = e.boards > 0 ? (e.cost + e.setupHrs * r.laborPerHr) / e.boards : 0; 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'); section(t('legacyMiscUi.exportSectionOperations'));
kv('Boards / month', String(s.boardsPerMonth)); kv(t('legacyMiscUi.exportKvBoardsPerMonth'), String(s.boardsPerMonth));
kv('Rent / month', fmt(s.rent)); kv(t('legacyMiscUi.exportKvRentPerMonth'), fmt(s.rent));
kv('Utilities / month', fmt(s.utilities)); kv(t('legacyMiscUi.exportKvUtilitiesPerMonth'), fmt(s.utilities));
kv('Subscriptions / month', fmt(s.subscriptions)); kv(t('legacyMiscUi.exportKvSubscriptionsPerMonth'), fmt(s.subscriptions));
kv('Per board', fmt(r.operations), true); kv(t('legacyMiscUi.exportKvPerBoard'), fmt(r.operations), true);
section('Materials'); section(t('legacyMiscUi.exportSectionMaterials'));
s.materials.forEach((m) => { s.materials.forEach((m) => {
const per = m.boards > 0 ? (m.orderCost + m.shipping + (m.orderMin + m.unloadMin) * r.laborPerMin) / m.boards : 0; 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) => { s.processes.forEach((p) => {
const rate = p.rate > 0 ? p.rate : r.laborPerHr; 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'); section(t('legacyMiscUi.exportSectionPackaging'));
s.packaging.forEach((p) => item(p.name, fmt(p.materialsPerBoard + p.minutesPerBoard * r.laborPerMin), `${p.minutesPerBoard} min \u00b7 ${fmt(p.materialsPerBoard)} materials`)); 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('Packaging per board', fmt(r.packaging), true); kv(t('legacyMiscUi.exportKvPackagingPerBoard'), fmt(r.packaging), true);
// ── Result (kept together) ── // ── Result (kept together) ──
ensure(180); ensure(180);
section('Result'); section(t('legacyMiscUi.exportSectionResult'));
kv('Markup', `${s.profitMargin}\u00d7 (\u2248 ${marginPct}% gross margin)`); kv(t('legacyMiscUi.exportKvMarkup'), t('legacyMiscUi.exportMarkupValue', { markup: s.profitMargin, pct: marginPct }));
kv('Waste (effective)', `${r.effectiveWastePct.toFixed(1)}%`); kv(t('legacyMiscUi.exportKvWasteEffective'), `${r.effectiveWastePct.toFixed(1)}%`);
kv('Cost / board (no packaging)', fmt(r.costNoPackUnit)); kv(t('legacyMiscUi.exportKvCostNoPack'), fmt(r.costNoPackUnit));
kv('Cost / board (with packaging)', fmt(r.costWithPackUnit)); kv(t('legacyMiscUi.exportKvCostWithPack'), fmt(r.costWithPackUnit));
if (r.batchSetup > 0) kv('Batch setup / board', fmt(r.setupPerUnit)); if (r.batchSetup > 0) kv(t('legacyMiscUi.exportKvBatchSetupPerBoard'), fmt(r.setupPerUnit));
if (s.taxPct > 0) kv(`Tax (${s.taxPct}%${s.taxInclusive ? ', included' : ''})`, fmt(r.orderTax)); if (s.taxPct > 0) kv(s.taxInclusive ? t('legacyMiscUi.exportKvTaxIncluded', { pct: s.taxPct }) : t('legacyMiscUi.exportKvTax', { pct: s.taxPct }), fmt(r.orderTax));
y += 6; y += 6;
const priceBox = (label: string, value: string) => { 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' }); setText(14, true, 30); doc.text(value, rightX - 10, y + 17, { align: 'right' });
y += 36; y += 36;
}; };
priceBox('Selling price (no packaging)', fmt(r.priceNoPack)); priceBox(t('legacyMiscUi.exportKvSellingNoPack'), fmt(r.priceNoPack));
priceBox('Selling price (with packaging)', fmt(r.priceWithPack)); priceBox(t('legacyMiscUi.exportKvSellingWithPack'), fmt(r.priceWithPack));
ensure(30); ensure(30);
y += 4; y += 4;
setText(8, false, 135); doc.text('Does not include shipping to the buyer.', M, y); y += 16; setText(8, false, 135); doc.text(t('legacyMiscUi.exportShippingNote'), M, y); y += 16;
setText(8, false, 155); doc.text('Generated by SkateSpots \u00b7 skate-spots.org', M, y); setText(8, false, 155); doc.text(t('legacyMiscUi.exportGeneratedBy'), M, y);
// ── Page numbers ── // ── Page numbers ──
const pages = doc.getNumberOfPages(); 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.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.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); doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(120, 120, 120);
const now = new Date(); const now = new Date();
doc.text(now.toLocaleDateString(dateLocaleCode(), { year: 'numeric', month: 'long', day: 'numeric' }), rightX, y, { align: 'right' }); 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; y += 34;
const qty = r.qty; const qty = r.qty;
doc.setDrawColor(230, 230, 230); doc.line(M, y, rightX, y); y += 20; 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.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.text(fmt(r.priceWithPack), rightX, y, { align: 'right' }); y += 16;
doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(120, 120, 120); 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; doc.setDrawColor(230, 230, 230); doc.line(M, y, rightX, y); y += 24;
if (s.taxPct > 0 && !s.taxInclusive) { if (s.taxPct > 0 && !s.taxInclusive) {
doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(120, 120, 120); 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(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.text(fmt(r.orderTax), rightX, y, { align: 'right' }); y += 16;
} }
doc.setFont('helvetica', 'bold'); doc.setFontSize(14); doc.setTextColor(20, 20, 20); 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; doc.text(fmt(r.orderTotal), rightX, y, { align: 'right' }); y += 6;
if (s.taxPct > 0 && s.taxInclusive) { if (s.taxPct > 0 && s.taxInclusive) {
doc.setFont('helvetica', 'normal'); doc.setFontSize(8); doc.setTextColor(120, 120, 120); 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; y += 20;
@ -886,19 +890,19 @@ export function DeckCostCalculator() {
const deposit = q.depositPct > 0 ? (r.orderTotal * q.depositPct) / 100 : 0; const deposit = q.depositPct > 0 ? (r.orderTotal * q.depositPct) / 100 : 0;
if (deposit > 0) { if (deposit > 0) {
doc.setFont('helvetica', 'bold'); doc.setFontSize(10); doc.setTextColor(20, 20, 20); 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.text(fmt(deposit), rightX, y, { align: 'right' }); y += 13;
doc.setFont('helvetica', 'normal'); doc.setTextColor(120, 120, 120); 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; doc.text(fmt(r.orderTotal - deposit), rightX, y, { align: 'right' }); y += 18;
} }
const payLines = [ const payLines = [
q.payMethods.length ? `Payment: ${q.payMethods.join(' \u00b7 ')}` : '', q.payMethods.length ? t('legacyMiscUi.exportQuotePayment', { value: q.payMethods.join(' \u00b7 ') }) : '',
q.lightning.trim() ? `Lightning: ${q.lightning.trim()}` : '', q.lightning.trim() ? t('legacyMiscUi.exportQuoteLightning', { value: q.lightning.trim() }) : '',
q.paymentDetails.trim() ? `Details: ${q.paymentDetails.trim()}` : '', q.paymentDetails.trim() ? t('legacyMiscUi.exportQuoteDetails', { value: q.paymentDetails.trim() }) : '',
q.paymentTerms.trim() ? `Terms: ${q.paymentTerms.trim()}` : '', q.paymentTerms.trim() ? t('legacyMiscUi.exportQuoteTerms', { value: q.paymentTerms.trim() }) : '',
q.incoterm ? `Delivery: ${q.incoterm}` : '', q.incoterm ? t('legacyMiscUi.exportQuoteDelivery', { value: q.incoterm }) : '',
].filter(Boolean); ].filter(Boolean);
if (payLines.length) { if (payLines.length) {
doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(90, 90, 90); doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(90, 90, 90);
@ -915,8 +919,8 @@ export function DeckCostCalculator() {
ensureRoom(30); ensureRoom(30);
doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(120, 120, 120); 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); 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(t('legacyMiscUi.exportQuoteValidUntil', { date: 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.exportGeneratedWith'), M, y);
doc.save(`deck-quote-${new Date().toISOString().slice(0, 10)}.pdf`); 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 = '#ff0040'; ctx.fillRect(48, 46, 64, 6);
ctx.fillStyle = '#ffffff'; ctx.font = 'bold 34px system-ui, sans-serif'; 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.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); ctx.fillText(`${new Date().toLocaleDateString(dateLocaleCode(), { year: 'numeric', month: 'long', day: 'numeric' })} · ${currencyLabel(cur)}`, 48, 140);
if (quote.businessName.trim()) { if (quote.businessName.trim()) {
@ -956,20 +960,20 @@ export function DeckCostCalculator() {
} }
ctx.fillStyle = '#6b7280'; ctx.font = '13px system-ui, sans-serif'; 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.fillStyle = '#ccff00'; ctx.font = 'bold 46px system-ui, sans-serif';
ctx.fillText(fmt(r.priceWithPack), 48, 256); ctx.fillText(fmt(r.priceWithPack), 48, 256);
ctx.fillStyle = '#6b7280'; ctx.font = '13px system-ui, sans-serif'; 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.fillStyle = '#e5e7eb'; ctx.font = 'bold 32px system-ui, sans-serif';
ctx.fillText(fmt(r.priceNoPack), 540, 250); ctx.fillText(fmt(r.priceNoPack), 540, 250);
const rows: [string, number][] = [ const rows: [string, number][] = [
['Equipment', r.equipment], [t('legacyMiscUi.exportSectionEquipment'), r.equipment],
['Operations', r.operations], [t('legacyMiscUi.exportLabelOperations'), r.operations],
['Materials', r.materials], [t('legacyMiscUi.exportSectionMaterials'), r.materials],
['Processes', r.processes], [t('legacyMiscUi.exportLabelProcesses'), r.processes],
['Packaging', r.packaging], [t('legacyMiscUi.exportSectionPackaging'), r.packaging],
]; ];
let y = 330; let y = 330;
ctx.font = '15px system-ui, sans-serif'; ctx.font = '15px system-ui, sans-serif';
@ -980,17 +984,17 @@ export function DeckCostCalculator() {
} }
y += 12; y += 12;
ctx.fillStyle = '#6b7280'; ctx.font = '13px system-ui, sans-serif'; 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.fillStyle = '#ffffff'; ctx.font = 'bold 24px system-ui, sans-serif';
ctx.fillText(fmt(r.costWithPack), 48, y + 32); ctx.fillText(fmt(r.costWithPack), 48, y + 32);
if (quote.depositPct > 0) { if (quote.depositPct > 0) {
ctx.textAlign = 'right'; ctx.textAlign = 'right';
ctx.fillStyle = '#9ca3af'; ctx.font = '15px system-ui, sans-serif'; 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.textAlign = 'left';
} }
ctx.fillStyle = '#6b7280'; ctx.font = '12px system-ui, sans-serif'; 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'); const a = document.createElement('a');
a.href = c.toDataURL('image/png'); a.href = c.toDataURL('image/png');
@ -1004,50 +1008,50 @@ export function DeckCostCalculator() {
const wb = XLSX.utils.book_new(); const wb = XLSX.utils.book_new();
const num = (v: number) => Number(v.toFixed(4)); const num = (v: number) => Number(v.toFixed(4));
const summary: (string | number)[][] = [ const summary: (string | number)[][] = [
['SkateSpots — Deck cost calculator'], [t('legacyMiscUi.exportTitle')],
[], [],
['Currency', cur], [t('legacyMiscUi.exportXlsxCurrency'), cur],
['Date', new Date().toISOString().slice(0, 10)], [t('legacyMiscUi.exportXlsxDate'), new Date().toISOString().slice(0, 10)],
[], [],
['Cost / board (no packaging)', num(r.costNoPack)], [t('legacyMiscUi.exportKvCostNoPack'), num(r.costNoPack)],
['Cost / board (with packaging)', num(r.costWithPack)], [t('legacyMiscUi.exportKvCostWithPack'), num(r.costWithPack)],
['Selling price (no packaging)', num(r.priceNoPack)], [t('legacyMiscUi.exportKvSellingNoPack'), num(r.priceNoPack)],
['Selling price (with packaging)', num(r.priceWithPack)], [t('legacyMiscUi.exportKvSellingWithPack'), num(r.priceWithPack)],
[], [],
['Equipment per board', num(r.equipment)], [t('legacyMiscUi.exportKvEquipmentPerBoard'), num(r.equipment)],
['Operations per board', num(r.operations)], [t('legacyMiscUi.exportXlsxOperationsPerBoard'), num(r.operations)],
['Materials per board', num(r.materials)], [t('legacyMiscUi.exportKvMaterialsPerBoard'), num(r.materials)],
['Processes per board', num(r.processes)], [t('legacyMiscUi.exportKvProcessesPerBoard'), num(r.processes)],
['Packaging per board', num(r.packaging)], [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([ 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)]), ...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([ 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)]), ...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([ 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)]), ...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([ 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)]), ...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`); XLSX.writeFile(wb, `deck-cost-${new Date().toISOString().slice(0, 10)}.xlsx`);
}; };
const copySummary = async () => { const copySummary = async () => {
const text = [ const text = [
`Deck cost — ${currencyLabel(cur)}`, t('legacyMiscUi.exportCopyTitle', { currency: currencyLabel(cur) }),
`Cost / board (with packaging): ${fmt(r.costWithPack)}`, t('legacyMiscUi.exportCopyCostWith', { amount: fmt(r.costWithPack) }),
`Selling price (no packaging): ${fmt(r.priceNoPack)}`, t('legacyMiscUi.exportCopySellingNo', { amount: fmt(r.priceNoPack) }),
`Selling price (with packaging): ${fmt(r.priceWithPack)}`, 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'); ].join('\n');
try { try {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
@ -1068,48 +1072,48 @@ export function DeckCostCalculator() {
const exportCsv = () => download('deck-cost.csv', toCsv(calcCsvRows(s)), 'text/csv'); const exportCsv = () => download('deck-cost.csv', toCsv(calcCsvRows(s)), 'text/csv');
const exportMarkdown = () => { const exportMarkdown = () => {
const md = [ const md = [
'# Deck cost calculator', `# ${t('legacyMiscUi.exportMarkdownTitle')}`,
'', '',
`${new Date().toLocaleDateString(dateLocaleCode(), { year: 'numeric', month: 'long', day: 'numeric' })} · ${currencyLabel(cur)}`, `${new Date().toLocaleDateString(dateLocaleCode(), { year: 'numeric', month: 'long', day: 'numeric' })} · ${currencyLabel(cur)}`,
'', '',
'## Labor', `## ${t('legacyMiscUi.exportSectionLabor')}`,
`- Hourly rate: ${fmt(s.hourlyRate)}`, `- ${t('legacyMiscUi.exportMdHourlyRate', { value: fmt(s.hourlyRate) })}`,
`- Labour burden: ${s.overheadFactor}×`, `- ${t('legacyMiscUi.exportMdLabourBurden', { value: `${s.overheadFactor}×` })}`,
`- Loaded rate: ${fmt(s.hourlyRate * s.overheadFactor)}/hr`, `- ${t('legacyMiscUi.exportMdLoadedRate', { value: `${fmt(s.hourlyRate * s.overheadFactor)}/hr` })}`,
'', '',
'## 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)} · ${e.boards.toLocaleString()} boards)`), ...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() })})`),
`- **Equipment per board: ${fmt(r.equipment)}**`, `- **${t('legacyMiscUi.exportKvEquipmentPerBoard')}: ${fmt(r.equipment)}**`,
'', '',
'## Operation expenses', `## ${t('legacyMiscUi.exportSectionOperations')}`,
`- Boards/month: ${s.boardsPerMonth}`, `- ${t('legacyMiscUi.exportMdBoardsPerMonth', { value: s.boardsPerMonth })}`,
`- Rent: ${fmt(s.rent)}`, `- ${t('legacyMiscUi.exportMdRent', { value: fmt(s.rent) })}`,
`- Utilities: ${fmt(s.utilities)}`, `- ${t('legacyMiscUi.exportMdUtilities', { value: fmt(s.utilities) })}`,
`- Subscriptions: ${fmt(s.subscriptions)}`, `- ${t('legacyMiscUi.exportMdSubscriptions', { value: fmt(s.subscriptions) })}`,
`- **Per board: ${fmt(r.operations)}**`, `- **${t('legacyMiscUi.exportMdPerBoard', { value: fmt(r.operations) })}**`,
'', '',
'## 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)} · ${m.boards.toLocaleString()} boards)`), ...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() })})`),
`- **Materials per board: ${fmt(r.materials)}**`, `- **${t('legacyMiscUi.exportKvMaterialsPerBoard')}: ${fmt(r.materials)}**`,
'', '',
'## Process costs', `## ${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)} (${p.minutes} min${p.scrapPct ? ` · ${p.scrapPct}% scrap` : ''})`), ...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 })}` : ''})`),
`- **Processes per board: ${fmt(r.processes)}**`, `- **${t('legacyMiscUi.exportKvProcessesPerBoard')}: ${fmt(r.processes)}**`,
'', '',
'## Packaging', `## ${t('legacyMiscUi.exportSectionPackaging')}`,
...s.packaging.map((p) => `- ${p.name}: ${fmt(p.materialsPerBoard + p.minutesPerBoard * r.laborPerMin)}`), ...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', `## ${t('legacyMiscUi.exportSectionResult')}`,
`- Markup: ${s.profitMargin}× (≈ ${marginPct}% gross margin) · Waste: ${r.effectiveWastePct.toFixed(1)}%`, `- ${t('legacyMiscUi.exportMdMarkupWaste', { markup: s.profitMargin, pct: marginPct, waste: r.effectiveWastePct.toFixed(1) })}`,
`- Cost/board (no packaging): ${fmt(r.costNoPackUnit)}`, `- ${t('legacyMiscUi.exportMdCostNoPack', { value: fmt(r.costNoPackUnit) })}`,
`- Cost/board (with packaging): ${fmt(r.costWithPackUnit)}`, `- ${t('legacyMiscUi.exportMdCostWithPack', { value: fmt(r.costWithPackUnit) })}`,
...(r.batchSetup > 0 ? [`- Batch setup/board: ${fmt(r.setupPerUnit)}`] : []), ...(r.batchSetup > 0 ? [`- ${t('legacyMiscUi.exportMdBatchSetup', { value: fmt(r.setupPerUnit) })}`] : []),
...(s.taxPct > 0 ? [`- Tax (${s.taxPct}%${s.taxInclusive ? ', included' : ''}): ${fmt(r.orderTax)}`] : []), ...(s.taxPct > 0 ? [`- ${s.taxInclusive ? t('legacyMiscUi.exportKvTaxIncluded', { pct: s.taxPct }) : t('legacyMiscUi.exportKvTax', { pct: s.taxPct })}: ${fmt(r.orderTax)}`] : []),
`- **Selling price (no packaging): ${fmt(r.priceNoPack)}**`, `- **${t('legacyMiscUi.exportMdSellingNo', { value: fmt(r.priceNoPack) })}**`,
`- **Selling price (with packaging): ${fmt(r.priceWithPack)}**`, `- **${t('legacyMiscUi.exportMdSellingWith', { value: fmt(r.priceWithPack) })}**`,
'', '',
'_Does not include shipping to the buyer. Generated by SkateSpots._', `_${t('legacyMiscUi.exportMdDisclaimer')}_`,
].join('\n'); ].join('\n');
download('deck-cost.md', md, 'text/markdown'); download('deck-cost.md', md, 'text/markdown');
}; };

View file

@ -8,6 +8,7 @@ import {
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ImageWithFallback } from '@/components/ImageWithFallback'; import { ImageWithFallback } from '@/components/ImageWithFallback';
import { useTranslation } from '@/contexts/language';
export interface LightboxImage { export interface LightboxImage {
url: string; url: string;
@ -33,6 +34,7 @@ interface ImageLightboxProps {
* so it reuses ImageWithFallback to degrade gracefully on dead URLs. * so it reuses ImageWithFallback to degrade gracefully on dead URLs.
*/ */
export function ImageLightbox({ images, index, onIndexChange, onClose }: ImageLightboxProps) { export function ImageLightbox({ images, index, onIndexChange, onClose }: ImageLightboxProps) {
const { t } = useTranslation();
const open = index !== null && index >= 0 && index < images.length; const open = index !== null && index >= 0 && index < images.length;
const current = open ? images[index as number] : undefined; const current = open ? images[index as number] : undefined;
@ -91,7 +93,7 @@ export function ImageLightbox({ images, index, onIndexChange, onClose }: ImageLi
type="button" type="button"
size="icon" size="icon"
variant="ghost" variant="ghost"
aria-label="Previous photo" aria-label={t('legacyMiscUi.ariaPrevPhoto')}
onClick={() => go(-1)} 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" 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" type="button"
size="icon" size="icon"
variant="ghost" variant="ghost"
aria-label="Next photo" aria-label={t('legacyMiscUi.ariaNextPhoto')}
onClick={() => go(1)} 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" 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"
> >

View file

@ -10,6 +10,7 @@ import { useEffect, useState } from 'react';
import { Maximize2 } from 'lucide-react'; import { Maximize2 } from 'lucide-react';
import { drawCardToCanvas, type CanvasCardData } from '@/lib/riderCardCanvas'; import { drawCardToCanvas, type CanvasCardData } from '@/lib/riderCardCanvas';
import type { ThemeId } from '@/lib/riderCard'; import type { ThemeId } from '@/lib/riderCard';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
export interface BadgeAward { export interface BadgeAward {
@ -90,6 +91,7 @@ function toCanvasData(d: CardData): CanvasCardData {
* a canvas + QR render on every keystroke. * a canvas + QR render on every keystroke.
*/ */
export function CardPreview({ d, className, zoomable = false }: { d: CardData; className?: string; zoomable?: boolean }) { export function CardPreview({ d, className, zoomable = false }: { d: CardData; className?: string; zoomable?: boolean }) {
const { t } = useTranslation();
const [src, setSrc] = useState<string | null>(null); const [src, setSrc] = useState<string | null>(null);
const [zoom, setZoom] = useState(false); 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} aria-label={zoomable ? 'Zoom card' : undefined}
> >
{src {src
? <img src={src} alt="Card preview" className="h-full w-full object-cover" style={{ display: 'block' }} /> ? <img src={src} alt={t('legacyMiscUi.altCardPreview')} className="h-full w-full object-cover" style={{ display: 'block' }} />
: <div className="h-full w-full animate-pulse rounded-2xl bg-white/5" />} : <div className="h-full w-full animate-pulse rounded-2xl bg-white/5" />}
{zoomable && src && ( {zoomable && src && (
<span className="absolute bottom-2 right-2 rounded-full bg-black/60 p-1.5 text-white opacity-0 transition-opacity group-hover:opacity-100"> <span className="absolute bottom-2 right-2 rounded-full bg-black/60 p-1.5 text-white opacity-0 transition-opacity group-hover:opacity-100">
@ -136,7 +138,7 @@ export function CardPreview({ d, className, zoomable = false }: { d: CardData; c
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
> >
<img src={src} alt="Card" className="max-h-[92vh] w-auto max-w-[95vw] rounded-2xl shadow-2xl" /> <img src={src} alt={t('legacyMiscUi.altCard')} className="max-h-[92vh] w-auto max-w-[95vw] rounded-2xl shadow-2xl" />
</div> </div>
)} )}
</> </>

View file

@ -15,6 +15,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
import { MessageSquare, ChevronDown, ChevronRight, MoreHorizontal, Flag } from 'lucide-react'; import { MessageSquare, ChevronDown, ChevronRight, MoreHorizontal, Flag } from 'lucide-react';
import { formatDistanceToNow } from '@/lib/dateFnsI18n'; import { formatDistanceToNow } from '@/lib/dateFnsI18n';
import { genUserName } from '@/lib/genUserName'; import { genUserName } from '@/lib/genUserName';
import { useTranslation } from '@/contexts/language';
interface CommentProps { interface CommentProps {
root: NostrEvent | URL; root: NostrEvent | URL;
@ -25,6 +26,7 @@ interface CommentProps {
} }
export function Comment({ root, comment, depth = 0, maxDepth = 3, limit }: CommentProps) { export function Comment({ root, comment, depth = 0, maxDepth = 3, limit }: CommentProps) {
const { t } = useTranslation();
const [showReplyForm, setShowReplyForm] = useState(false); const [showReplyForm, setShowReplyForm] = useState(false);
const [showReplies, setShowReplies] = useState(depth < 2); // Auto-expand first 2 levels const [showReplies, setShowReplies] = useState(depth < 2); // Auto-expand first 2 levels
const [showReportDialog, setShowReportDialog] = useState(false); const [showReportDialog, setShowReportDialog] = useState(false);
@ -109,7 +111,7 @@ export function Comment({ root, comment, depth = 0, maxDepth = 3, limit }: Comme
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-8 px-2 text-xs" className="h-8 px-2 text-xs"
aria-label="Comment options" aria-label={t('legacyMiscUi.ariaCommentOptions')}
> >
<MoreHorizontal className="h-3 w-3" /> <MoreHorizontal className="h-3 w-3" />
</Button> </Button>

View file

@ -72,6 +72,122 @@ const jaBatch20 = {
// Zap errors // Zap errors
zapNoActiveMint: '有効なCashuミントが見つかりません。', zapNoActiveMint: '有効なCashuミントが見つかりません。',
modHelp: '非表示は org.skatespots.moderation / hidden を発行します。確認は … / verified を、元に戻すは … / cleared(中立に戻す)を発行します。3つとも公開・可逆のラベルです — リレーから何かが削除されることはありません。', modHelp: '非表示は org.skatespots.moderation / hidden を発行します。確認は … / verified を、元に戻すは … / cleared(中立に戻す)を発行します。3つとも公開・可逆のラベルです — リレーから何かが削除されることはありません。',
// Image alt text + aria labels
altProfile: 'プロフィール',
altCardPreview: 'カードのプレビュー',
altCard: 'カード',
altCaptured: '撮影済み',
ariaPrevPhoto: '前の写真',
ariaNextPhoto: '次の写真',
ariaCommentOptions: 'コメントのオプション',
ariaMessageConversation: 'メッセージの会話',
// Deck cost calculator — generated export documents
exportTitle: 'SkateSpots — Deck 原価計算',
exportQuoteTitle: '見積書',
exportPngTitle: 'Deck 原価',
exportMarkdownTitle: 'Deck 原価計算',
exportSectionLabor: '人件費',
exportSectionEquipment: '設備',
exportSectionOperations: '運営費',
exportSectionMaterials: '材料',
exportSectionProcesses: '工程費',
exportSectionPackaging: '梱包',
exportSectionResult: '結果',
exportLabelOperations: '運営',
exportLabelProcesses: '工程',
exportKvHourlyRate: '時給',
exportKvLabourBurden: '労務負荷',
exportKvLoadedRate: '込み単価',
exportKvEquipmentPerBoard: '1枚あたりの設備費',
exportKvBoardsPerMonth: 'ボード / 月',
exportKvRentPerMonth: '家賃 / 月',
exportKvUtilitiesPerMonth: '光熱費 / 月',
exportKvSubscriptionsPerMonth: 'サブスク / 月',
exportKvPerBoard: '1枚あたり',
exportKvMaterialsPerBoard: '1枚あたりの材料費',
exportKvProcessesPerBoard: '1枚あたりの工程費',
exportKvPackagingPerBoard: '1枚あたりの梱包費',
exportKvMarkup: 'マークアップ',
exportMarkupValue: '{{markup}}× (≈ 粗利益率 {{pct}}%)',
exportKvWasteEffective: 'ロス (実効)',
exportKvCostNoPack: '原価 / 枚 (梱包なし)',
exportKvCostWithPack: '原価 / 枚 (梱包あり)',
exportKvBatchSetupPerBoard: 'ロット段取り / 枚',
exportKvTax: '税 ({{pct}}%)',
exportKvTaxIncluded: '税 ({{pct}}%、税込)',
exportKvSellingNoPack: '販売価格 (梱包なし)',
exportKvSellingWithPack: '販売価格 (梱包あり)',
exportShippingNote: '買い手への送料は含まれていません。',
exportGeneratedBy: 'SkateSpots が生成 · skate-spots.org',
exportGeneratedWith: 'SkateSpots で生成 · skate-spots.org',
exportDetailSetup: '+ 段取り {{hours}} 時間',
exportDetailBoards: '{{boards}} 枚',
exportDetailShip: '+ 送料 {{amount}}',
exportDetailMinutes: '{{minutes}} 分',
exportDetailPerBoards: '/ {{boards}} 枚',
exportDetailScrap: 'ロス {{pct}}%',
exportDetailMaterials: '材料 {{materials}}',
exportQuoteRef: '整理番号 {{ref}}',
exportQuoteLineItem: 'カスタムスケートボード Deck',
exportQuoteLineItemQty: 'カスタムスケートボード Deck × {{qty}}',
exportQuoteLineDesc: '手作りで仕上げ、梱包済み。数量 1。',
exportQuoteLineDescQty: '手作りで仕上げ、梱包済み。各 {{price}}。',
exportQuoteSubtotal: '小計',
exportQuoteSubtotalQty: '小計 ({{qty}} 枚)',
exportQuoteTax: '税 / VAT ({{pct}}%)',
exportQuoteTotal: '合計',
exportQuoteTotalQty: '合計 ({{qty}} 枚)',
exportQuoteIncludesTax: '税 {{pct}}% を含む ({{amount}})',
exportQuoteDeposit: '今すぐ支払う手付金 ({{pct}}%)',
exportQuoteBalance: '完成時の残金',
exportQuotePayment: '支払い: {{value}}',
exportQuoteLightning: 'Lightning: {{value}}',
exportQuoteDetails: '詳細: {{value}}',
exportQuoteTerms: '条件: {{value}}',
exportQuoteDelivery: '配送: {{value}}',
exportQuoteValidUntil: '送料は含まれていません。見積の有効期限は {{date}} です。',
exportPngSellingWith: '販売価格 · 梱包あり',
exportPngSellingNo: '販売価格 · 梱包なし',
exportPngCostWith: '原価 / 枚 (梱包あり)',
exportPngDeposit: '手付金 {{pct}}%: {{amount}}',
exportXlsxSummary: '概要',
exportXlsxCurrency: '通貨',
exportXlsxDate: '日付',
exportXlsxOperationsPerBoard: '1枚あたりの運営費',
exportColCost: '費用',
exportColSetupH: '段取り (h)',
exportColBoards: '枚数',
exportColPerBoard: '1枚あたり',
exportColMaterial: '材料',
exportColOrderCost: '発注費',
exportColShipping: '送料',
exportColOrderMin: '発注 (分)',
exportColUnloadMin: '荷下ろし (分)',
exportColProcess: '工程',
exportColTimeMin: '時間 (分)',
exportColTimeBoardMin: '時間/枚 (分)',
exportColMaterialsBoard: '材料/枚',
exportCopyTitle: 'Deck 原価 — {{currency}}',
exportCopyCostWith: '原価 / 枚 (梱包あり): {{amount}}',
exportCopySellingNo: '販売価格 (梱包なし): {{amount}}',
exportCopySellingWith: '販売価格 (梱包あり): {{amount}}',
exportCopyBreakdown: '設備 {{equipment}} · 運営 {{operations}} · 材料 {{materials}} · 工程 {{processes}} · 梱包 {{packaging}}',
exportMdHourlyRate: '時給: {{value}}',
exportMdLabourBurden: '労務負荷: {{value}}',
exportMdLoadedRate: '込み単価: {{value}}',
exportMdBoardsPerMonth: 'ボード/月: {{value}}',
exportMdRent: '家賃: {{value}}',
exportMdUtilities: '光熱費: {{value}}',
exportMdSubscriptions: 'サブスク: {{value}}',
exportMdPerBoard: '1枚あたり: {{value}}',
exportMdMarkupWaste: 'マークアップ: {{markup}}× (≈ 粗利益率 {{pct}}%) · ロス: {{waste}}%',
exportMdCostNoPack: '原価/枚 (梱包なし): {{value}}',
exportMdCostWithPack: '原価/枚 (梱包あり): {{value}}',
exportMdBatchSetup: 'ロット段取り/枚: {{value}}',
exportMdSellingNo: '販売価格 (梱包なし): {{value}}',
exportMdSellingWith: '販売価格 (梱包あり): {{value}}',
exportMdDisclaimer: '買い手への送料は含まれていません。SkateSpots が生成。',
}, },
}; };

View file

@ -68,6 +68,123 @@ const legacyMiscUi = {
// Zap errors // Zap errors
zapNoActiveMint: 'No active Cashu mint found.', zapNoActiveMint: 'No active Cashu mint found.',
// Image alt text + aria labels
altProfile: 'Profile',
altCardPreview: 'Card preview',
altCard: 'Card',
altCaptured: 'Captured',
ariaPrevPhoto: 'Previous photo',
ariaNextPhoto: 'Next photo',
ariaCommentOptions: 'Comment options',
ariaMessageConversation: 'Message conversation',
// Deck cost calculator — generated export documents
exportTitle: 'SkateSpots — Deck cost calculator',
exportQuoteTitle: 'QUOTE',
exportPngTitle: 'Deck cost',
exportMarkdownTitle: 'Deck cost calculator',
exportSectionLabor: 'Labor',
exportSectionEquipment: 'Equipment',
exportSectionOperations: 'Operation expenses',
exportSectionMaterials: 'Materials',
exportSectionProcesses: 'Process costs',
exportSectionPackaging: 'Packaging',
exportSectionResult: 'Result',
exportLabelOperations: 'Operations',
exportLabelProcesses: 'Processes',
exportKvHourlyRate: 'Hourly rate',
exportKvLabourBurden: 'Labour burden',
exportKvLoadedRate: 'Loaded rate',
exportKvEquipmentPerBoard: 'Equipment per board',
exportKvBoardsPerMonth: 'Boards / month',
exportKvRentPerMonth: 'Rent / month',
exportKvUtilitiesPerMonth: 'Utilities / month',
exportKvSubscriptionsPerMonth: 'Subscriptions / month',
exportKvPerBoard: 'Per board',
exportKvMaterialsPerBoard: 'Materials per board',
exportKvProcessesPerBoard: 'Processes per board',
exportKvPackagingPerBoard: 'Packaging per board',
exportKvMarkup: 'Markup',
exportMarkupValue: '{{markup}}× (≈ {{pct}}% gross margin)',
exportKvWasteEffective: 'Waste (effective)',
exportKvCostNoPack: 'Cost / board (no packaging)',
exportKvCostWithPack: 'Cost / board (with packaging)',
exportKvBatchSetupPerBoard: 'Batch setup / board',
exportKvTax: 'Tax ({{pct}}%)',
exportKvTaxIncluded: 'Tax ({{pct}}%, included)',
exportKvSellingNoPack: 'Selling price (no packaging)',
exportKvSellingWithPack: 'Selling price (with packaging)',
exportShippingNote: 'Does not include shipping to the buyer.',
exportGeneratedBy: 'Generated by SkateSpots · skate-spots.org',
exportGeneratedWith: 'Generated with SkateSpots · skate-spots.org',
exportDetailSetup: '+ {{hours}}h setup',
exportDetailBoards: '{{boards}} boards',
exportDetailShip: '+ {{amount}} ship',
exportDetailMinutes: '{{minutes}} min',
exportDetailPerBoards: '/ {{boards}} boards',
exportDetailScrap: '{{pct}}% scrap',
exportDetailMaterials: '{{materials}} materials',
exportQuoteRef: 'Ref {{ref}}',
exportQuoteLineItem: 'Custom skateboard deck',
exportQuoteLineItemQty: 'Custom skateboard deck × {{qty}}',
exportQuoteLineDesc: 'Hand-built, finished and packed. Quantity 1.',
exportQuoteLineDescQty: 'Hand-built, finished and packed. {{price}} each.',
exportQuoteSubtotal: 'Subtotal',
exportQuoteSubtotalQty: 'Subtotal ({{qty}} decks)',
exportQuoteTax: 'Tax / VAT ({{pct}}%)',
exportQuoteTotal: 'Total',
exportQuoteTotalQty: 'Total ({{qty}} decks)',
exportQuoteIncludesTax: 'Includes {{pct}}% tax ({{amount}})',
exportQuoteDeposit: 'Deposit due now ({{pct}}%)',
exportQuoteBalance: 'Balance on completion',
exportQuotePayment: 'Payment: {{value}}',
exportQuoteLightning: 'Lightning: {{value}}',
exportQuoteDetails: 'Details: {{value}}',
exportQuoteTerms: 'Terms: {{value}}',
exportQuoteDelivery: 'Delivery: {{value}}',
exportQuoteValidUntil: 'Does not include shipping. Quote valid until {{date}}.',
exportPngSellingWith: 'SELLING PRICE · WITH PACKAGING',
exportPngSellingNo: 'SELLING PRICE · NO PACKAGING',
exportPngCostWith: 'COST / BOARD (WITH PACKAGING)',
exportPngDeposit: 'Deposit {{pct}}%: {{amount}}',
exportXlsxSummary: 'Summary',
exportXlsxCurrency: 'Currency',
exportXlsxDate: 'Date',
exportXlsxOperationsPerBoard: 'Operations per board',
exportColCost: 'Cost',
exportColSetupH: 'Setup (h)',
exportColBoards: 'Boards',
exportColPerBoard: 'Per board',
exportColMaterial: 'Material',
exportColOrderCost: 'Order cost',
exportColShipping: 'Shipping',
exportColOrderMin: 'Order (min)',
exportColUnloadMin: 'Unload (min)',
exportColProcess: 'Process',
exportColTimeMin: 'Time (min)',
exportColTimeBoardMin: 'Time/board (min)',
exportColMaterialsBoard: 'Materials/board',
exportCopyTitle: 'Deck cost — {{currency}}',
exportCopyCostWith: 'Cost / board (with packaging): {{amount}}',
exportCopySellingNo: 'Selling price (no packaging): {{amount}}',
exportCopySellingWith: 'Selling price (with packaging): {{amount}}',
exportCopyBreakdown: 'Equipment {{equipment}} · Operations {{operations}} · Materials {{materials}} · Processes {{processes}} · Packaging {{packaging}}',
exportMdHourlyRate: 'Hourly rate: {{value}}',
exportMdLabourBurden: 'Labour burden: {{value}}',
exportMdLoadedRate: 'Loaded rate: {{value}}',
exportMdBoardsPerMonth: 'Boards/month: {{value}}',
exportMdRent: 'Rent: {{value}}',
exportMdUtilities: 'Utilities: {{value}}',
exportMdSubscriptions: 'Subscriptions: {{value}}',
exportMdPerBoard: 'Per board: {{value}}',
exportMdMarkupWaste: 'Markup: {{markup}}× (≈ {{pct}}% gross margin) · Waste: {{waste}}%',
exportMdCostNoPack: 'Cost/board (no packaging): {{value}}',
exportMdCostWithPack: 'Cost/board (with packaging): {{value}}',
exportMdBatchSetup: 'Batch setup/board: {{value}}',
exportMdSellingNo: 'Selling price (no packaging): {{value}}',
exportMdSellingWith: 'Selling price (with packaging): {{value}}',
exportMdDisclaimer: 'Does not include shipping to the buyer. Generated by SkateSpots.',
}, },
es: { es: {
// Hosting dashboard // Hosting dashboard
@ -130,6 +247,123 @@ const legacyMiscUi = {
// Zap errors // Zap errors
zapNoActiveMint: 'No se encontró ninguna casa de moneda Cashu activa.', zapNoActiveMint: 'No se encontró ninguna casa de moneda Cashu activa.',
// Image alt text + aria labels
altProfile: 'Perfil',
altCardPreview: 'Vista previa de la tarjeta',
altCard: 'Tarjeta',
altCaptured: 'Capturado',
ariaPrevPhoto: 'Foto anterior',
ariaNextPhoto: 'Foto siguiente',
ariaCommentOptions: 'Opciones del comentario',
ariaMessageConversation: 'Conversación de mensajes',
// Deck cost calculator — generated export documents
exportTitle: 'SkateSpots — Calculadora de coste de Deck',
exportQuoteTitle: 'PRESUPUESTO',
exportPngTitle: 'Coste del Deck',
exportMarkdownTitle: 'Calculadora de coste de Deck',
exportSectionLabor: 'Mano de obra',
exportSectionEquipment: 'Equipamiento',
exportSectionOperations: 'Gastos de operación',
exportSectionMaterials: 'Materiales',
exportSectionProcesses: 'Costes de proceso',
exportSectionPackaging: 'Embalaje',
exportSectionResult: 'Resultado',
exportLabelOperations: 'Operaciones',
exportLabelProcesses: 'Procesos',
exportKvHourlyRate: 'Tarifa por hora',
exportKvLabourBurden: 'Carga laboral',
exportKvLoadedRate: 'Tarifa cargada',
exportKvEquipmentPerBoard: 'Equipamiento por tabla',
exportKvBoardsPerMonth: 'Tablas / mes',
exportKvRentPerMonth: 'Alquiler / mes',
exportKvUtilitiesPerMonth: 'Suministros / mes',
exportKvSubscriptionsPerMonth: 'Suscripciones / mes',
exportKvPerBoard: 'Por tabla',
exportKvMaterialsPerBoard: 'Materiales por tabla',
exportKvProcessesPerBoard: 'Procesos por tabla',
exportKvPackagingPerBoard: 'Embalaje por tabla',
exportKvMarkup: 'Margen',
exportMarkupValue: '{{markup}}× (≈ {{pct}}% de margen bruto)',
exportKvWasteEffective: 'Desperdicio (efectivo)',
exportKvCostNoPack: 'Coste / tabla (sin embalaje)',
exportKvCostWithPack: 'Coste / tabla (con embalaje)',
exportKvBatchSetupPerBoard: 'Preparación de lote / tabla',
exportKvTax: 'Impuesto ({{pct}}%)',
exportKvTaxIncluded: 'Impuesto ({{pct}}%, incluido)',
exportKvSellingNoPack: 'Precio de venta (sin embalaje)',
exportKvSellingWithPack: 'Precio de venta (con embalaje)',
exportShippingNote: 'No incluye el envío al comprador.',
exportGeneratedBy: 'Generado por SkateSpots · skate-spots.org',
exportGeneratedWith: 'Generado con SkateSpots · skate-spots.org',
exportDetailSetup: '+ {{hours}} h de preparación',
exportDetailBoards: '{{boards}} tablas',
exportDetailShip: '+ {{amount}} de envío',
exportDetailMinutes: '{{minutes}} min',
exportDetailPerBoards: '/ {{boards}} tablas',
exportDetailScrap: '{{pct}}% de desperdicio',
exportDetailMaterials: '{{materials}} de materiales',
exportQuoteRef: 'Ref. {{ref}}',
exportQuoteLineItem: 'Deck de skateboard personalizado',
exportQuoteLineItemQty: 'Deck de skateboard personalizado × {{qty}}',
exportQuoteLineDesc: 'Fabricado a mano, acabado y embalado. Cantidad 1.',
exportQuoteLineDescQty: 'Fabricado a mano, acabado y embalado. {{price}} cada uno.',
exportQuoteSubtotal: 'Subtotal',
exportQuoteSubtotalQty: 'Subtotal ({{qty}} tablas)',
exportQuoteTax: 'Impuesto / IVA ({{pct}}%)',
exportQuoteTotal: 'Total',
exportQuoteTotalQty: 'Total ({{qty}} tablas)',
exportQuoteIncludesTax: 'Incluye {{pct}}% de impuesto ({{amount}})',
exportQuoteDeposit: 'Señal a pagar ahora ({{pct}}%)',
exportQuoteBalance: 'Saldo al finalizar',
exportQuotePayment: 'Pago: {{value}}',
exportQuoteLightning: 'Lightning: {{value}}',
exportQuoteDetails: 'Datos: {{value}}',
exportQuoteTerms: 'Condiciones: {{value}}',
exportQuoteDelivery: 'Entrega: {{value}}',
exportQuoteValidUntil: 'No incluye el envío. Presupuesto válido hasta el {{date}}.',
exportPngSellingWith: 'PRECIO DE VENTA · CON EMBALAJE',
exportPngSellingNo: 'PRECIO DE VENTA · SIN EMBALAJE',
exportPngCostWith: 'COSTE / TABLA (CON EMBALAJE)',
exportPngDeposit: 'Señal {{pct}}%: {{amount}}',
exportXlsxSummary: 'Resumen',
exportXlsxCurrency: 'Moneda',
exportXlsxDate: 'Fecha',
exportXlsxOperationsPerBoard: 'Operaciones por tabla',
exportColCost: 'Coste',
exportColSetupH: 'Preparación (h)',
exportColBoards: 'Tablas',
exportColPerBoard: 'Por tabla',
exportColMaterial: 'Material',
exportColOrderCost: 'Coste del pedido',
exportColShipping: 'Envío',
exportColOrderMin: 'Pedido (min)',
exportColUnloadMin: 'Descarga (min)',
exportColProcess: 'Proceso',
exportColTimeMin: 'Tiempo (min)',
exportColTimeBoardMin: 'Tiempo/tabla (min)',
exportColMaterialsBoard: 'Materiales/tabla',
exportCopyTitle: 'Coste del Deck — {{currency}}',
exportCopyCostWith: 'Coste / tabla (con embalaje): {{amount}}',
exportCopySellingNo: 'Precio de venta (sin embalaje): {{amount}}',
exportCopySellingWith: 'Precio de venta (con embalaje): {{amount}}',
exportCopyBreakdown: 'Equipamiento {{equipment}} · Operaciones {{operations}} · Materiales {{materials}} · Procesos {{processes}} · Embalaje {{packaging}}',
exportMdHourlyRate: 'Tarifa por hora: {{value}}',
exportMdLabourBurden: 'Carga laboral: {{value}}',
exportMdLoadedRate: 'Tarifa cargada: {{value}}',
exportMdBoardsPerMonth: 'Tablas/mes: {{value}}',
exportMdRent: 'Alquiler: {{value}}',
exportMdUtilities: 'Suministros: {{value}}',
exportMdSubscriptions: 'Suscripciones: {{value}}',
exportMdPerBoard: 'Por tabla: {{value}}',
exportMdMarkupWaste: 'Margen: {{markup}}× (≈ {{pct}}% de margen bruto) · Desperdicio: {{waste}}%',
exportMdCostNoPack: 'Coste/tabla (sin embalaje): {{value}}',
exportMdCostWithPack: 'Coste/tabla (con embalaje): {{value}}',
exportMdBatchSetup: 'Preparación de lote/tabla: {{value}}',
exportMdSellingNo: 'Precio de venta (sin embalaje): {{value}}',
exportMdSellingWith: 'Precio de venta (con embalaje): {{value}}',
exportMdDisclaimer: 'No incluye el envío al comprador. Generado por SkateSpots.',
}, },
}; };

View file

@ -71,6 +71,122 @@ const thBatch19 = {
// Zap errors // Zap errors
zapNoActiveMint: 'ไม่พบ Cashu mint ที่ใช้งานอยู่', zapNoActiveMint: 'ไม่พบ Cashu mint ที่ใช้งานอยู่',
modHelp: 'ซ่อนจะเผยแพร่ org.skatespots.moderation / hidden; ยืนยันจะเผยแพร่ … / verified; เลิกทำจะเผยแพร่ … / cleared (กลับเป็นกลาง) ทั้งสามเป็นเลเบลสาธารณะที่ย้อนกลับได้ — ไม่มีอะไรถูกลบออกจาก relay', modHelp: 'ซ่อนจะเผยแพร่ org.skatespots.moderation / hidden; ยืนยันจะเผยแพร่ … / verified; เลิกทำจะเผยแพร่ … / cleared (กลับเป็นกลาง) ทั้งสามเป็นเลเบลสาธารณะที่ย้อนกลับได้ — ไม่มีอะไรถูกลบออกจาก relay',
// Image alt text + aria labels
altProfile: 'โปรไฟล์',
altCardPreview: 'ตัวอย่างการ์ด',
altCard: 'การ์ด',
altCaptured: 'ถ่ายแล้ว',
ariaPrevPhoto: 'รูปก่อนหน้า',
ariaNextPhoto: 'รูปถัดไป',
ariaCommentOptions: 'ตัวเลือกความคิดเห็น',
ariaMessageConversation: 'บทสนทนาข้อความ',
// Deck cost calculator — generated export documents
exportTitle: 'SkateSpots — เครื่องคำนวณต้นทุน Deck',
exportQuoteTitle: 'ใบเสนอราคา',
exportPngTitle: 'ต้นทุน Deck',
exportMarkdownTitle: 'เครื่องคำนวณต้นทุน Deck',
exportSectionLabor: 'ค่าแรง',
exportSectionEquipment: 'อุปกรณ์',
exportSectionOperations: 'ค่าใช้จ่ายในการดำเนินงาน',
exportSectionMaterials: 'วัสดุ',
exportSectionProcesses: 'ต้นทุนกระบวนการ',
exportSectionPackaging: 'บรรจุภัณฑ์',
exportSectionResult: 'ผลลัพธ์',
exportLabelOperations: 'การดำเนินงาน',
exportLabelProcesses: 'กระบวนการ',
exportKvHourlyRate: 'อัตราต่อชั่วโมง',
exportKvLabourBurden: 'ภาระค่าแรง',
exportKvLoadedRate: 'อัตรารวมภาระ',
exportKvEquipmentPerBoard: 'อุปกรณ์ต่อบอร์ด',
exportKvBoardsPerMonth: 'บอร์ด / เดือน',
exportKvRentPerMonth: 'ค่าเช่า / เดือน',
exportKvUtilitiesPerMonth: 'ค่าสาธารณูปโภค / เดือน',
exportKvSubscriptionsPerMonth: 'ค่าสมาชิก / เดือน',
exportKvPerBoard: 'ต่อบอร์ด',
exportKvMaterialsPerBoard: 'วัสดุต่อบอร์ด',
exportKvProcessesPerBoard: 'กระบวนการต่อบอร์ด',
exportKvPackagingPerBoard: 'บรรจุภัณฑ์ต่อบอร์ด',
exportKvMarkup: 'กำไรที่บวกเพิ่ม',
exportMarkupValue: '{{markup}}× (≈ {{pct}}% กำไรขั้นต้น)',
exportKvWasteEffective: 'ของเสีย (สุทธิ)',
exportKvCostNoPack: 'ต้นทุน / บอร์ด (ไม่รวมบรรจุภัณฑ์)',
exportKvCostWithPack: 'ต้นทุน / บอร์ด (รวมบรรจุภัณฑ์)',
exportKvBatchSetupPerBoard: 'ตั้งเครื่องต่อล็อต / บอร์ด',
exportKvTax: 'ภาษี ({{pct}}%)',
exportKvTaxIncluded: 'ภาษี ({{pct}}%, รวมแล้ว)',
exportKvSellingNoPack: 'ราคาขาย (ไม่รวมบรรจุภัณฑ์)',
exportKvSellingWithPack: 'ราคาขาย (รวมบรรจุภัณฑ์)',
exportShippingNote: 'ไม่รวมค่าจัดส่งถึงผู้ซื้อ',
exportGeneratedBy: 'สร้างโดย SkateSpots · skate-spots.org',
exportGeneratedWith: 'สร้างด้วย SkateSpots · skate-spots.org',
exportDetailSetup: '+ ตั้งเครื่อง {{hours}} ชม.',
exportDetailBoards: '{{boards}} บอร์ด',
exportDetailShip: '+ ค่าจัดส่ง {{amount}}',
exportDetailMinutes: '{{minutes}} นาที',
exportDetailPerBoards: '/ {{boards}} บอร์ด',
exportDetailScrap: 'ของเสีย {{pct}}%',
exportDetailMaterials: 'วัสดุ {{materials}}',
exportQuoteRef: 'อ้างอิง {{ref}}',
exportQuoteLineItem: 'Deck สเก็ตบอร์ดสั่งทำ',
exportQuoteLineItemQty: 'Deck สเก็ตบอร์ดสั่งทำ × {{qty}}',
exportQuoteLineDesc: 'ทำมือ ขัดแต่ง และบรรจุพร้อมส่ง จำนวน 1',
exportQuoteLineDescQty: 'ทำมือ ขัดแต่ง และบรรจุพร้อมส่ง ชิ้นละ {{price}}',
exportQuoteSubtotal: 'ยอดรวมย่อย',
exportQuoteSubtotalQty: 'ยอดรวมย่อย ({{qty}} บอร์ด)',
exportQuoteTax: 'ภาษี / VAT ({{pct}}%)',
exportQuoteTotal: 'ยอดรวม',
exportQuoteTotalQty: 'ยอดรวม ({{qty}} บอร์ด)',
exportQuoteIncludesTax: 'รวมภาษี {{pct}}% ({{amount}})',
exportQuoteDeposit: 'มัดจำจ่ายตอนนี้ ({{pct}}%)',
exportQuoteBalance: 'ยอดคงเหลือเมื่อเสร็จ',
exportQuotePayment: 'ชำระเงิน: {{value}}',
exportQuoteLightning: 'Lightning: {{value}}',
exportQuoteDetails: 'รายละเอียด: {{value}}',
exportQuoteTerms: 'เงื่อนไข: {{value}}',
exportQuoteDelivery: 'การจัดส่ง: {{value}}',
exportQuoteValidUntil: 'ไม่รวมค่าจัดส่ง ใบเสนอราคาใช้ได้ถึง {{date}}',
exportPngSellingWith: 'ราคาขาย · รวมบรรจุภัณฑ์',
exportPngSellingNo: 'ราคาขาย · ไม่รวมบรรจุภัณฑ์',
exportPngCostWith: 'ต้นทุน / บอร์ด (รวมบรรจุภัณฑ์)',
exportPngDeposit: 'มัดจำ {{pct}}%: {{amount}}',
exportXlsxSummary: 'สรุป',
exportXlsxCurrency: 'สกุลเงิน',
exportXlsxDate: 'วันที่',
exportXlsxOperationsPerBoard: 'การดำเนินงานต่อบอร์ด',
exportColCost: 'ต้นทุน',
exportColSetupH: 'ตั้งเครื่อง (ชม.)',
exportColBoards: 'บอร์ด',
exportColPerBoard: 'ต่อบอร์ด',
exportColMaterial: 'วัสดุ',
exportColOrderCost: 'ต้นทุนการสั่งซื้อ',
exportColShipping: 'ค่าจัดส่ง',
exportColOrderMin: 'สั่งซื้อ (นาที)',
exportColUnloadMin: 'ขนลง (นาที)',
exportColProcess: 'กระบวนการ',
exportColTimeMin: 'เวลา (นาที)',
exportColTimeBoardMin: 'เวลา/บอร์ด (นาที)',
exportColMaterialsBoard: 'วัสดุ/บอร์ด',
exportCopyTitle: 'ต้นทุน Deck — {{currency}}',
exportCopyCostWith: 'ต้นทุน / บอร์ด (รวมบรรจุภัณฑ์): {{amount}}',
exportCopySellingNo: 'ราคาขาย (ไม่รวมบรรจุภัณฑ์): {{amount}}',
exportCopySellingWith: 'ราคาขาย (รวมบรรจุภัณฑ์): {{amount}}',
exportCopyBreakdown: 'อุปกรณ์ {{equipment}} · การดำเนินงาน {{operations}} · วัสดุ {{materials}} · กระบวนการ {{processes}} · บรรจุภัณฑ์ {{packaging}}',
exportMdHourlyRate: 'อัตราต่อชั่วโมง: {{value}}',
exportMdLabourBurden: 'ภาระค่าแรง: {{value}}',
exportMdLoadedRate: 'อัตรารวมภาระ: {{value}}',
exportMdBoardsPerMonth: 'บอร์ด/เดือน: {{value}}',
exportMdRent: 'ค่าเช่า: {{value}}',
exportMdUtilities: 'ค่าสาธารณูปโภค: {{value}}',
exportMdSubscriptions: 'ค่าสมาชิก: {{value}}',
exportMdPerBoard: 'ต่อบอร์ด: {{value}}',
exportMdMarkupWaste: 'กำไรที่บวกเพิ่ม: {{markup}}× (≈ {{pct}}% กำไรขั้นต้น) · ของเสีย: {{waste}}%',
exportMdCostNoPack: 'ต้นทุน/บอร์ด (ไม่รวมบรรจุภัณฑ์): {{value}}',
exportMdCostWithPack: 'ต้นทุน/บอร์ด (รวมบรรจุภัณฑ์): {{value}}',
exportMdBatchSetup: 'ตั้งเครื่องต่อล็อต/บอร์ด: {{value}}',
exportMdSellingNo: 'ราคาขาย (ไม่รวมบรรจุภัณฑ์): {{value}}',
exportMdSellingWith: 'ราคาขาย (รวมบรรจุภัณฑ์): {{value}}',
exportMdDisclaimer: 'ไม่รวมค่าจัดส่งถึงผู้ซื้อ สร้างโดย SkateSpots',
}, },
}; };

View file

@ -667,7 +667,7 @@ export default function BecomeBuilderPage() {
<Label>{t('labUi.builderForm.logoPhoto')}</Label> <Label>{t('labUi.builderForm.logoPhoto')}</Label>
{image ? ( {image ? (
<div className="relative w-32 h-32 rounded-full overflow-hidden bg-gray-800"> <div className="relative w-32 h-32 rounded-full overflow-hidden bg-gray-800">
<img src={image} alt="Profile" className="w-full h-full object-cover" /> <img src={image} alt={t('legacyMiscUi.altProfile')} className="w-full h-full object-cover" />
<button type="button" onClick={() => setImage(null)} className="absolute top-2 right-2 p-1 rounded-full bg-black/80 hover:bg-red-500"> <button type="button" onClick={() => setImage(null)} className="absolute top-2 right-2 p-1 rounded-full bg-black/80 hover:bg-red-500">
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>

View file

@ -971,7 +971,7 @@ export default function BecomeCoachPage() {
<Label>{t('labUi.become.profilePhoto')}</Label> <Label>{t('labUi.become.profilePhoto')}</Label>
{image ? ( {image ? (
<div className="relative w-32 h-32 rounded-full overflow-hidden bg-gray-800"> <div className="relative w-32 h-32 rounded-full overflow-hidden bg-gray-800">
<img src={image} alt="Profile" className="w-full h-full object-cover" /> <img src={image} alt={t('legacyMiscUi.altProfile')} className="w-full h-full object-cover" />
<button type="button" onClick={() => setImage(null)} className="absolute top-2 right-2 p-1 rounded-full bg-black/80 hover:bg-red-500"> <button type="button" onClick={() => setImage(null)} className="absolute top-2 right-2 p-1 rounded-full bg-black/80 hover:bg-red-500">
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>

View file

@ -631,7 +631,7 @@ export default function BecomeFilmerPage() {
<Label>{t('labUi.become.profilePhoto')}</Label> <Label>{t('labUi.become.profilePhoto')}</Label>
{image ? ( {image ? (
<div className="relative w-32 h-32 rounded-full overflow-hidden bg-gray-800"> <div className="relative w-32 h-32 rounded-full overflow-hidden bg-gray-800">
<img src={image} alt="Profile" className="w-full h-full object-cover" /> <img src={image} alt={t('legacyMiscUi.altProfile')} className="w-full h-full object-cover" />
<button type="button" onClick={() => setImage(null)} className="absolute top-2 right-2 p-1 rounded-full bg-black/80 hover:bg-red-500"> <button type="button" onClick={() => setImage(null)} className="absolute top-2 right-2 p-1 rounded-full bg-black/80 hover:bg-red-500">
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>

View file

@ -696,7 +696,7 @@ export default function BecomePhotographerPage() {
<Label>{t('labUi.become.profilePhoto')}</Label> <Label>{t('labUi.become.profilePhoto')}</Label>
{image ? ( {image ? (
<div className="relative w-32 h-32 rounded-full overflow-hidden bg-gray-800"> <div className="relative w-32 h-32 rounded-full overflow-hidden bg-gray-800">
<img src={image} alt="Profile" className="w-full h-full object-cover" /> <img src={image} alt={t('legacyMiscUi.altProfile')} className="w-full h-full object-cover" />
<button type="button" onClick={() => setImage(null)} className="absolute top-2 right-2 p-1 rounded-full bg-black/80 hover:bg-red-500"> <button type="button" onClick={() => setImage(null)} className="absolute top-2 right-2 p-1 rounded-full bg-black/80 hover:bg-red-500">
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>

View file

@ -345,7 +345,7 @@ export default function BecomeSkaterPage() {
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="relative w-28 h-28 rounded-2xl overflow-hidden bg-gray-800 flex-shrink-0 ring-2 ring-white/10"> <div className="relative w-28 h-28 rounded-2xl overflow-hidden bg-gray-800 flex-shrink-0 ring-2 ring-white/10">
{avatar ? ( {avatar ? (
<img src={avatar} alt="Profile" className="w-full h-full object-cover" /> <img src={avatar} alt={t('legacyMiscUi.altProfile')} className="w-full h-full object-cover" />
) : ( ) : (
<div className="w-full h-full flex items-center justify-center bg-[#ff0040]/15 text-[#ff0040] text-2xl font-black"> <div className="w-full h-full flex items-center justify-center bg-[#ff0040]/15 text-[#ff0040] text-2xl font-black">
{displayName.slice(0, 2).toUpperCase()} {displayName.slice(0, 2).toUpperCase()}

View file

@ -403,7 +403,7 @@ export default function ConversationPage() {
className="flex-1 overflow-y-auto py-3 sm:py-4 space-y-2 sm:space-y-3 min-h-0" className="flex-1 overflow-y-auto py-3 sm:py-4 space-y-2 sm:space-y-3 min-h-0"
role="log" role="log"
aria-live="polite" aria-live="polite"
aria-label="Message conversation" aria-label={t('legacyMiscUi.ariaMessageConversation')}
> >
{isLoading ? ( {isLoading ? (
<> <>