From a3b7f311ce05e6912448f5ce5afc5aba734a0ced Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Wed, 7 Oct 2026 18:30:52 +0700 Subject: [PATCH] i18n: translate image alt/aria + generated export documents (last English literals) --- src/components/CameraCapture.tsx | 2 +- src/components/CraftProfileForm.tsx | 2 +- src/components/DeckCostCalculator.tsx | 242 +++++++++++++------------- src/components/ImageLightbox.tsx | 6 +- src/components/RiderCard.tsx | 6 +- src/components/comments/Comment.tsx | 4 +- src/i18n/ja/batch20.ts | 116 ++++++++++++ src/i18n/legacy/misc.ts | 234 +++++++++++++++++++++++++ src/i18n/th/batch19.ts | 116 ++++++++++++ src/pages/BecomeBuilderPage.tsx | 2 +- src/pages/BecomeCoachPage.tsx | 2 +- src/pages/BecomeFilmerPage.tsx | 2 +- src/pages/BecomePhotographerPage.tsx | 2 +- src/pages/BecomeSkaterPage.tsx | 2 +- src/pages/ConversationPage.tsx | 2 +- 15 files changed, 608 insertions(+), 132 deletions(-) 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 && ( - Captured + {t('legacyMiscUi.altCaptured')} )} {/* 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 ? ( <> - Profile + {t('legacyMiscUi.altProfile')} 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 - ? Card preview + ? {t('legacyMiscUi.altCardPreview')} :
} {zoomable && src && ( @@ -136,7 +138,7 @@ export function CardPreview({ d, className, zoomable = false }: { d: CardData; c role="dialog" aria-modal="true" > - Card + {t('legacyMiscUi.altCard')}
)} 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')} > diff --git a/src/i18n/ja/batch20.ts b/src/i18n/ja/batch20.ts index 2f62a22..e71bd9e 100644 --- a/src/i18n/ja/batch20.ts +++ b/src/i18n/ja/batch20.ts @@ -72,6 +72,122 @@ const jaBatch20 = { // Zap errors zapNoActiveMint: '有効なCashuミントが見つかりません。', 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 が生成。', }, }; diff --git a/src/i18n/legacy/misc.ts b/src/i18n/legacy/misc.ts index 88ef8e3..475a879 100644 --- a/src/i18n/legacy/misc.ts +++ b/src/i18n/legacy/misc.ts @@ -68,6 +68,123 @@ const legacyMiscUi = { // Zap errors 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: { // Hosting dashboard @@ -130,6 +247,123 @@ const legacyMiscUi = { // Zap errors 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.', }, }; diff --git a/src/i18n/th/batch19.ts b/src/i18n/th/batch19.ts index 240e2f3..d7d6057 100644 --- a/src/i18n/th/batch19.ts +++ b/src/i18n/th/batch19.ts @@ -71,6 +71,122 @@ const thBatch19 = { // Zap errors zapNoActiveMint: 'ไม่พบ Cashu mint ที่ใช้งานอยู่', 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', }, }; diff --git a/src/pages/BecomeBuilderPage.tsx b/src/pages/BecomeBuilderPage.tsx index 702af51..e4b437e 100644 --- a/src/pages/BecomeBuilderPage.tsx +++ b/src/pages/BecomeBuilderPage.tsx @@ -667,7 +667,7 @@ export default function BecomeBuilderPage() { {image ? (
- Profile + {t('legacyMiscUi.altProfile')} diff --git a/src/pages/BecomeCoachPage.tsx b/src/pages/BecomeCoachPage.tsx index c59233f..a133782 100644 --- a/src/pages/BecomeCoachPage.tsx +++ b/src/pages/BecomeCoachPage.tsx @@ -971,7 +971,7 @@ export default function BecomeCoachPage() { {image ? (
- Profile + {t('legacyMiscUi.altProfile')} diff --git a/src/pages/BecomeFilmerPage.tsx b/src/pages/BecomeFilmerPage.tsx index 43653b2..410b36b 100644 --- a/src/pages/BecomeFilmerPage.tsx +++ b/src/pages/BecomeFilmerPage.tsx @@ -631,7 +631,7 @@ export default function BecomeFilmerPage() { {image ? (
- Profile + {t('legacyMiscUi.altProfile')} diff --git a/src/pages/BecomePhotographerPage.tsx b/src/pages/BecomePhotographerPage.tsx index 195f469..4134023 100644 --- a/src/pages/BecomePhotographerPage.tsx +++ b/src/pages/BecomePhotographerPage.tsx @@ -696,7 +696,7 @@ export default function BecomePhotographerPage() { {image ? (
- Profile + {t('legacyMiscUi.altProfile')} diff --git a/src/pages/BecomeSkaterPage.tsx b/src/pages/BecomeSkaterPage.tsx index 1467a50..25c76a7 100644 --- a/src/pages/BecomeSkaterPage.tsx +++ b/src/pages/BecomeSkaterPage.tsx @@ -345,7 +345,7 @@ export default function BecomeSkaterPage() {
{avatar ? ( - Profile + {t('legacyMiscUi.altProfile')} ) : (
{displayName.slice(0, 2).toUpperCase()} diff --git a/src/pages/ConversationPage.tsx b/src/pages/ConversationPage.tsx index a71702c..fece1cc 100644 --- a/src/pages/ConversationPage.tsx +++ b/src/pages/ConversationPage.tsx @@ -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" role="log" aria-live="polite" - aria-label="Message conversation" + aria-label={t('legacyMiscUi.ariaMessageConversation')} > {isLoading ? ( <>