From 44a86f0b0fb627a436c31206128779619844fb25 Mon Sep 17 00:00:00 2001 From: seaznCode Date: Wed, 22 Apr 2026 20:27:56 +0200 Subject: [PATCH 01/32] fix: ensure invoice downloads as PDF regardless of URL type --- src/app/profile/components/financeInvoices.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/app/profile/components/financeInvoices.tsx b/src/app/profile/components/financeInvoices.tsx index 22ca016..7b7a5dc 100644 --- a/src/app/profile/components/financeInvoices.tsx +++ b/src/app/profile/components/financeInvoices.tsx @@ -113,8 +113,7 @@ export default function FinanceInvoices({ abonementId }: Props) { if (!res.ok) throw new Error(`Download failed: ${res.status}`) const blob = await res.blob() const invoiceNo = invoice.invoiceNumber || String(invoice.id) - const ext = invoice.pdfUrl ? 'pdf' : 'html' - downloadBlob(blob, `invoice-${invoiceNo}.${ext}`) + downloadBlob(blob, `invoice-${invoiceNo}.pdf`) } else { const blob = new Blob([JSON.stringify(invoice.raw, null, 2)], { type: 'application/json' }) downloadBlob(blob, `invoice-${invoice.invoiceNumber || invoice.id}.json`) -- 2.39.5 From 3de7e8971909858314a226676687d647fb95f221 Mon Sep 17 00:00:00 2001 From: seaznCode Date: Wed, 22 Apr 2026 20:36:00 +0200 Subject: [PATCH 02/32] fix: update bill filter status to 'issued' and correct spelling of 'Cancelled' --- src/app/admin/finance-management/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/admin/finance-management/page.tsx b/src/app/admin/finance-management/page.tsx index 9b559ba..bad7358 100644 --- a/src/app/admin/finance-management/page.tsx +++ b/src/app/admin/finance-management/page.tsx @@ -12,7 +12,7 @@ export default function FinanceManagementPage() { const accessToken = useAuthStore(s => s.accessToken) const { rates, loading: vatLoading, error: vatError } = useVatRates() const [timeframe, setTimeframe] = useState<'7d' | '30d' | '90d' | 'ytd'>('30d') - const [billFilter, setBillFilter] = useState({ query: '', status: 'all', from: '', to: '' }) + const [billFilter, setBillFilter] = useState({ query: '', status: 'issued', from: '', to: '' }) const [diagLoading, setDiagLoading] = useState(false) const [diagError, setDiagError] = useState('') const [diagData, setDiagData] = useState(null) @@ -270,7 +270,7 @@ export default function FinanceManagementPage() { - + Date: Wed, 22 Apr 2026 21:14:39 +0200 Subject: [PATCH 03/32] feat: add invoice upload functionality with form validation and modal --- src/app/admin/finance-management/page.tsx | 161 ++++++++++++++++++++++ 1 file changed, 161 insertions(+) diff --git a/src/app/admin/finance-management/page.tsx b/src/app/admin/finance-management/page.tsx index bad7358..ec9b5f0 100644 --- a/src/app/admin/finance-management/page.tsx +++ b/src/app/admin/finance-management/page.tsx @@ -121,6 +121,16 @@ export default function FinanceManagementPage() { } const [pdfLoading, setPdfLoading] = useState(null) + const [uploadModalOpen, setUploadModalOpen] = useState(false) + const [uploadForm, setUploadForm] = useState({ + buyer_name: '', buyer_email: '', buyer_street: '', buyer_postal_code: '', + buyer_city: '', buyer_country: '', currency: 'EUR', + total_gross: '', vat_rate: '20', + status: 'issued', issued_at: '', due_at: '', + }) + const [uploadFile, setUploadFile] = useState(null) + const [uploading, setUploading] = useState(false) + const [uploadError, setUploadError] = useState(null) const viewInvoicePdf = async (inv: AdminInvoice) => { setPdfLoading(inv.id) @@ -147,6 +157,52 @@ export default function FinanceManagementPage() { } } + const submitUploadInvoice = async () => { + if (!uploadForm.total_gross || isNaN(Number(uploadForm.total_gross))) { + setUploadError('Total gross (Bruttobetrag) is required.') + return + } + setUploadError(null) + setUploading(true) + try { + const base = process.env.NEXT_PUBLIC_API_BASE_URL || '' + const gross = parseFloat(uploadForm.total_gross) + const rate = parseFloat(uploadForm.vat_rate) || 0 + const net = rate > 0 ? +(gross / (1 + rate / 100)).toFixed(2) : gross + const tax = +(gross - net).toFixed(2) + const fd = new FormData() + const { total_gross, vat_rate, ...rest } = uploadForm + Object.entries(rest).forEach(([k, v]) => { if (v !== '') fd.append(k, v) }) + fd.append('total_gross', gross.toFixed(2)) + fd.append('total_net', net.toFixed(2)) + fd.append('total_tax', tax.toFixed(2)) + fd.append('vat_rate', String(rate)) + if (uploadFile) fd.append('pdf', uploadFile) + const res = await fetch(`${base}/api/admin/invoices`, { + method: 'POST', + credentials: 'include', + headers: { ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}) }, + body: fd, + }) + const body = await res.json().catch(() => ({})) + if (!res.ok || body?.success === false) throw new Error(body?.message || `Upload failed (${res.status})`) + setUploadModalOpen(false) + setUploadForm({ + buyer_name: '', buyer_email: '', buyer_street: '', buyer_postal_code: '', + buyer_city: '', buyer_country: '', currency: 'EUR', + total_gross: '', vat_rate: '20', + status: 'issued', issued_at: '', due_at: '', + }) + setUploadFile(null) + reload() + setReportMsg({ type: 'success', text: `Invoice ${body.data?.invoice_number ?? ''} created successfully.` }) + } catch (e: any) { + setUploadError(e?.message || 'Upload failed.') + } finally { + setUploading(false) + } + } + const sendEmailReport = async () => { if (!reportEmail.trim()) return setReportMsg(null) @@ -246,6 +302,7 @@ export default function FinanceManagementPage() {

Invoices

+ @@ -444,6 +501,110 @@ export default function FinanceManagementPage() { /> )} + {/* Upload Invoice Modal */} + {uploadModalOpen && ( +
+
+

Upload Invoice

+
+
+ + setUploadForm(f => ({ ...f, buyer_name: e.target.value }))} placeholder="Max Mustermann" /> +
+
+ + setUploadForm(f => ({ ...f, buyer_email: e.target.value }))} placeholder="kunde@example.com" /> +
+
+ + setUploadForm(f => ({ ...f, buyer_street: e.target.value }))} placeholder="Musterstraße 1" /> +
+
+ + setUploadForm(f => ({ ...f, buyer_postal_code: e.target.value }))} placeholder="8010" /> +
+
+ + setUploadForm(f => ({ ...f, buyer_city: e.target.value }))} placeholder="Graz" /> +
+
+ + setUploadForm(f => ({ ...f, buyer_country: e.target.value }))} placeholder="Austria" /> +
+
+ + setUploadForm(f => ({ ...f, total_gross: e.target.value }))} placeholder="0.00" /> +
+
+ + setUploadForm(f => ({ ...f, vat_rate: e.target.value }))} placeholder="20" /> +
+ {(() => { + const gross = parseFloat(uploadForm.total_gross) || 0 + const rate = parseFloat(uploadForm.vat_rate) || 0 + const net = rate > 0 ? +(gross / (1 + rate / 100)).toFixed(2) : gross + const tax = +(gross - net).toFixed(2) + return ( +
+
+
Netto (calculated)
+
{uploadForm.currency} {net.toFixed(2)}
+
+
+
MwSt. (calculated)
+
{uploadForm.currency} {tax.toFixed(2)}
+
+
+ ) + })()} +
+ + +
+
+ + +
+
+ + setUploadForm(f => ({ ...f, issued_at: e.target.value }))} /> +
+
+ + setUploadForm(f => ({ ...f, due_at: e.target.value }))} /> +
+
+ + setUploadFile(e.target.files?.[0] ?? null)} + /> + {uploadFile &&

{uploadFile.name}

} +
+
+ {uploadError && ( +
{uploadError}
+ )} +
+ + +
+
+
+ )} + {/* Email Report Dialog */} {emailDialogOpen && (
-- 2.39.5 From 554a573c98ba58c40ceff7e2fff4e43c4fc3e56a Mon Sep 17 00:00:00 2001 From: DeathKaioken Date: Tue, 28 Apr 2026 19:23:03 +0200 Subject: [PATCH 04/32] uid change --- .../summary/hooks/subscribeAbo.ts | 6 + src/app/coffee-abonnements/summary/page.tsx | 118 ++++++++++++++---- .../company/page.tsx | 36 ++++-- 3 files changed, 123 insertions(+), 37 deletions(-) diff --git a/src/app/coffee-abonnements/summary/hooks/subscribeAbo.ts b/src/app/coffee-abonnements/summary/hooks/subscribeAbo.ts index 4f6537c..a4bfda4 100644 --- a/src/app/coffee-abonnements/summary/hooks/subscribeAbo.ts +++ b/src/app/coffee-abonnements/summary/hooks/subscribeAbo.ts @@ -33,6 +33,9 @@ export type SubscribeAboInput = { invoiceCity?: string invoicePhone?: string invoiceEmail?: string + uidNumber?: string + atuNumber?: string + taxMode?: 'standard_vat' | 'reverse_charge' signingCity?: string signatureDataUrl?: string // logged-in user id @@ -99,6 +102,9 @@ export async function subscribeAbo(input: SubscribeAboInput) { paymentMethod: input.paymentMethod || undefined, invoiceByEmail: input.invoiceByEmail ?? false, invoiceSameAsShipping: input.invoiceSameAsShipping ?? true, + uidNumber: input.uidNumber || undefined, + atuNumber: input.atuNumber || undefined, + taxMode: input.taxMode || undefined, signingCity: input.signingCity || undefined, signatureDataUrl: input.signatureDataUrl || undefined, } diff --git a/src/app/coffee-abonnements/summary/page.tsx b/src/app/coffee-abonnements/summary/page.tsx index f87ad1c..0f2e502 100644 --- a/src/app/coffee-abonnements/summary/page.tsx +++ b/src/app/coffee-abonnements/summary/page.tsx @@ -43,6 +43,24 @@ function hashString(value: string): number { return hash >>> 0 } +const HOME_COUNTRY_CODE = 'AT' + +function normalizeUid(value: unknown): string { + if (typeof value !== 'string') return '' + return value.replace(/\s+/g, '').toUpperCase() +} + +function isLikelyValidUid(value: string): boolean { + return /^[A-Z]{2}[A-Z0-9]{4,14}$/.test(value) +} + +function pickFirstString(...values: unknown[]): string { + for (const value of values) { + if (typeof value === 'string' && value.trim() !== '') return value.trim() + } + return '' +} + export default function SummaryPage() { const router = useRouter(); const { coffees, loading, error } = useActiveCoffees(); @@ -75,6 +93,7 @@ export default function SummaryPage() { invoiceCity: '', invoicePhone: '', invoiceEmail: '', + uidNumber: '', signingCity: '', }); const [showThanks, setShowThanks] = useState(false); @@ -90,12 +109,27 @@ export default function SummaryPage() { const templateVariableNames = useMemo(() => extractTemplateVariables(contractHtml), [contractHtml]) const templateVariableNamesKey = useMemo(() => templateVariableNames.join('|'), [templateVariableNames]) const [contractVariables, setContractVariables] = useState>({}) + const isCompanyCustomer = user?.userType === 'company' || user?.user_type === 'company' + + const profileUidNumber = useMemo(() => normalizeUid(pickFirstString( + user?.uidNumber, + user?.uid_number, + user?.atuNumber, + user?.atu_number, + user?.companyProfile?.uid_number, + user?.companyProfile?.atu_number + )), [user]) + + const enteredUidNumber = useMemo(() => normalizeUid(form.uidNumber), [form.uidNumber]) + const effectiveUidNumber = enteredUidNumber || profileUidNumber + const hasValidCompanyUid = isCompanyCustomer && isLikelyValidUid(effectiveUidNumber) + const isForeignInvoiceCountry = form.country.toUpperCase() !== HOME_COUNTRY_CODE + const isReverseCharge = isCompanyCustomer && hasValidCompanyUid && isForeignInvoiceCountry // Auto-compute contract variables from form state for preview useEffect(() => { if (!templateVariableNamesKey) return const fullName = `${form.firstName} ${form.lastName}`.trim() - const isCompany = user?.userType === 'company' || user?.user_type === 'company' const invoiceSame = form.invoiceSameAsShipping const computed: Record = { @@ -103,8 +137,8 @@ export default function SummaryPage() { currentDate: new Date().toLocaleDateString('de-AT', { day: '2-digit', month: '2-digit', year: 'numeric' }), recipientName: fullName, recipientAddress: `${form.street}, ${form.postalCode} ${form.city}`.trim(), - shippingCustomerClass: isCompany ? '' : 'checked', - shippingCompanyClass: isCompany ? 'checked' : '', + shippingCustomerClass: isCompanyCustomer ? '' : 'checked', + shippingCompanyClass: isCompanyCustomer ? 'checked' : '', shippingFullName: fullName, shippingStreet: form.street, shippingPostalCode: form.postalCode, @@ -112,8 +146,8 @@ export default function SummaryPage() { shippingPhone: form.phone, shippingEmail: form.email, invoiceSameAsShippingMark: invoiceSame ? '✓' : '', - invoiceCompanyClass: isCompany ? 'checked' : '', - invoiceCustomerClass: isCompany ? '' : 'checked', + invoiceCompanyClass: isCompanyCustomer ? 'checked' : '', + invoiceCustomerClass: isCompanyCustomer ? '' : 'checked', invoiceFullName: invoiceSame ? fullName : form.invoiceFullName, invoiceStreet: invoiceSame ? form.street : form.invoiceStreet, invoicePostalCode: invoiceSame ? form.postalCode : form.invoicePostalCode, @@ -122,10 +156,10 @@ export default function SummaryPage() { invoiceEmail: invoiceSame ? form.email : form.invoiceEmail, fnCheckedClass: '', fnNumber: '', - atuCheckedClass: '', - atuNumber: '', - entrepreneurClass: isCompany ? 'checked' : '', - consumerClass: isCompany ? '' : 'checked', + atuCheckedClass: hasValidCompanyUid ? 'checked' : '', + atuNumber: effectiveUidNumber, + entrepreneurClass: isCompanyCustomer ? 'checked' : '', + consumerClass: isCompanyCustomer ? '' : 'checked', paymentSepaClass: form.paymentMethod === 'sepa' ? 'checked' : '', paymentCardClass: form.paymentMethod === 'card' ? 'checked' : '', paymentSofortClass: form.paymentMethod === 'sofort' ? 'checked' : '', @@ -134,7 +168,7 @@ export default function SummaryPage() { fullName, } setContractVariables(computed) - }, [templateVariableNamesKey, form, user, signatureDataUrl]) + }, [templateVariableNamesKey, form, signatureDataUrl, effectiveUidNumber, hasValidCompanyUid, isCompanyCustomer]) const populatedContractHtml = useMemo(() => { if (!contractHtml) return null @@ -458,8 +492,9 @@ export default function SummaryPage() { [totalPrice, shippingFee] ); - const taxAmount = useMemo(() => totalPrice * taxRate, [totalPrice, taxRate]); - const taxAmountWithShipping = useMemo(() => netWithShipping * taxRate, [netWithShipping, taxRate]); + const effectiveTaxRate = isReverseCharge ? 0 : taxRate + const taxAmount = useMemo(() => totalPrice * effectiveTaxRate, [totalPrice, effectiveTaxRate]); + const taxAmountWithShipping = useMemo(() => netWithShipping * effectiveTaxRate, [netWithShipping, effectiveTaxRate]); const totalWithTax = useMemo(() => netWithShipping + taxAmountWithShipping, [netWithShipping, taxAmountWithShipping]); const handleInput = (e: React.ChangeEvent) => { @@ -478,23 +513,24 @@ export default function SummaryPage() { return; } - const pick = (...values: any[]) => { - for (const value of values) { - if (typeof value === 'string' && value.trim() !== '') return value.trim(); - } - return ''; - }; - setSubmitError(null); setForm(prev => ({ ...prev, - firstName: pick(user.firstName, user.firstname, user.givenName, user.first_name) || prev.firstName, - lastName: pick(user.lastName, user.lastname, user.familyName, user.last_name) || prev.lastName, - email: pick(user.email, user.mail) || prev.email, - street: pick(user.street, user.addressStreet, user.address?.street, user.address_line_1) || prev.street, - postalCode: pick(user.postalCode, user.zipCode, user.zip, user.addressPostalCode, user.address?.postalCode) || prev.postalCode, - city: pick(user.city, user.addressCity, user.town, user.address?.city) || prev.city, - country: (pick(user.country, user.countryCode, user.addressCountry, user.address?.country) || prev.country).toUpperCase(), + firstName: pickFirstString(user.firstName, user.firstname, user.givenName, user.first_name) || prev.firstName, + lastName: pickFirstString(user.lastName, user.lastname, user.familyName, user.last_name) || prev.lastName, + email: pickFirstString(user.email, user.mail) || prev.email, + street: pickFirstString(user.street, user.addressStreet, user.address?.street, user.address_line_1) || prev.street, + postalCode: pickFirstString(user.postalCode, user.zipCode, user.zip, user.addressPostalCode, user.address?.postalCode) || prev.postalCode, + city: pickFirstString(user.city, user.addressCity, user.town, user.address?.city) || prev.city, + country: (pickFirstString(user.country, user.countryCode, user.addressCountry, user.address?.country) || prev.country).toUpperCase(), + uidNumber: normalizeUid(pickFirstString( + user.uidNumber, + user.uid_number, + user.atuNumber, + user.atu_number, + user.companyProfile?.uid_number, + user.companyProfile?.atu_number + ) || prev.uidNumber), })); }; @@ -576,6 +612,9 @@ export default function SummaryPage() { } : {}), signingCity: form.signingCity.trim() || undefined, signatureDataUrl: signatureDataUrl || undefined, + uidNumber: effectiveUidNumber || undefined, + atuNumber: effectiveUidNumber || undefined, + taxMode: isReverseCharge ? 'reverse_charge' : 'standard_vat', referred_by: typeof currentUserId === 'number' ? currentUserId : undefined, } console.info('[SummaryPage] subscribeAbo payload:', payload) @@ -730,10 +769,30 @@ export default function SummaryPage() { {/* Invoice address */}

Invoice address

+ {isCompanyCustomer && ( +
+ Unternehmer mit gueltiger UID und Rechnungsland ausserhalb von {HOME_COUNTRY_CODE} werden per Reverse Charge ohne ausgewiesene MwSt verrechnet. +
+ )} + {isCompanyCustomer && ( +
+ + +

+ Ohne gueltige UID wird die Rechnung mit normaler MwSt erstellt. +

+
+ )} {!form.invoiceSameAsShipping && (
@@ -902,13 +961,18 @@ export default function SummaryPage() { €{netWithShipping.toFixed(2)}
- Tax ({(taxRate * 100).toFixed(1)}%) + {isReverseCharge ? 'Tax (Reverse Charge)' : `Tax (${(effectiveTaxRate * 100).toFixed(1)}%)`} €{taxAmountWithShipping.toFixed(2)}
Total incl. tax €{totalWithTax.toFixed(2)}
+ {isReverseCharge && ( +
+ Reverse Charge aktiv: gueltige UID und auslaendisches Rechnungsland erkannt. +
+ )} {/* Validation summary (refined design) */}
Selected: {totalCapsules} capsules ({totalPacks} packs of 10). Target: {selectedPlanCapsules} capsules ({requiredPacks} packs). diff --git a/src/app/quickaction-dashboard/register-additional-information/company/page.tsx b/src/app/quickaction-dashboard/register-additional-information/company/page.tsx index 771ea97..55aad22 100644 --- a/src/app/quickaction-dashboard/register-additional-information/company/page.tsx +++ b/src/app/quickaction-dashboard/register-additional-information/company/page.tsx @@ -15,7 +15,8 @@ interface CompanyProfileData { companyPhone: string contactPersonName: string contactPersonPhone: string - vatNumber: string + registrationNumber: string + uidNumber: string street: string postalCode: string city: string @@ -44,7 +45,8 @@ const init: CompanyProfileData = { companyPhone: '', contactPersonName: '', contactPersonPhone: '', - vatNumber: '', + registrationNumber: '', + uidNumber: '', street: '', postalCode: '', city: '', @@ -216,7 +218,8 @@ export default function CompanyAdditionalInformationPage() { companyPhone: profile?.phone || me?.companyPhone || prev.companyPhone, contactPersonName: profile?.contact_person_name || me?.contactPersonName || prev.contactPersonName, contactPersonPhone: profile?.contact_person_phone || me?.contactPersonPhone || prev.contactPersonPhone, - vatNumber: profile?.registration_number || prev.vatNumber, + registrationNumber: profile?.registration_number || prev.registrationNumber, + uidNumber: profile?.uid_number || profile?.atu_number || prev.uidNumber, street: profile?.address || prev.street, postalCode: profile?.zip_code || prev.postalCode, city: profile?.city || prev.city, @@ -281,7 +284,7 @@ export default function CompanyAdditionalInformationPage() { const validate = () => { const required: (keyof CompanyProfileData)[] = [ 'companyName','companyEmail','companyPhone','contactPersonName','contactPersonPhone', - 'vatNumber','street','postalCode','city','country','accountHolder','iban' + 'street','postalCode','city','country','accountHolder','iban' ] for (const k of required) { if (!form[k].trim()) { @@ -414,7 +417,9 @@ export default function CompanyAdditionalInformationPage() { zip_code: form.postalCode, // Backend expects 'zip_code' city: form.city, country: form.country, - registrationNumber: form.vatNumber, // Map VAT number to registration number + registrationNumber: form.registrationNumber || undefined, + uidNumber: form.uidNumber || undefined, + atuNumber: form.uidNumber || undefined, businessType: 'company', // Default business type branch: null, // Not collected in form, set to null numberOfEmployees: null, // Not collected in form, set to null @@ -580,15 +585,26 @@ export default function CompanyAdditionalInformationPage() {
+
+
+ +
-- 2.39.5 From 8b3cf18ea6c4999fa8b4ebf0eb836fe1dfdd1131 Mon Sep 17 00:00:00 2001 From: DeathKaioken Date: Tue, 28 Apr 2026 19:30:24 +0200 Subject: [PATCH 05/32] admin dashboard --- src/app/admin/page.tsx | 72 +++++++++++++++++ src/app/coffee-abonnements/summary/page.tsx | 4 +- src/app/components/nav/Header.tsx | 85 +-------------------- 3 files changed, 78 insertions(+), 83 deletions(-) diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 639f8c2..dfa8d0e 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -315,6 +315,78 @@ export default function AdminDashboardPage() { + {/* User Verify */} + + + {/* Finance Management */} + + + {/* Pool Management */} + + + {/* Affiliate Management */} + + {/* News Management */} - - - - {DISPLAY_MATRIX && ( - - )} - - {DISPLAY_ABONEMENTS && ( - <> - - - - )} - {DISPLAY_POOLS && ( - - )} - - {DISPLAY_NEWS && ( - - )} - - {/* ADDED: Dev Management in hamburger admin nav */} - {isAdminOrSuper && ( - - )} +

+ Open the dashboard to access all admin modules via icon panels. +

-- 2.39.5 From 7d029efe1ba4736dd5fb80454f1825d9dfe0dba5 Mon Sep 17 00:00:00 2001 From: seaznCode Date: Wed, 29 Apr 2026 20:00:58 +0200 Subject: [PATCH 06/32] refactor: update shipping fee logic to use threshold-based calculation and improve piece count handling --- .../hooks/useShippingFees.ts | 23 ++-- src/app/coffee-abonnements/page.tsx | 116 ++++++++---------- src/app/coffee-abonnements/summary/page.tsx | 14 +-- src/app/profile/subscriptions/page.tsx | 6 +- 4 files changed, 72 insertions(+), 87 deletions(-) diff --git a/src/app/coffee-abonnements/hooks/useShippingFees.ts b/src/app/coffee-abonnements/hooks/useShippingFees.ts index 18a59eb..84377d0 100644 --- a/src/app/coffee-abonnements/hooks/useShippingFees.ts +++ b/src/app/coffee-abonnements/hooks/useShippingFees.ts @@ -2,18 +2,14 @@ import { useEffect, useMemo, useState } from 'react'; -export type CoffeeShippingFeePieceCount = 60 | 120; - export type CoffeeShippingFee = { - pieceCount: CoffeeShippingFeePieceCount; + pieceCount: number; price: number; }; -type ShippingFeeMap = Record; - -function normalizePieceCount(v: any): CoffeeShippingFeePieceCount | null { +function normalizePieceCount(v: any): number | null { const n = typeof v === 'number' ? v : (typeof v === 'string' && /^\d+$/.test(v) ? Number(v) : NaN); - if (n === 60 || n === 120) return n; + if (Number.isInteger(n) && n >= 60 && n % 10 === 0) return n; return null; } @@ -24,12 +20,11 @@ export function useShippingFees() { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const feeByPieceCount: ShippingFeeMap = useMemo(() => { - const map: ShippingFeeMap = { 60: 0, 120: 0 }; - for (const row of fees) { - map[row.pieceCount] = row.price; - } - return map; + // Threshold lookup: finds the highest breakpoint <= n (mirrors backend logic). + const resolveShippingFee = useMemo(() => (n: number): number => { + const sorted = [...fees].sort((a, b) => b.pieceCount - a.pieceCount); + const match = sorted.find(f => f.pieceCount <= n); + return match ? match.price : 0; }, [fees]); useEffect(() => { @@ -83,5 +78,5 @@ export function useShippingFees() { }; }, [base]); - return { fees, feeByPieceCount, loading, error }; + return { fees, resolveShippingFee, loading, error }; } diff --git a/src/app/coffee-abonnements/page.tsx b/src/app/coffee-abonnements/page.tsx index 885730d..9f8d61a 100644 --- a/src/app/coffee-abonnements/page.tsx +++ b/src/app/coffee-abonnements/page.tsx @@ -8,19 +8,41 @@ import { useShippingFees } from './hooks/useShippingFees'; export default function CoffeeAbonnementPage() { const [selections, setSelections] = useState>({}); const [bump, setBump] = useState>({}); - const [selectedPlanCapsules, setSelectedPlanCapsules] = useState<60 | 120>(120); + const [selectedPlanCapsules, setSelectedPlanCapsules] = useState(60); const router = useRouter(); // Fetch active coffees from the backend const { coffees, loading, error } = useActiveCoffees(); - // Shipping fees (per piece count) - const { feeByPieceCount, loading: shippingLoading, error: shippingError } = useShippingFees(); - const shippingFeeFor60 = feeByPieceCount[60] ?? 0; - const shippingFeeFor120 = feeByPieceCount[120] ?? 0; - const selectedShippingFee = feeByPieceCount[selectedPlanCapsules] ?? 0; + // Shipping fees (threshold-based) + const { resolveShippingFee, loading: shippingLoading, error: shippingError } = useShippingFees(); + const selectedShippingFee = resolveShippingFee(selectedPlanCapsules); const isFreeShippingSelected = Number(selectedShippingFee) === 0; + const changePlanSize = (delta: number) => { + setSelectedPlanCapsules(prev => { + const next = Math.max(60, prev + delta); + // Trim selections that exceed the new plan size + setSelections(sel => { + const trimmed = { ...sel }; + let running = 0; + for (const id of Object.keys(trimmed)) { + if (running + trimmed[id] <= next) { + running += trimmed[id]; + } else if (running < next) { + const allowed = Math.floor((next - running) / 10) * 10; + if (allowed >= 10) { trimmed[id] = allowed; running = next; } + else delete trimmed[id]; + } else { + delete trimmed[id]; + } + } + return trimmed; + }); + return next; + }); + }; + const selectedEntries = useMemo( () => Object.entries(selections).map(([id, qty]) => { @@ -81,7 +103,7 @@ export default function CoffeeAbonnementPage() { setSelections((prev) => { if (!(id in prev)) return prev; const otherTotal = Object.entries(prev).reduce((sum, [key, qty]) => key === id ? sum : sum + qty, 0); - const maxForCoffee = Math.min(120, selectedPlanCapsules - otherTotal); + const maxForCoffee = selectedPlanCapsules - otherTotal; const next = prev[id] + delta; if (next < 10 || next > maxForCoffee) return prev; const updated = { ...prev, [id]: next }; @@ -115,63 +137,32 @@ export default function CoffeeAbonnementPage() {

1. Select subscription size

-
+
+ onClick={() => changePlanSize(-10)} + disabled={selectedPlanCapsules <= 60} + className="h-10 w-10 rounded-full bg-gray-100 hover:bg-gray-200 text-lg font-bold transition disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center" + >− +
+
{selectedPlanCapsules} pcs
+
{selectedPlanCapsules / 10} packs of 10 · min. 60
+
+ onClick={() => changePlanSize(+10)} + className="h-10 w-10 rounded-full bg-gray-100 hover:bg-gray-200 text-lg font-bold transition flex items-center justify-center" + >+ +
+ {shippingLoading ? ( + Shipping… + ) : isFreeShippingSelected ? ( + FREE SHIPPING + ) : ( + Shipping €{selectedShippingFee.toFixed(2)} + )} +
- {shippingError && (
Shipping fees could not be loaded: {shippingError} @@ -263,7 +254,7 @@ export default function CoffeeAbonnementPage() { {active && (
- Quantity (10–120) + Quantity (10–{maxForCoffee} pcs) @@ -374,8 +365,7 @@ export default function CoffeeAbonnementPage() { Selected: {totalCapsules} capsules ({packsSelected} packs of 10). Target: {selectedPlanCapsules} capsules ({requiredPacks} packs). {packsSelected !== requiredPacks && ( - Please select exactly {selectedPlanCapsules} capsules ({requiredPacks} packs). - {packsSelected < requiredPacks ? ` ${requiredPacks - packsSelected} packs missing.` : ` ${packsSelected - requiredPacks} packs too many.`} + {packsSelected < requiredPacks ? `${requiredPacks - packsSelected} packs missing.` : `${packsSelected - requiredPacks} packs too many — reduce plan size or remove some coffees.`} )}
@@ -407,7 +397,7 @@ export default function CoffeeAbonnementPage() { {!canProceed && (

- You can continue once exactly {selectedPlanCapsules} capsules ({requiredPacks} packs) are selected. + You can continue once exactly {selectedPlanCapsules} capsules ({requiredPacks} packs) are selected. Use the +/− buttons above to adjust the plan size.

)}
diff --git a/src/app/coffee-abonnements/summary/page.tsx b/src/app/coffee-abonnements/summary/page.tsx index f87ad1c..ce479c9 100644 --- a/src/app/coffee-abonnements/summary/page.tsx +++ b/src/app/coffee-abonnements/summary/page.tsx @@ -47,7 +47,7 @@ export default function SummaryPage() { const router = useRouter(); const { coffees, loading, error } = useActiveCoffees(); const user = useAuthStore(state => state.user) - const { feeByPieceCount, loading: shippingLoading, error: shippingError } = useShippingFees(); + const { resolveShippingFee, loading: shippingLoading, error: shippingError } = useShippingFees(); const { html: contractHtml, loading: contractLoading, error: contractError } = useAboContractTemplateHtml() const [isContractPreviewOpen, setIsContractPreviewOpen] = useState(false) const [contractPdfUrl, setContractPdfUrl] = useState('') @@ -55,7 +55,7 @@ export default function SummaryPage() { const [contractPdfLoading, setContractPdfLoading] = useState(false) const [contractPdfError, setContractPdfError] = useState(null) const [selections, setSelections] = useState>({}); - const [selectedPlanCapsules, setSelectedPlanCapsules] = useState<60 | 120>(120); + const [selectedPlanCapsules, setSelectedPlanCapsules] = useState(60); const [signatureDataUrl, setSignatureDataUrl] = useState('') const [form, setForm] = useState({ firstName: '', @@ -349,8 +349,9 @@ export default function SummaryPage() { const raw = sessionStorage.getItem('coffeeSelections'); if (raw) setSelections(JSON.parse(raw)); const rawPlan = sessionStorage.getItem('coffeeAboSizeCapsules'); - if (rawPlan === '60' || rawPlan === '120') { - setSelectedPlanCapsules(Number(rawPlan) as 60 | 120); + const parsedPlan = rawPlan ? Number(rawPlan) : null; + if (parsedPlan && Number.isInteger(parsedPlan) && parsedPlan >= 60 && parsedPlan % 10 === 0) { + setSelectedPlanCapsules(parsedPlan); } } catch {} }, []); @@ -449,9 +450,8 @@ export default function SummaryPage() { ); const shippingFee = useMemo(() => { - const v = feeByPieceCount[selectedPlanCapsules]; - return Number.isFinite(Number(v)) ? Number(v) : 0; - }, [feeByPieceCount, selectedPlanCapsules]); + return resolveShippingFee(selectedPlanCapsules); + }, [resolveShippingFee, selectedPlanCapsules]); const netWithShipping = useMemo( () => totalPrice + shippingFee, diff --git a/src/app/profile/subscriptions/page.tsx b/src/app/profile/subscriptions/page.tsx index 62dca15..1aa2299 100644 --- a/src/app/profile/subscriptions/page.tsx +++ b/src/app/profile/subscriptions/page.tsx @@ -153,8 +153,8 @@ export default function ProfileSubscriptionsPage() { setContentError('Please select at least one coffee with quantity greater than 0.') return } - if (draftTotalPacks !== 6 && draftTotalPacks !== 12) { - setContentError('Total packs must be exactly 6 or 12.') + if (draftTotalPacks < 6) { + setContentError('Total must be at least 6 packs (60 capsules).') return } @@ -439,7 +439,7 @@ export default function ProfileSubscriptionsPage() {

Edit coffee content

-

Selected packs: {draftTotalPacks} (must be 6 or 12)

+

Selected packs: {draftTotalPacks} (minimum 6)

{coffeesLoading ? ( -- 2.39.5 From 7559466c272e023cf849939706a0ae74f4b24486 Mon Sep 17 00:00:00 2001 From: DeathKaioken Date: Sat, 2 May 2026 21:00:08 +0200 Subject: [PATCH 07/32] i18 Co-authored-by: Copilot --- .../components/InvoiceDetailModal.tsx | 98 +- src/app/admin/language-management/page.tsx | 849 ++++++++++++++++ src/app/admin/page.tsx | 129 ++- src/app/api/i18n/scan/route.ts | 215 ++++ src/app/components/LanguageSwitcher.tsx | 117 +-- src/app/components/UserDetailModal.tsx | 104 +- src/app/components/nav/Header.tsx | 80 +- src/app/dashboard/page.tsx | 61 +- src/app/i18n/dynamicTranslations.ts | 83 ++ src/app/i18n/translations/de.ts | 929 ++++++++++++++++- src/app/i18n/translations/en.ts | 935 +++++++++++++++++- src/app/i18n/types.ts | 917 ++++++++++++++++- src/app/i18n/useTranslation.tsx | 74 +- src/app/quickaction-dashboard/page.tsx | 82 +- .../company/page.tsx | 96 +- .../personal/page.tsx | 172 ++-- .../register-email-verify/page.tsx | 86 +- .../register-sign-contract/company/page.tsx | 96 +- .../register-sign-contract/personal/page.tsx | 96 +- .../company/hooks/useCompanyUploadId.ts | 22 +- .../register-upload-id/company/page.tsx | 66 +- .../personal/hooks/usePersonalUploadId.ts | 34 +- .../register-upload-id/personal/page.tsx | 76 +- 23 files changed, 4691 insertions(+), 726 deletions(-) create mode 100644 src/app/admin/language-management/page.tsx create mode 100644 src/app/api/i18n/scan/route.ts create mode 100644 src/app/i18n/dynamicTranslations.ts diff --git a/src/app/admin/finance-management/components/InvoiceDetailModal.tsx b/src/app/admin/finance-management/components/InvoiceDetailModal.tsx index f7abfb6..fe01715 100644 --- a/src/app/admin/finance-management/components/InvoiceDetailModal.tsx +++ b/src/app/admin/finance-management/components/InvoiceDetailModal.tsx @@ -18,6 +18,7 @@ import { ShieldCheckIcon, } from '@heroicons/react/24/outline' import useAuthStore from '../../../store/authStore' +import { useTranslation } from '../../../i18n/useTranslation' /* ---------- types ---------- */ export type AdminInvoice = { @@ -85,11 +86,11 @@ const STATUSES = ['draft', 'issued', 'paid', 'overdue', 'canceled'] as const type InvoiceStatus = (typeof STATUSES)[number] const STATUS_CONFIG: Record = { - draft: { label: 'Draft', bg: 'bg-gray-100', text: 'text-gray-700', icon: PencilSquareIcon }, - issued: { label: 'Issued', bg: 'bg-indigo-100', text: 'text-indigo-700', icon: DocumentTextIcon }, - paid: { label: 'Paid', bg: 'bg-green-100', text: 'text-green-700', icon: CheckCircleIcon }, - overdue: { label: 'Overdue', bg: 'bg-red-100', text: 'text-red-700', icon: ExclamationCircleIcon }, - canceled: { label: 'Canceled', bg: 'bg-yellow-100', text: 'text-yellow-700', icon: NoSymbolIcon }, + draft: { label: 'draft', bg: 'bg-gray-100', text: 'text-gray-700', icon: PencilSquareIcon }, + issued: { label: 'issued', bg: 'bg-indigo-100', text: 'text-indigo-700', icon: DocumentTextIcon }, + paid: { label: 'paid', bg: 'bg-green-100', text: 'text-green-700', icon: CheckCircleIcon }, + overdue: { label: 'overdue', bg: 'bg-red-100', text: 'text-red-700', icon: ExclamationCircleIcon }, + canceled: { label: 'canceled', bg: 'bg-yellow-100', text: 'text-yellow-700', icon: NoSymbolIcon }, } function fmtDate(d?: string | null) { @@ -116,6 +117,7 @@ export default function InvoiceDetailModal({ onExport, }: InvoiceDetailModalProps) { const token = useAuthStore((s) => s.accessToken) + const { t } = useTranslation() // detail data const [items, setItems] = useState([]) @@ -274,10 +276,10 @@ export default function InvoiceDetailModal({
- Invoice {invoice.invoice_number ?? `#${invoice.id}`} + {t('invoiceDetailModal.invoiceTitle')} {invoice.invoice_number ?? `#${invoice.id}`}

- Created {fmtDateTime(invoice.created_at)} + {t('invoiceDetailModal.created')} {fmtDateTime(invoice.created_at)}

@@ -297,12 +299,12 @@ export default function InvoiceDetailModal({
- {statusConf.label} + {t(`invoiceDetailModal.status${statusConf.label.charAt(0).toUpperCase() + statusConf.label.slice(1)}` as any)}
- Change status: + {t('invoiceDetailModal.changeStatus')} {STATUSES.map((s) => { const sc = STATUS_CONFIG[s] const active = s === currentStatus @@ -317,7 +319,7 @@ export default function InvoiceDetailModal({ : 'border-gray-200 text-gray-600 hover:bg-gray-50 disabled:opacity-40' }`} > - {sc.label} + {t(`invoiceDetailModal.status${sc.label.charAt(0).toUpperCase() + sc.label.slice(1)}` as any)} ) })} @@ -327,7 +329,7 @@ export default function InvoiceDetailModal({ {/* status feedback */} {changingStatus && (
- Updating status… + {t('invoiceDetailModal.updatingStatus')}
)} {statusMsg && ( @@ -346,46 +348,46 @@ export default function InvoiceDetailModal({ {/* Customer info */}
- Customer + {t('invoiceDetailModal.customer')}
- - - - - - + + + + + +
{/* Financial info */}
- Financials + {t('invoiceDetailModal.financials')}
- - - - - + + + + +
{/* Dates */}
- Dates + {t('invoiceDetailModal.dates')}
- - - - + + + +
{/* Line items */}
- Line Items + {t('invoiceDetailModal.lineItems')}
{detailLoading ? (
@@ -395,17 +397,17 @@ export default function InvoiceDetailModal({ ) : detailError ? (
{detailError}
) : items.length === 0 ? ( -
No line items found.
+
{t('invoiceDetailModal.noLineItems')}
) : (
- - - - - + + + + + @@ -421,7 +423,7 @@ export default function InvoiceDetailModal({ - + @@ -434,17 +436,17 @@ export default function InvoiceDetailModal({ {payments.length > 0 && (
- Payments + {t('invoiceDetailModal.payments')}
DescriptionQtyUnit PriceTaxGross{t('invoiceDetailModal.description')}{t('invoiceDetailModal.qty')}{t('invoiceDetailModal.unitPrice')}{t('invoiceDetailModal.tax')}{t('invoiceDetailModal.gross')}
Total{t('invoiceDetailModal.total')} {fmtMoney(invoice.total_gross)}
- - - - - + + + + + @@ -471,8 +473,8 @@ export default function InvoiceDetailModal({ {invoice.context && (
- Context / Metadata - (click to expand) + {t('invoiceDetailModal.contextMetadata')} + {t('invoiceDetailModal.clickToExpand')}
                         {typeof invoice.context === 'string' ? invoice.context : JSON.stringify(invoice.context, null, 2)}
@@ -488,20 +490,20 @@ export default function InvoiceDetailModal({
                       onClick={() => onExport?.(invoice)}
                       className="inline-flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition"
                     >
-                       Export JSON
+                       {t('invoiceDetailModal.exportJson')}
                     
                     
                   
                   
                 
               
diff --git a/src/app/admin/language-management/page.tsx b/src/app/admin/language-management/page.tsx
new file mode 100644
index 0000000..e94c775
--- /dev/null
+++ b/src/app/admin/language-management/page.tsx
@@ -0,0 +1,849 @@
+'use client';
+
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useRouter } from 'next/navigation';
+import PageLayout from '../../components/PageLayout';
+import {
+  getAllTranslationKeys,
+  getEnglishValue,
+  getBuiltInFlatTranslations,
+} from '../../i18n/useTranslation';
+import {
+  loadCustomI18n,
+  saveCustomI18n,
+  type CustomI18nData,
+  type CustomLanguageEntry,
+} from '../../i18n/dynamicTranslations';
+
+// ── built-in languages (always present, cannot be deleted)
+const BUILTIN_LANGUAGES: CustomLanguageEntry[] = [
+  { code: 'en', name: 'English', flag: '🇬🇧' },
+  { code: 'de', name: 'Deutsch', flag: '🇩🇪' },
+];
+
+// ── flag emoji options for the language picker
+const FLAG_OPTIONS: { flag: string; label: string }[] = [
+  { flag: '🇬🇧', label: 'UK' },
+  { flag: '🇺🇸', label: 'US' },
+  { flag: '🇩🇪', label: 'Germany' },
+  { flag: '🇫🇷', label: 'France' },
+  { flag: '🇪🇸', label: 'Spain' },
+  { flag: '🇮🇹', label: 'Italy' },
+  { flag: '🇵🇹', label: 'Portugal' },
+  { flag: '🇧🇷', label: 'Brazil' },
+  { flag: '🇳🇱', label: 'Netherlands' },
+  { flag: '🇧🇪', label: 'Belgium' },
+  { flag: '🇨🇭', label: 'Switzerland' },
+  { flag: '🇦🇹', label: 'Austria' },
+  { flag: '🇵🇱', label: 'Poland' },
+  { flag: '🇨🇿', label: 'Czech' },
+  { flag: '🇸🇰', label: 'Slovakia' },
+  { flag: '🇭🇺', label: 'Hungary' },
+  { flag: '🇷🇴', label: 'Romania' },
+  { flag: '🇷🇺', label: 'Russia' },
+  { flag: '🇺🇦', label: 'Ukraine' },
+  { flag: '🇹🇷', label: 'Turkey' },
+  { flag: '🇬🇷', label: 'Greece' },
+  { flag: '🇸🇪', label: 'Sweden' },
+  { flag: '🇳🇴', label: 'Norway' },
+  { flag: '🇩🇰', label: 'Denmark' },
+  { flag: '🇫🇮', label: 'Finland' },
+  { flag: '🇯🇵', label: 'Japan' },
+  { flag: '🇰🇷', label: 'Korea' },
+  { flag: '🇨🇳', label: 'China' },
+  { flag: '🇮🇳', label: 'India' },
+  { flag: '🇸🇦', label: 'Saudi' },
+  { flag: '🇦🇪', label: 'UAE' },
+  { flag: '🇮🇱', label: 'Israel' },
+  { flag: '🇿🇦', label: 'S. Africa' },
+  { flag: '🇳🇬', label: 'Nigeria' },
+  { flag: '🇲🇽', label: 'Mexico' },
+  { flag: '🇦🇷', label: 'Argentina' },
+  { flag: '🇨🇦', label: 'Canada' },
+  { flag: '🇦🇺', label: 'Australia' },
+  { flag: '🏳️', label: 'None' },
+];
+
+// ── namespace categories
+const NAMESPACE_CATEGORIES: { label: string; namespaces: string[] }[] = [
+  { label: 'General', namespaces: ['common', 'nav', 'footer', 'home'] },
+  { label: 'Auth', namespaces: ['login', 'register', 'passwordReset'] },
+  { label: 'Pages', namespaces: ['dashboard', 'profile', 'community', 'shop', 'memberships', 'affiliateLinks', 'aboutUs', 'news'] },
+  { label: 'Coffee ABO', namespaces: ['coffeeSelection', 'coffeeSummary'] },
+  { label: 'Account', namespaces: ['personalMatrix', 'referralManagement', 'quickactionDashboard', 'suspended'] },
+  { label: 'Admin', namespaces: ['adminDashboard', 'userManagement', 'languageManagement', 'contractManagement'] },
+  { label: 'Notifications', namespaces: ['toasts'] },
+];
+
+function groupKeys(keys: string[]): Record {
+  const groups: Record = {};
+  for (const key of keys) {
+    const ns = key.split('.')[0];
+    if (!groups[ns]) groups[ns] = [];
+    groups[ns].push(key);
+  }
+  return groups;
+}
+
+export default function LanguageManagementPage() {
+  const router = useRouter();
+
+  type WorkspaceScanResult = {
+    scannedFiles: number;
+    scannedDirectories: number;
+    translationCallCount: number;
+    uniqueKeyCount: number;
+    missingKeys: Array<{ key: string; files: string[] }>;
+    untranslatedLiterals: Array<{ text: string; files: string[] }>;
+  };
+
+  // ── all flat keys from the English source-of-truth
+  const allKeys = useMemo(() => getAllTranslationKeys(), []);
+  const keyGroups = useMemo(() => groupKeys(allKeys), [allKeys]);
+  const namespaces = useMemo(() => Object.keys(keyGroups).sort(), [keyGroups]);
+
+  // ── custom i18n state (persisted in localStorage)
+  const [data, setData] = useState({ languages: [], translations: {} });
+  const [isDirty, setIsDirty] = useState(false);
+  const [saved, setSaved] = useState(false);
+
+  // ── selected language tab
+  const [activeLang, setActiveLang] = useState('en');
+
+  // ── search / filter
+  const [search, setSearch] = useState('');
+  const [expandedNs, setExpandedNs] = useState>({});
+
+  // ── add-language modal
+  const [showAddModal, setShowAddModal] = useState(false);
+  const [newCode, setNewCode] = useState('');
+  const [newName, setNewName] = useState('');
+  const [addError, setAddError] = useState('');
+
+  // ── delete confirm
+  const [deleteTarget, setDeleteTarget] = useState(null);
+
+  // ── active namespace category ('all' = show everything)
+  const [activeCategory, setActiveCategory] = useState('all');
+
+  // ── new language: flag selection
+  const [newFlag, setNewFlag] = useState('🏳️');
+
+  // ── scan results modal
+  const [showScanModal, setShowScanModal] = useState(false);
+  const [lastScanTime, setLastScanTime] = useState(null);
+  const [isScanning, setIsScanning] = useState(false);
+  const [scanError, setScanError] = useState(null);
+  const [workspaceScan, setWorkspaceScan] = useState(null);
+
+  // ── load on mount
+  useEffect(() => {
+    setData(loadCustomI18n());
+  }, []);
+
+  // ── all languages (built-in + custom), deduplicated
+  const allLanguages: CustomLanguageEntry[] = useMemo(() => {
+    const custom = data.languages.filter(
+      (l) => !BUILTIN_LANGUAGES.some((b) => b.code === l.code)
+    );
+    return [...BUILTIN_LANGUAGES, ...custom];
+  }, [data.languages]);
+
+  // ── resolve flat translations for the active language
+  // Built-in langs: use the compiled translation file as base; custom overrides on top.
+  // Custom langs: custom overrides only, fall back to English.
+  const baseFlat = useMemo((): Record => {
+    const builtIn = getBuiltInFlatTranslations(activeLang);
+    return builtIn; // may be empty object for new langs
+  }, [activeLang]);
+
+  const getDisplayValue = useCallback(
+    (key: string): string => {
+      const override = data.translations[activeLang]?.[key];
+      if (override !== undefined) return override;
+      return baseFlat[key] ?? '';
+    },
+    [activeLang, data.translations, baseFlat]
+  );
+
+  const getPlaceholder = useCallback(
+    (key: string): string => {
+      // Show English as placeholder when editing non-English
+      if (activeLang !== 'en') return getEnglishValue(key);
+      return '';
+    },
+    [activeLang]
+  );
+
+  const handleChange = (key: string, value: string) => {
+    setData((prev) => {
+      const langTranslations = { ...(prev.translations[activeLang] ?? {}) };
+      langTranslations[key] = value;
+      return {
+        ...prev,
+        translations: { ...prev.translations, [activeLang]: langTranslations },
+      };
+    });
+    setIsDirty(true);
+    setSaved(false);
+  };
+
+  const handleSave = () => {
+    saveCustomI18n(data);
+    setIsDirty(false);
+    setSaved(true);
+    setTimeout(() => setSaved(false), 2500);
+  };
+
+  const handleAddLanguage = () => {
+    const code = newCode.trim().toLowerCase();
+    const name = newName.trim();
+    if (!code) { setAddError('Language code is required.'); return; }
+    if (!name) { setAddError('Language name is required.'); return; }
+    if (!/^[a-z]{2,5}(-[a-zA-Z]{2,4})?$/.test(code)) {
+      setAddError('Use a valid BCP-47 code, e.g. fr, es, zh-TW.');
+      return;
+    }
+    if (allLanguages.some((l) => l.code === code)) {
+      setAddError(`Language "${code}" already exists.`);
+      return;
+    }
+    setData((prev) => ({
+      ...prev,
+      languages: [...prev.languages, { code, name, flag: newFlag }],
+    }));
+    setIsDirty(true);
+    setSaved(false);
+    setShowAddModal(false);
+    setNewCode('');
+    setNewName('');
+    setNewFlag('🏳️');
+    setAddError('');
+    setActiveLang(code);
+  };
+
+  const handleDeleteLanguage = (code: string) => {
+    if (BUILTIN_LANGUAGES.some((b) => b.code === code)) return; // can't delete built-ins
+    setData((prev) => {
+      const langs = prev.languages.filter((l) => l.code !== code);
+      const { [code]: _removed, ...rest } = prev.translations;
+      return { languages: langs, translations: rest };
+    });
+    setIsDirty(true);
+    setSaved(false);
+    setDeleteTarget(null);
+    if (activeLang === code) setActiveLang('en');
+  };
+
+  const toggleNs = (ns: string) =>
+    setExpandedNs((prev) => ({ ...prev, [ns]: !prev[ns] }));
+
+  const filteredGroups = useMemo(() => {
+    const q = search.toLowerCase();
+    if (!q) return keyGroups;
+    const result: Record = {};
+    for (const [ns, keys] of Object.entries(keyGroups)) {
+      const filtered = keys.filter(
+        (k) =>
+          k.toLowerCase().includes(q) ||
+          getEnglishValue(k).toLowerCase().includes(q)
+      );
+      if (filtered.length > 0) result[ns] = filtered;
+    }
+    return result;
+  }, [keyGroups, search]);
+
+  // ── scan results: coverage per namespace
+  const scanResults = useMemo(() => {
+    return namespaces.map((ns) => {
+      const keys = keyGroups[ns] ?? [];
+      let translated = 0;
+      if (activeLang === 'en') {
+        translated = keys.length; // English is the source
+      } else {
+        translated = keys.filter((k) => getDisplayValue(k) !== '').length;
+      }
+      return { ns, total: keys.length, translated, missing: keys.length - translated };
+    });
+  }, [namespaces, keyGroups, activeLang, getDisplayValue]);
+
+  const handleScan = async () => {
+    setShowScanModal(true);
+    setIsScanning(true);
+    setScanError(null);
+
+    try {
+      const response = await fetch('/api/i18n/scan', { method: 'GET' });
+      const result = await response.json();
+
+      if (!response.ok || !result?.ok) {
+        throw new Error(result?.message || 'Scan failed.');
+      }
+
+      setWorkspaceScan({
+        scannedFiles: Number(result.scannedFiles ?? 0),
+        scannedDirectories: Number(result.scannedDirectories ?? 0),
+        translationCallCount: Number(result.translationCallCount ?? 0),
+        uniqueKeyCount: Number(result.uniqueKeyCount ?? 0),
+        missingKeys: Array.isArray(result.missingKeys) ? result.missingKeys : [],
+        untranslatedLiterals: Array.isArray(result.untranslatedLiterals) ? result.untranslatedLiterals : [],
+      });
+      setLastScanTime(new Date());
+    } catch (error) {
+      setScanError(error instanceof Error ? error.message : 'Scan failed.');
+    } finally {
+      setIsScanning(false);
+    }
+  };
+
+  const categoryNamespaces = useMemo(() => {
+    if (activeCategory === 'all') return null; // null = no filter
+    return NAMESPACE_CATEGORIES.find((c) => c.label === activeCategory)?.namespaces ?? [];
+  }, [activeCategory]);
+
+  const filteredNs = useMemo(() => {
+    const base = Object.keys(filteredGroups).sort();
+    if (!categoryNamespaces) return base;
+    return base.filter((ns) => categoryNamespaces.includes(ns));
+  }, [filteredGroups, categoryNamespaces]);
+
+  // Auto-expand all namespaces when searching
+  useEffect(() => {
+    if (search) {
+      const all: Record = {};
+      for (const ns of filteredNs) all[ns] = true;
+      setExpandedNs(all);
+    }
+  }, [search]); // eslint-disable-line react-hooks/exhaustive-deps
+
+  const totalKeys = allKeys.length;
+  const translatedCount = useMemo(() => {
+    return allKeys.filter((k) => {
+      const v = getDisplayValue(k);
+      return v !== '' && v !== getEnglishValue(k);
+    }).length;
+  }, [allKeys, getDisplayValue]);
+
+  const isBuiltin = (code: string) => BUILTIN_LANGUAGES.some((b) => b.code === code);
+
+  return (
+    
+      
+ {/* Header */} +
+
+

Language Management

+

+ Manage UI translations. All {totalKeys} keys scanned from the English source file. +

+
+
+ + + {isDirty && ( + + )} + {saved && !isDirty && ( + + Saved + + )} +
+
+ + {/* Language tabs */} +
+ {allLanguages.map((lang) => ( + + )} + + ))} + +
+ + {/* Progress bar */} + {activeLang !== 'en' && ( +
+
+
+ Translation progress + {translatedCount} / {totalKeys} keys translated +
+
+
+
+
+ + {Math.round((translatedCount / totalKeys) * 100)}% + +
+ )} + + {/* Category tabs */} +
+ + {NAMESPACE_CATEGORIES.map((cat) => ( + + ))} +
+ + {/* Search */} +
+ setSearch(e.target.value)} + placeholder="Search keys or English text…" + className="w-full max-w-sm rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" + /> +
+ + {/* Translation table grouped by namespace */} +
+ {filteredNs.map((ns) => { + const keys = filteredGroups[ns]; + const isOpen = !!expandedNs[ns]; + return ( +
+ + {isOpen && ( +
MethodTransactionAmountPaid AtStatus{t('invoiceDetailModal.method')}{t('invoiceDetailModal.transaction')}{t('invoiceDetailModal.amount')}{t('invoiceDetailModal.paidAt')}{t('invoiceDetailModal.status')}
+ + + + {activeLang !== 'en' && ( + + )} + + + + + {keys.map((key) => { + const enVal = getEnglishValue(key); + const currentVal = getDisplayValue(key); + const hasOverride = (data.translations[activeLang]?.[key] ?? '') !== ''; + return ( + + + {activeLang !== 'en' && ( + + )} +
KeyEnglish (reference) + {allLanguages.find((l) => l.code === activeLang)?.name ?? activeLang} +
+ {key} + + {enVal} + +
+