From f47441ff4189391f3e2572bf1bbb3744edab4c24 Mon Sep 17 00:00:00 2001 From: DeathKaioken Date: Sun, 15 Mar 2026 18:34:19 +0100 Subject: [PATCH 1/2] im getting MY>AADASD --- eslint.config.mjs | 27 +- public/templates/abo-contract-template.html | 620 ++++++++++++++++++ .../components/contractEditor.tsx | 23 +- .../components/contractTemplateList.tsx | 4 +- .../hooks/useContractManagement.ts | 86 ++- .../hooks/useAdminDashboardPlatforms.ts | 272 ++++++++ src/app/admin/dashboard-management/page.tsx | 259 ++++++++ src/app/admin/page.tsx | 18 + .../hooks/useCoffeeShippingFees.ts | 120 ++++ src/app/admin/subscriptions/page.tsx | 186 +++++- .../hooks/useShippingFees.ts | 87 +++ src/app/coffee-abonnements/page.tsx | 70 +- .../summary/components/SignaturePad.tsx | 167 +++++ .../summary/hooks/useAboActiveContractHtml.ts | 105 +++ src/app/coffee-abonnements/summary/page.tsx | 95 ++- src/app/components/nav/Header.tsx | 6 + src/app/dashboard/page.tsx | 92 ++- src/app/hooks/usePublicDashboardPlatforms.ts | 101 +++ src/app/page.tsx | 213 ++++-- src/app/utils/dashboardPlatforms.ts | 159 +++++ 20 files changed, 2547 insertions(+), 163 deletions(-) create mode 100644 public/templates/abo-contract-template.html create mode 100644 src/app/admin/dashboard-management/hooks/useAdminDashboardPlatforms.ts create mode 100644 src/app/admin/dashboard-management/page.tsx create mode 100644 src/app/admin/subscriptions/hooks/useCoffeeShippingFees.ts create mode 100644 src/app/coffee-abonnements/hooks/useShippingFees.ts create mode 100644 src/app/coffee-abonnements/summary/components/SignaturePad.tsx create mode 100644 src/app/coffee-abonnements/summary/hooks/useAboActiveContractHtml.ts create mode 100644 src/app/hooks/usePublicDashboardPlatforms.ts create mode 100644 src/app/utils/dashboardPlatforms.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 719cea2..42817f9 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,25 +1,14 @@ -import { dirname } from "path"; -import { fileURLToPath } from "url"; -import { FlatCompat } from "@eslint/eslintrc"; +import nextCoreWebVitals from 'eslint-config-next/core-web-vitals'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -const compat = new FlatCompat({ - baseDirectory: __dirname, -}); - -const eslintConfig = [ - ...compat.extends("next/core-web-vitals", "next/typescript"), +export default [ + ...nextCoreWebVitals, { ignores: [ - "node_modules/**", - ".next/**", - "out/**", - "build/**", - "next-env.d.ts", + 'node_modules/**', + '.next/**', + 'out/**', + 'build/**', + 'next-env.d.ts', ], }, ]; - -export default eslintConfig; diff --git a/public/templates/abo-contract-template.html b/public/templates/abo-contract-template.html new file mode 100644 index 0000000..9e24630 --- /dev/null +++ b/public/templates/abo-contract-template.html @@ -0,0 +1,620 @@ + + + + + + ABO Vertrag – Profit Planet GmbH + + + +
+
+
+

ABO Vertrag

+

Kaffee-/Tee-Service & automatische Wiederbestellungen

+
+
+
PROFIT PLANET GMBH
+
Liebenauer Hauptstraße 82c
+
A-8041 Graz
+
FN-649474 i
+
IBAN: AT16 2081 5000 4639 9507
+
Swift/BIC Code: STSPAT2GXXX
+
ATU82089605
+
+
+ +
+
+

Vertrag über automatische Wiederbestellungen (ABO)

+

Bitte alle Felder vollständig ausfüllen und Zutreffendes ankreuzen.

+
+
+
Vertragsnummer: {{contractNumber}}
+
Datum: {{currentDate}}
+
+
+ +

An die

+
+
+
Empfänger
+
{{recipientName}}
+
+
+
Adresse
+
{{recipientAddress}}
+
+
+ +
+
+
Affiliate
+
+
AFFILIATE NAME
+
{{affiliateName}}
+
+
+
+
Client
+
+
CLIENT NAME
+
{{clientName}}
+
+
+
+ +

Lieferadresse

+
+
+
+
+ KUNDE + FIRMA +
+
Vor- und Nachname
{{shippingFullName}}
+
Adresse
{{shippingStreet}}
+
PLZ / Ort
{{shippingPostalCode}}    {{shippingCity}}
+
+
+
Telefonnummer
{{shippingPhone}}
+
Mobil
{{shippingMobile}}
+
E-Mail-Adresse
{{shippingEmail}}
+
+
Bevorzugte Kontaktaufnahme
+
+ Telefon + E-Mail +
+
+
+
+ +
Rechnungsadresse: {{invoiceSameAsShippingMark}} wie Lieferadresse
+
+ +

Rechnungsadresse (falls abweichend)

+
+
+
+
+ FIRMA + KUNDE +
+
Vor- und Nachname
{{invoiceFullName}}
+
Adresse
{{invoiceStreet}}
+
PLZ / Ort
{{invoicePostalCode}}    {{invoiceCity}}
+
+
+
Telefonnummer
{{invoicePhone}}
+
Mobil
{{invoiceMobile}}
+
E-Mail-Adresse
{{invoiceEmail}}
+
+
Bevorzugte Kontaktaufnahme
+
+ Telefon + E-Mail +
+
+
+
+ +
+ FN: {{fnNumber}} + ATU: {{atuNumber}} + (falls zutreffend ausfüllen) +
+
+ +

Zutreffendes bitte ankreuzen

+
+
+ + Der Kunde/Käufer tätigt das gegenständliche Rechtsgeschäft als Unternehmer im Sinne des § 1 Abs 1 Z 1 KSchG, das heißt, das Geschäft gehört zum Betrieb seines Unternehmens. +
+
+ + Der Kunde/Käufer tätigt das gegenständliche Rechtsgeschäft als Konsument im Sinne des § 1 Abs 1 Z 2 KSchG. +
+
+ +

Angebote

+
+

+ Mindestbestellmenge für BIO Kaffee und BIO Tee und BIO Kakao beträgt pro Bestellung jeweils 120 Kapseln. + Preis pro Kapsel € 2,97 inkl. 20% MwSt. Preise und Konditionen gemäß gültigem PROFIT PLANET GMBH Tarif. +

+ + + + + + + + + + + + + + + + + +
TarifPreis pro Kapsel
Customer without abo2.97€
Customer with abo1.77€
+
+ +

Produktauswahl

+
+

Superfood Coffee – 60 Kapseln (bitte gewünschte Sorten ankreuzen / ergänzen)

+ {{selectedProductsHtml}} + +

+ Bei Angabe einer automatischen Wiederbestellung, gemäß den Regelungen in nachstehendem Punkt 3, erhält der Kunde in regelmäßigen Abständen, + BEGINNEND AM (Unterzeichnung des Vertrages) vorstehend eingetragene BIO Kaffee-Teemenge für die Dauer des Vertrages oder bis zum Widerruf der automatischen Wiederbestellung. + Der BIO Kaffee-Tee wird automatisch im Abstand von (zutreffendes bitte ankreuzen) +

+ +
+ 1 Monat + 2 Monate + 3 Monate +
+ +

fakturiert und innerhalb von drei bis fünf Werktagen an den Kunden geliefert.

+
+ +

Zahlungsart

+
+
+ Sepa + Kreditkarte + Sofortbanking +
+
+ Bitte senden Sie mir meine Rechnung per E-Mail zu! +
+
+ +
+ + +
+ + diff --git a/src/app/admin/contract-management/components/contractEditor.tsx b/src/app/admin/contract-management/components/contractEditor.tsx index 3db037b..f1512fd 100644 --- a/src/app/admin/contract-management/components/contractEditor.tsx +++ b/src/app/admin/contract-management/components/contractEditor.tsx @@ -19,7 +19,7 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi const [lang, setLang] = useState<'en' | 'de'>('en'); const [type, setType] = useState<'contract' | 'invoice' | 'other'>('contract'); - const [contractType, setContractType] = useState<'contract' | 'gdpr'>('contract'); + const [contractType, setContractType] = useState<'contract' | 'gdpr' | 'abo'>('contract'); const [userType, setUserType] = useState<'personal' | 'company' | 'both'>('personal'); const [description, setDescription] = useState(''); @@ -55,7 +55,7 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi setDescription(((tpl as any)?.description as string) || ''); // FIX: DocumentTemplate may not declare `description` setLang((tpl.lang as any) || 'en'); setType(((tpl.type as any) || 'contract') as 'contract' | 'invoice' | 'other'); - setContractType(((tpl.contract_type as any) || 'contract') as 'contract' | 'gdpr'); + setContractType(((tpl.contract_type as any) || 'contract') as 'contract' | 'gdpr' | 'abo'); setUserType(((tpl.user_type as any) || 'both') as 'personal' | 'company' | 'both'); setEditingMeta({ id: editingTemplateId, @@ -163,6 +163,20 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi return; } + try { + console.info('[ContractEditor] doSave()', { + editingTemplateId: editingTemplateId ?? null, + publish, + name: name.trim(), + type, + contract_type: type === 'contract' ? contractType : null, + lang, + user_type: type === 'invoice' ? 'both' : userType, + descriptionLength: description ? description.length : 0, + htmlLength: html.length, + }); + } catch {} + setSaving(true); setStatusMsg(null); @@ -216,7 +230,7 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi const save = async (publish: boolean) => { if (publish) { let kind = type === 'contract' - ? (contractType === 'gdpr' ? 'GDPR' : 'Contract') + ? (contractType === 'gdpr' ? 'GDPR' : contractType === 'abo' ? 'ABO' : 'Contract') : type === 'invoice' ? 'Invoice' : 'Other'; @@ -302,12 +316,13 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi {type === 'contract' && ( )} {type !== 'invoice' && ( diff --git a/src/app/admin/contract-management/components/contractTemplateList.tsx b/src/app/admin/contract-management/components/contractTemplateList.tsx index 7302877..6af3a65 100644 --- a/src/app/admin/contract-management/components/contractTemplateList.tsx +++ b/src/app/admin/contract-management/components/contractTemplateList.tsx @@ -100,7 +100,7 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props) const tpl = items.find((i) => i.id === id); if (tpl) { const kind = tpl.type === 'contract' - ? (tpl.contract_type === 'gdpr' ? 'GDPR' : 'Contract') + ? (tpl.contract_type === 'gdpr' ? 'GDPR' : tpl.contract_type === 'abo' ? 'ABO' : 'Contract') : tpl.type === 'invoice' ? 'Invoice' : 'Other'; @@ -172,7 +172,7 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props) )} {c.type === 'contract' && ( - {c.contract_type === 'gdpr' ? 'GDPR' : 'Contract'} + {c.contract_type === 'gdpr' ? 'GDPR' : c.contract_type === 'abo' ? 'ABO' : 'Contract'} )} {c.user_type && c.type !== 'invoice' && ( diff --git a/src/app/admin/contract-management/hooks/useContractManagement.ts b/src/app/admin/contract-management/hooks/useContractManagement.ts index c1f239a..a3d791c 100644 --- a/src/app/admin/contract-management/hooks/useContractManagement.ts +++ b/src/app/admin/contract-management/hooks/useContractManagement.ts @@ -5,7 +5,7 @@ export type DocumentTemplate = { id: string; name: string; type?: string; - contract_type?: 'contract' | 'gdpr' | null | string; + contract_type?: 'contract' | 'gdpr' | 'abo' | null | string; lang?: 'en' | 'de' | string; user_type?: 'personal' | 'company' | 'both' | string; state?: 'active' | 'inactive' | string; @@ -32,6 +32,33 @@ function isFormData(body: any): body is FormData { return typeof FormData !== 'undefined' && body instanceof FormData; } +function safeDescribeBody(body: any) { + if (!body) return null; + if (isFormData(body)) { + const entries: Record = {}; + try { + for (const [k, v] of body.entries()) { + if (typeof File !== 'undefined' && v instanceof File) { + entries[k] = { kind: 'File', name: v.name, type: v.type, size: v.size }; + } else { + // Strings only for our current usage. + entries[k] = v; + } + } + } catch (e: any) { + return { kind: 'FormData', error: e?.message || String(e) }; + } + return { kind: 'FormData', entries }; + } + + if (typeof body === 'string') { + return { kind: 'string', preview: body.slice(0, 500), length: body.length }; + } + + // Avoid dumping arbitrary objects (could be huge / sensitive) + return { kind: typeof body }; +} + export default function useContractManagement() { const base = process.env.NEXT_PUBLIC_API_BASE_URL || ''; const getState = useAuthStore.getState; @@ -57,16 +84,32 @@ export default function useContractManagement() { headers['Content-Type'] = headers['Content-Type'] || 'application/json'; } + const url = `${base}${path}`; + const method = init.method || 'GET'; + // Debug (safe) try { console.debug('[CM] fetch ->', { - url: `${base}${path}`, - method: init.method || 'GET', + url, + method, hasAuth: !!token, - tokenPrefix: token ? `${token.substring(0, 12)}...` : null, }); } catch {} + // EXTRA debug for document-template calls: show what we send (safe metadata only) + if (path.startsWith('/api/document-templates')) { + try { + const safeHeaders = { ...headers } as Record; + if (safeHeaders.Authorization) safeHeaders.Authorization = '[redacted]'; + console.info('[CM][document-templates] request', { + url, + method, + headers: safeHeaders, + body: safeDescribeBody(init.body), + }); + } catch {} + } + // Include cookies + Authorization on all requests const res = await fetch(`${base}${path}`, { credentials: 'include', @@ -113,7 +156,7 @@ export default function useContractManagement() { return {} as T; } }, - [base] + [base, getState] ); // Document templates @@ -154,7 +197,7 @@ export default function useContractManagement() { file: File | Blob; name: string; type: string; - contract_type?: 'contract' | 'gdpr'; + contract_type?: 'contract' | 'gdpr' | 'abo'; lang: 'en' | 'de' | string; description?: string; user_type?: 'personal' | 'company' | 'both'; @@ -171,6 +214,19 @@ export default function useContractManagement() { if (payload.description) fd.append('description', payload.description); fd.append('user_type', (payload.user_type ?? 'both')); + try { + console.info('[CM][document-templates] uploadTemplate()', { + name: payload.name, + type: payload.type, + contract_type: payload.contract_type, + willSendContractType: payload.type === 'contract' && Boolean(payload.contract_type), + lang: payload.lang, + user_type: payload.user_type ?? 'both', + descriptionLength: payload.description ? payload.description.length : 0, + file: typeof File !== 'undefined' && file instanceof File ? { name: file.name, type: file.type, size: file.size } : null, + }); + } catch {} + return authorizedFetch('/api/document-templates', { method: 'POST', body: fd }); }, [authorizedFetch]); @@ -178,7 +234,7 @@ export default function useContractManagement() { file?: File | Blob; name?: string; type?: string; - contract_type?: 'contract' | 'gdpr'; + contract_type?: 'contract' | 'gdpr' | 'abo'; lang?: 'en' | 'de' | string; description?: string; user_type?: 'personal' | 'company' | 'both'; @@ -205,7 +261,7 @@ export default function useContractManagement() { file: File | Blob; name?: string; type?: string; - contract_type?: 'contract' | 'gdpr'; + contract_type?: 'contract' | 'gdpr' | 'abo'; lang?: 'en' | 'de' | string; description?: string; user_type?: 'personal' | 'company' | 'both'; @@ -224,6 +280,20 @@ export default function useContractManagement() { if (payload.user_type !== undefined) fd.append('user_type', payload.user_type); if (payload.state !== undefined) fd.append('state', payload.state); + try { + console.info('[CM][document-templates] reviseTemplate()', { + id, + name: payload.name, + type: payload.type, + contract_type: payload.contract_type, + lang: payload.lang, + user_type: payload.user_type, + state: payload.state, + descriptionLength: payload.description ? payload.description.length : 0, + file: typeof File !== 'undefined' && file instanceof File ? { name: file.name, type: file.type, size: file.size } : null, + }); + } catch {} + return authorizedFetch(`/api/document-templates/${id}/revise`, { method: 'POST', body: fd }); }, [authorizedFetch]); diff --git a/src/app/admin/dashboard-management/hooks/useAdminDashboardPlatforms.ts b/src/app/admin/dashboard-management/hooks/useAdminDashboardPlatforms.ts new file mode 100644 index 0000000..73f5eda --- /dev/null +++ b/src/app/admin/dashboard-management/hooks/useAdminDashboardPlatforms.ts @@ -0,0 +1,272 @@ +'use client' + +import { useCallback, useEffect, useMemo, useState } from 'react' +import { authFetch } from '../../../utils/authFetch' +import { + DEFAULT_DASHBOARD_PLATFORMS, + type DashboardPlatform, + type DashboardPlatformColorClass, + type DashboardPlatformIconName +} from '../../../utils/dashboardPlatforms' + +type BackendPlatform = { + id: string | number + title: string + href: string + description?: string | null + icon?: DashboardPlatformIconName | null + color?: DashboardPlatformColorClass | null + state?: boolean + disabled?: boolean + disabledText?: string | null + sortOrder?: number | null +} + +export type PlatformRow = DashboardPlatform & { + _isNew?: boolean +} + +const API_BASE = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '') +const FIXED_ICON: DashboardPlatformIconName = 'LinkIcon' + +function createId(): string { + if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { + return (crypto as any).randomUUID() + } + return `platform_${Date.now()}_${Math.random().toString(16).slice(2)}` +} + +function isValidHref(href: string): boolean { + const v = href.trim() + if (!v) return false + return v.startsWith('/') || v.startsWith('http://') || v.startsWith('https://') +} + +function toRow(p: BackendPlatform): PlatformRow { + return { + id: String(p.id), + title: typeof p.title === 'string' ? p.title : '', + description: typeof p.description === 'string' ? p.description : '', + href: typeof p.href === 'string' ? p.href : '', + icon: FIXED_ICON, + color: (p.color as DashboardPlatformColorClass) || ('bg-blue-500' as DashboardPlatformColorClass), + isActive: typeof p.state === 'boolean' ? p.state : true, + disabled: typeof p.disabled === 'boolean' ? p.disabled : false, + disabledText: typeof p.disabledText === 'string' ? p.disabledText : undefined + } +} + +function toPayload(p: PlatformRow) { + return { + title: p.title, + href: p.href, + description: p.description ?? '', + icon: FIXED_ICON, + color: p.color, + state: Boolean(p.isActive), + disabled: Boolean(p.disabled), + disabledText: p.disabledText ?? '' + } +} + +function normalizeForCompare(p: PlatformRow) { + return { + title: (p.title || '').trim(), + href: (p.href || '').trim(), + description: (p.description || '').trim(), + icon: FIXED_ICON, + color: p.color, + isActive: Boolean(p.isActive), + disabled: Boolean(p.disabled), + disabledText: (p.disabledText || '').trim() + } +} + +function isChanged(p: PlatformRow, baselineById: Record): boolean { + if (p._isNew) return true + const baseline = baselineById[p.id] + if (!baseline) return true + const a = normalizeForCompare(p) + const b = normalizeForCompare(baseline) + return ( + a.title !== b.title || + a.href !== b.href || + a.description !== b.description || + a.color !== b.color || + a.isActive !== b.isActive || + a.disabled !== b.disabled || + a.disabledText !== b.disabledText + ) +} + +function forceLinkIcon(rows: DashboardPlatform[]): PlatformRow[] { + return rows.map(r => ({ ...r, icon: FIXED_ICON })) as PlatformRow[] +} + +export function useAdminDashboardPlatforms() { + const [platforms, setPlatforms] = useState(forceLinkIcon(DEFAULT_DASHBOARD_PLATFORMS)) + const [baselineById, setBaselineById] = useState>({}) + const [savedAt, setSavedAt] = useState(null) + const [loading, setLoading] = useState(false) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + const hasValidationErrors = useMemo(() => { + return platforms.some(p => !p.title.trim() || !p.href.trim() || !isValidHref(p.href)) + }, [platforms]) + + const reload = useCallback(async () => { + setLoading(true) + setError(null) + try { + const res = await authFetch(`${API_BASE}/api/admin/dashboard-platforms`, { + method: 'GET', + headers: { Accept: 'application/json' }, + credentials: 'include' + }) + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(text || `HTTP ${res.status}`) + } + + const json = (await res.json().catch(() => null)) as unknown + const list = Array.isArray(json) ? (json as BackendPlatform[]) : [] + const rows = list.map(toRow) + setPlatforms(rows) + setBaselineById(Object.fromEntries(rows.map(r => [r.id, r]))) + } catch (e: any) { + setError(e?.message || 'Failed to load platforms') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + void reload() + }, [reload]) + + const addPlatform = useCallback((): string => { + const id = createId() + setPlatforms(prev => [ + ...prev, + { + id, + title: 'New Platform', + description: '', + href: '/dashboard', + icon: FIXED_ICON, + color: 'bg-blue-500' as DashboardPlatformColorClass, + isActive: true, + disabled: false, + _isNew: true + } + ]) + return id + }, []) + + const updatePlatform = useCallback((id: string, patch: Partial) => { + setPlatforms(prev => prev.map(p => (p.id === id ? { ...p, ...patch, icon: FIXED_ICON } : p))) + }, []) + + const removeNewPlatform = useCallback((id: string) => { + setPlatforms(prev => prev.filter(p => p.id !== id)) + }, []) + + const setPlatformState = useCallback(async (platform: PlatformRow, state: boolean) => { + if (platform._isNew) { + updatePlatform(platform.id, { isActive: state }) + return + } + + const prev = platform.isActive + updatePlatform(platform.id, { isActive: state }) + + try { + const res = await authFetch(`${API_BASE}/api/admin/dashboard-platforms/${platform.id}/state`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json' + }, + credentials: 'include', + body: JSON.stringify({ state }) + }) + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(text || `HTTP ${res.status}`) + } + + setBaselineById(prevMap => { + const prevBaseline = prevMap[platform.id] + if (!prevBaseline) return prevMap + return { ...prevMap, [platform.id]: { ...prevBaseline, isActive: state, icon: FIXED_ICON } } + }) + } catch (e: any) { + setError(e?.message || 'Failed to update platform state') + updatePlatform(platform.id, { isActive: prev }) + } + }, [updatePlatform]) + + const save = useCallback(async () => { + setSaving(true) + setError(null) + try { + // 1) Create new platforms + for (const platform of platforms.filter(p => p._isNew)) { + const res = await authFetch(`${API_BASE}/api/admin/dashboard-platforms`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json' + }, + credentials: 'include', + body: JSON.stringify(toPayload(platform)) + }) + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(text || `HTTP ${res.status}`) + } + } + + // 2) Update changed existing platforms + for (const platform of platforms.filter(p => !p._isNew && isChanged(p, baselineById))) { + const res = await authFetch(`${API_BASE}/api/admin/dashboard-platforms/${platform.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json' + }, + credentials: 'include', + body: JSON.stringify(toPayload(platform)) + }) + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(text || `HTTP ${res.status}`) + } + } + + // 3) Re-fetch list for canonical state/sort + await reload() + setSavedAt(Date.now()) + } catch (e: any) { + setError(e?.message || 'Save failed') + } finally { + setSaving(false) + } + }, [baselineById, platforms, reload]) + + return { + platforms, + loading, + saving, + error, + savedAt, + hasValidationErrors, + addPlatform, + updatePlatform, + removeNewPlatform, + setPlatformState, + save, + isValidHref, + } +} diff --git a/src/app/admin/dashboard-management/page.tsx b/src/app/admin/dashboard-management/page.tsx new file mode 100644 index 0000000..bccb62b --- /dev/null +++ b/src/app/admin/dashboard-management/page.tsx @@ -0,0 +1,259 @@ +'use client' + +import { useState } from 'react' +import PageLayout from '../../components/PageLayout' +import { + DASHBOARD_PLATFORMS_COLOR_OPTIONS, + type DashboardPlatform, + type DashboardPlatformColorClass +} from '../../utils/dashboardPlatforms' +import { PlusIcon, TrashIcon, CheckIcon, ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/outline' +import { useAdminDashboardPlatforms, type PlatformRow } from './hooks/useAdminDashboardPlatforms' + +export default function AdminDashboardManagementPage() { + const { + platforms, + loading, + saving, + error, + savedAt, + hasValidationErrors, + addPlatform, + updatePlatform, + removeNewPlatform, + setPlatformState, + save, + isValidHref, + } = useAdminDashboardPlatforms() + + const [openById, setOpenById] = useState>({}) + + const toggleOpen = (id: string) => { + setOpenById(prev => ({ ...prev, [id]: !prev[id] })) + } + + const addAndOpen = () => { + const id = addPlatform() + setOpenById(prev => ({ ...prev, [id]: true })) + } + + const isOpen = (p: PlatformRow) => Boolean(openById[p.id] ?? p._isNew) + + return ( + +
+
+
+
+
+

Dashboard Management

+

+ Manage the “Platforms” cards shown on the user dashboard. +

+
+ +
+ + +
+
+ + {error && ( +
+ {error} +
+ )} + + {savedAt && ( +
+ Saved at {new Date(savedAt).toLocaleTimeString('de-DE')} +
+ )} + + {hasValidationErrors && ( +
+ Please ensure every platform has a title and a valid link (must start with “/” or “http(s)://”). +
+ )} + +
+ {loading && ( +
+ Loading… +
+ )} + + {!loading && platforms.map(platform => ( +
+
+
+
+
{platform.title}
+
{platform.href}
+
+
+ + + +
+
+ + {isOpen(platform) && ( +
+ + + + + + + + + + +
+ + + +
+ + {platform.disabled && ( + + )} +
+ )} +
+
+ ))} + + {!loading && platforms.length === 0 && ( +
+ No platforms configured. +
+ )} +
+
+
+
+
+ ) +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index b085e30..639f8c2 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -279,6 +279,24 @@ export default function AdminDashboardPage() { + {/* Dashboard Management */} + + {/* User Management (unchanged) */} + + + {shippingFeesError && ( +
{shippingFeesError}
+ )} + +
+ {([60, 120] as CoffeeShippingFeePieceCount[]).map((pieceCount) => { + const saving = shippingFeeSaving[pieceCount]; + const savedAt = shippingFeeSavedAt[pieceCount]; + const fieldError = shippingFeeFieldError[pieceCount]; + const current = shippingFees.find((r) => r.pieceCount === pieceCount); + const draft = shippingFeeDraft[pieceCount] ?? ''; + + return ( +
+
+
+
+
{pieceCount} pieces
+ {typeof current?.price === 'number' && Number.isFinite(current.price) ? ( +
Current: €{formatPriceDraft(current.price)}
+ ) : null} + {savedAt ? ( +
+ Saved +
+ ) : null} +
+ {fieldError ? ( +
{fieldError}
+ ) : ( +
Enter a price in EUR (≥ 0).
+ )} +
+ +
+
+ + { + const v = e.target.value; + setShippingFeeDraft((prev) => ({ ...prev, [pieceCount]: v })); + setShippingFeeFieldError((prev) => ({ ...prev, [pieceCount]: null })); + }} + placeholder="0.00" + /> +
+ +
+
+
+ ); + })} +
+ +
{loading && (
Loading…
@@ -131,7 +315,7 @@ export default function AdminSubscriptionsPage() {

Delete coffee?

-

You are about to delete the coffee "{deleteTarget.title}". This action cannot be undone.

+

You are about to delete the coffee “{deleteTarget.title}”. This action cannot be undone.

+ + {shippingError && ( +
+ Shipping fees could not be loaded: {shippingError} +
+ )}

2. Choose coffees & quantities

@@ -298,10 +347,25 @@ export default function CoffeeAbonnementPage() {
))} + + {/* Shipping */} +
+ Shipping + + {shippingLoading ? ( + 'Loading…' + ) : isFreeShippingSelected ? ( + 'FREE SHIPPING' + ) : ( + `€${selectedShippingFee.toFixed(2)}` + )} + +
+
Total (net) - €{totalPrice.toFixed(2)} + €{totalNetWithShipping.toFixed(2)}
diff --git a/src/app/coffee-abonnements/summary/components/SignaturePad.tsx b/src/app/coffee-abonnements/summary/components/SignaturePad.tsx new file mode 100644 index 0000000..c81c7d3 --- /dev/null +++ b/src/app/coffee-abonnements/summary/components/SignaturePad.tsx @@ -0,0 +1,167 @@ +'use client' + +import React, { useEffect, useRef } from 'react' + +type Props = { + value: string + onChange: (dataUrl: string) => void + className?: string +} + +export default function SignaturePad({ value, onChange, className }: Props) { + const canvasRef = useRef(null) + const isDrawing = useRef(false) + + const getPos = (e: React.MouseEvent | React.TouchEvent) => { + const canvas = canvasRef.current + if (!canvas) return { x: 0, y: 0 } + const rect = canvas.getBoundingClientRect() + + const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX + const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY + + return { x: clientX - rect.left, y: clientY - rect.top } + } + + const setupCanvas = () => { + const canvas = canvasRef.current + if (!canvas) return + const rect = canvas.getBoundingClientRect() + const dpr = window.devicePixelRatio || 1 + canvas.width = rect.width * dpr + canvas.height = rect.height * dpr + + const ctx = canvas.getContext('2d') + if (!ctx) return + + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.lineWidth = 2 + ctx.lineCap = 'round' + ctx.strokeStyle = '#1C2B4A' + + // If we already have a signature value, redraw it after resize. + if (value) { + const img = new Image() + img.onload = () => { + try { + ctx.clearRect(0, 0, rect.width, rect.height) + ctx.drawImage(img, 0, 0, rect.width, rect.height) + } catch {} + } + img.src = value + } + } + + useEffect(() => { + setupCanvas() + + const onResize = () => setupCanvas() + window.addEventListener('resize', onResize) + return () => window.removeEventListener('resize', onResize) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // If parent sets a new value (e.g., cleared externally), reflect it. + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + const ctx = canvas.getContext('2d') + if (!ctx) return + + const rect = canvas.getBoundingClientRect() + ctx.clearRect(0, 0, rect.width, rect.height) + + if (!value) return + + const img = new Image() + img.onload = () => { + try { + ctx.drawImage(img, 0, 0, rect.width, rect.height) + } catch {} + } + img.src = value + }, [value]) + + const startDrawing = (e: React.MouseEvent | React.TouchEvent) => { + e.preventDefault() + const canvas = canvasRef.current + if (!canvas) return + const ctx = canvas.getContext('2d') + if (!ctx) return + + const { x, y } = getPos(e) + ctx.beginPath() + ctx.moveTo(x, y) + isDrawing.current = true + } + + const draw = (e: React.MouseEvent | React.TouchEvent) => { + e.preventDefault() + if (!isDrawing.current) return + + const canvas = canvasRef.current + if (!canvas) return + const ctx = canvas.getContext('2d') + if (!ctx) return + + const { x, y } = getPos(e) + ctx.lineTo(x, y) + ctx.stroke() + } + + const endDrawing = () => { + if (!isDrawing.current) return + isDrawing.current = false + + const canvas = canvasRef.current + if (!canvas) return + + try { + onChange(canvas.toDataURL('image/png')) + } catch { + onChange('') + } + } + + const clear = () => { + const canvas = canvasRef.current + if (!canvas) return + const ctx = canvas.getContext('2d') + if (!ctx) return + + const rect = canvas.getBoundingClientRect() + ctx.clearRect(0, 0, rect.width, rect.height) + onChange('') + } + + return ( +
+
+

Signature

+ +
+
+ +
+

+ {value ? 'Signature captured.' : 'Draw your signature in the box.'} +

+
+ ) +} diff --git a/src/app/coffee-abonnements/summary/hooks/useAboActiveContractHtml.ts b/src/app/coffee-abonnements/summary/hooks/useAboActiveContractHtml.ts new file mode 100644 index 0000000..cb9cf38 --- /dev/null +++ b/src/app/coffee-abonnements/summary/hooks/useAboActiveContractHtml.ts @@ -0,0 +1,105 @@ +'use client' + +import { useEffect, useState } from 'react' +import { authFetch } from '../../../utils/authFetch' + +const apiBase = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '') + +export function useAboActiveContractHtml() { + const [html, setHtml] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + let active = true + + ;(async () => { + setLoading(true) + setError(null) + try { + const url = `${apiBase}/api/contracts/abo/active` + const res = await authFetch(url, { + method: 'GET', + headers: { Accept: 'application/json' }, + credentials: 'include', + }) + + const ct = res.headers.get('content-type') || '' + const isJson = ct.includes('application/json') + + try { + console.info('[useAboActiveContractHtml] response meta', { + url, + status: res.status, + ok: res.ok, + contentType: ct, + isJson, + }) + } catch {} + + if (isJson) { + const payload: any = await res.json().catch(() => null) + + try { + console.info('[useAboActiveContractHtml] response json keys', payload && typeof payload === 'object' ? Object.keys(payload) : payload) + } catch {} + + if (!res.ok) { + const msg = payload?.message || payload?.error || `Failed to load contract: ${res.status}` + throw new Error(msg) + } + + const foundRaw = payload?.found ?? payload?.data?.found + const found = typeof foundRaw === 'boolean' ? foundRaw : undefined + + const htmlRaw = payload?.html ?? payload?.data?.html + const htmlValue = typeof htmlRaw === 'string' ? htmlRaw : '' + + const isFound = (typeof found === 'boolean') ? found : Boolean(htmlValue) + + try { + console.info('[useAboActiveContractHtml] parsed json', { + success: payload?.success, + found: isFound, + htmlLength: htmlValue ? htmlValue.length : 0, + htmlPreview: htmlValue ? `${htmlValue.slice(0, 200)}${htmlValue.length > 200 ? '…' : ''}` : '', + }) + } catch {} + + if (active) setHtml(isFound && htmlValue ? htmlValue : null) + return + } + + // Fallback: older endpoints returned raw HTML. + const text = await res.text().catch(() => '') + + try { + console.info('[useAboActiveContractHtml] response text preview', { + status: res.status, + ok: res.ok, + textLength: text.length, + textPreview: text ? `${text.slice(0, 200)}${text.length > 200 ? '…' : ''}` : '', + }) + } catch {} + + if (!res.ok) { + throw new Error(text || `Failed to load contract: ${res.status}`) + } + if (active) setHtml(text || null) + } catch (e: any) { + if (active) { + setHtml(null) + setError(e?.message || 'Failed to load contract preview.') + } + } finally { + if (active) setLoading(false) + } + })() + + return () => { + active = false + } + }, []) + + return { html, loading, error } +} diff --git a/src/app/coffee-abonnements/summary/page.tsx b/src/app/coffee-abonnements/summary/page.tsx index 2973494..141396d 100644 --- a/src/app/coffee-abonnements/summary/page.tsx +++ b/src/app/coffee-abonnements/summary/page.tsx @@ -1,19 +1,27 @@ 'use client'; -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import PageLayout from '../../components/PageLayout'; import { useRouter } from 'next/navigation'; import { useActiveCoffees } from '../hooks/getActiveCoffees'; import { getStandardVatRate, getVatRates } from './hooks/getTaxRate'; import { subscribeAbo } from './hooks/subscribeAbo'; import useAuthStore from '../../store/authStore' +import { useShippingFees } from '../hooks/useShippingFees'; +import { useAboActiveContractHtml } from './hooks/useAboActiveContractHtml' +import SignaturePad from './components/SignaturePad' + +const COLORS = ['#1C2B4A', '#233357', '#2A3B66', '#314475', '#3A4F88', '#5B6C9A']; // dark blue palette 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 { html: contractHtml, loading: contractLoading, error: contractError } = useAboActiveContractHtml() const [selections, setSelections] = useState>({}); const [selectedPlanCapsules, setSelectedPlanCapsules] = useState<60 | 120>(120); const [isForSelf, setIsForSelf] = useState(true); + const [signatureDataUrl, setSignatureDataUrl] = useState('') const [form, setForm] = useState({ firstName: '', lastName: '', @@ -34,7 +42,7 @@ export default function SummaryPage() { const [vatRates, setVatRates] = useState<{ code: string; rate: number | null }[]>([]); const [submitError, setSubmitError] = useState(null); const [submitLoading, setSubmitLoading] = useState(false); - const COLORS = ['#1C2B4A', '#233357', '#2A3B66', '#314475', '#3A4F88', '#5B6C9A']; // dark blue palette + const initialCountryRef = useRef(form.country) useEffect(() => { try { @@ -96,18 +104,19 @@ export default function SummaryPage() { useEffect(() => { let active = true; (async () => { - console.info('[SummaryPage] Loading vat rates (mount). country:', form.country) + const mountCountry = initialCountryRef.current + console.info('[SummaryPage] Loading vat rates (mount). country:', mountCountry) const list = await getVatRates(); if (!active) return; console.info('[SummaryPage] getVatRates result count:', list.length) setVatRates(list); - const upper = form.country.toUpperCase(); + const upper = mountCountry.toUpperCase(); const match = list.find(r => r.code === upper); if (match?.rate != null) { console.info('[SummaryPage] Initial taxRate from list:', match.rate, 'country:', upper) setTaxRate(match.rate); } else { - const rate = await getStandardVatRate(form.country); + const rate = await getStandardVatRate(mountCountry); console.info('[SummaryPage] Fallback taxRate via getStandardVatRate:', rate, 'country:', upper) setTaxRate(rate ?? 0.07); } @@ -138,8 +147,20 @@ export default function SummaryPage() { () => selectedEntries.reduce((sum, e) => sum + (e.quantity / 10) * e.coffee.pricePer10, 0), [selectedEntries] ); + + const shippingFee = useMemo(() => { + const v = feeByPieceCount[selectedPlanCapsules]; + return Number.isFinite(Number(v)) ? Number(v) : 0; + }, [feeByPieceCount, selectedPlanCapsules]); + + const netWithShipping = useMemo( + () => totalPrice + shippingFee, + [totalPrice, shippingFee] + ); + const taxAmount = useMemo(() => totalPrice * taxRate, [totalPrice, taxRate]); - const totalWithTax = useMemo(() => totalPrice + taxAmount, [totalPrice, taxRate, taxAmount]); + const taxAmountWithShipping = useMemo(() => netWithShipping * taxRate, [netWithShipping, taxRate]); + const totalWithTax = useMemo(() => netWithShipping + taxAmountWithShipping, [netWithShipping, taxAmountWithShipping]); const handleInput = (e: React.ChangeEvent) => { const { name, value } = e.target; @@ -432,6 +453,43 @@ export default function SummaryPage() { )} + + {/* Contract preview + signature (frontend only for now) */} +
+

Contract preview (ABO)

+

+ This is the currently active ABO contract template for your account. +

+ + {contractLoading ? ( +
+ Loading contract preview… +
+ ) : contractError ? ( +
+ Contract preview could not be loaded: {contractError} +
+ ) : contractHtml ? ( +
+