'use client'; 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 { useAboContractTemplateHtml } from './hooks/useAboContractTemplateHtml' import SignaturePad from './components/SignaturePad' import { Dialog, DialogActions, DialogBody, DialogTitle } from '../../components/dialog' import { createReferralLink } from '../../referral-management/hooks/generateReferralLink' const COLORS = ['#1C2B4A', '#233357', '#2A3B66', '#314475', '#3A4F88', '#5B6C9A']; // dark blue palette function extractTemplateVariables(templateHtml: string | null | undefined): string[] { if (!templateHtml) return [] const vars = new Set() const re = /\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g let match: RegExpExecArray | null while ((match = re.exec(templateHtml)) !== null) { if (match[1]) vars.add(match[1]) } return Array.from(vars).sort((a, b) => a.localeCompare(b)) } function escapeHtml(value: string): string { return value .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') } function hashString(value: string): number { // djb2 let hash = 5381 for (let i = 0; i < value.length; i++) { hash = ((hash << 5) + hash) ^ value.charCodeAt(i) } 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(); const user = useAuthStore(state => state.user) const { feeByPieceCount, loading: shippingLoading, error: shippingError } = useShippingFees(); const { html: contractHtml, loading: contractLoading, error: contractError } = useAboContractTemplateHtml() const [isContractPreviewOpen, setIsContractPreviewOpen] = useState(false) const [contractPdfUrl, setContractPdfUrl] = useState('') const [contractPdfKey, setContractPdfKey] = useState('') const [contractPdfLoading, setContractPdfLoading] = useState(false) const [contractPdfError, setContractPdfError] = useState(null) const [selections, setSelections] = useState>({}); const [selectedPlanCapsules, setSelectedPlanCapsules] = useState<60 | 120>(120); const [signatureDataUrl, setSignatureDataUrl] = useState('') const [form, setForm] = useState({ firstName: '', lastName: '', email: '', street: '', postalCode: '', city: '', country: 'DE', phone: '', paymentMethod: 'sepa' as 'sepa' | 'card' | 'sofort', invoiceByEmail: true, invoiceSameAsShipping: true, invoiceFullName: '', invoiceStreet: '', invoicePostalCode: '', invoiceCity: '', invoicePhone: '', invoiceEmail: '', uidNumber: '', signingCity: '', }); const [showThanks, setShowThanks] = useState(false); const [guestMailtoHref, setGuestMailtoHref] = useState('') const [guestInviteLink, setGuestInviteLink] = useState('') const [confetti, setConfetti] = useState<{ left: number; delay: number; color: string }[]>([]); const [taxRate, setTaxRate] = useState(0.07); // minimal fallback only const [vatRates, setVatRates] = useState<{ code: string; rate: number | null }[]>([]); const [submitError, setSubmitError] = useState(null); const [submitLoading, setSubmitLoading] = useState(false); const initialCountryRef = useRef(form.country) 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 invoiceSame = form.invoiceSameAsShipping const computed: Record = { contractNumber: '(wird generiert)', 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: isCompanyCustomer ? '' : 'checked', shippingCompanyClass: isCompanyCustomer ? 'checked' : '', shippingFullName: fullName, shippingStreet: form.street, shippingPostalCode: form.postalCode, shippingCity: form.city, shippingPhone: form.phone, shippingEmail: form.email, invoiceSameAsShippingMark: invoiceSame ? '✓' : '', invoiceCompanyClass: isCompanyCustomer ? 'checked' : '', invoiceCustomerClass: isCompanyCustomer ? '' : 'checked', invoiceFullName: invoiceSame ? fullName : form.invoiceFullName, invoiceStreet: invoiceSame ? form.street : form.invoiceStreet, invoicePostalCode: invoiceSame ? form.postalCode : form.invoicePostalCode, invoiceCity: invoiceSame ? form.city : form.invoiceCity, invoicePhone: invoiceSame ? form.phone : form.invoicePhone, invoiceEmail: invoiceSame ? form.email : form.invoiceEmail, fnCheckedClass: '', fnNumber: '', 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' : '', invoiceByEmailClass: form.invoiceByEmail ? 'checked' : '', signingCity: form.signingCity, fullName, } setContractVariables(computed) }, [templateVariableNamesKey, form, signatureDataUrl, effectiveUidNumber, hasValidCompanyUid, isCompanyCustomer]) const populatedContractHtml = useMemo(() => { if (!contractHtml) return null // Replace placeholders with escaped user-entered values so placeholders never show. return contractHtml.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (_whole, varName: string) => { if (varName === 'signatureImage' && !contractVariables[varName] && signatureDataUrl) { const safeUrl = String(signatureDataUrl) if (safeUrl.startsWith('data:image/')) { const src = escapeHtml(safeUrl) return `Signature` } } const value = contractVariables[varName] ?? '' return escapeHtml(String(value)) }) }, [contractHtml, contractVariables, signatureDataUrl]) const contractPdfCacheKey = useMemo(() => { if (!populatedContractHtml) return '' return String(hashString(populatedContractHtml)) }, [populatedContractHtml]) useEffect(() => { // Cleanup blob URL when it changes or on unmount. return () => { if (contractPdfUrl) URL.revokeObjectURL(contractPdfUrl) } }, [contractPdfUrl]) const closeContractPreview = () => { setIsContractPreviewOpen(false) setContractPdfError(null) setContractPdfLoading(false) setContractPdfKey('') if (contractPdfUrl) { URL.revokeObjectURL(contractPdfUrl) setContractPdfUrl('') } } const openContractPreview = async () => { if (!populatedContractHtml) return setIsContractPreviewOpen(true) setContractPdfError(null) // Reuse only if the populated HTML hasn't changed. if (contractPdfUrl && contractPdfKey === contractPdfCacheKey) return if (contractPdfUrl) { URL.revokeObjectURL(contractPdfUrl) setContractPdfUrl('') } setContractPdfLoading(true) try { const [jsPdfMod, html2canvasMod] = await Promise.all([import('jspdf'), import('html2canvas')]) const jsPDF: any = (jsPdfMod as any).jsPDF || (jsPdfMod as any).default const html2canvas: any = (html2canvasMod as any).default || html2canvasMod const parser = new DOMParser() const doc = parser.parseFromString(populatedContractHtml, 'text/html') const styles = Array.from(doc.querySelectorAll('style')) .map(s => s.textContent || '') .join('\n') const bodyHtml = doc.body?.innerHTML || '' const wrapper = document.createElement('div') wrapper.style.position = 'fixed' wrapper.style.left = '-10000px' wrapper.style.top = '0' wrapper.style.width = '794px' // approx A4 width at 96dpi wrapper.style.background = '#ffffff' wrapper.innerHTML = `${bodyHtml}` document.body.appendChild(wrapper) try { const pdf = new jsPDF({ orientation: 'p', unit: 'pt', format: 'a4' }) const pageWidth = pdf.internal.pageSize.getWidth() const pageHeight = pdf.internal.pageSize.getHeight() const marginX = 24 const marginTop = 24 const marginBottom = 24 const usableWidth = pageWidth - marginX * 2 const usableHeight = pageHeight - marginTop - marginBottom const renderCanvasToPdf = (canvas: HTMLCanvasElement, pageIndex: number) => { const imgWidthPt = usableWidth const pxPerPt = canvas.width / imgWidthPt const sliceHeightPx = Math.max(1, Math.floor(usableHeight * pxPerPt)) let yPx = 0 let sliceIndex = 0 while (yPx < canvas.height) { const remainingPx = canvas.height - yPx const currentSliceHeightPx = Math.min(sliceHeightPx, remainingPx) const sliceCanvas = document.createElement('canvas') sliceCanvas.width = canvas.width sliceCanvas.height = currentSliceHeightPx const ctx = sliceCanvas.getContext('2d') if (!ctx) break ctx.drawImage( canvas, 0, yPx, canvas.width, currentSliceHeightPx, 0, 0, canvas.width, currentSliceHeightPx ) const imgData = sliceCanvas.toDataURL('image/png') const imgHeightPt = currentSliceHeightPx / pxPerPt const isFirstOverall = pageIndex === 0 && sliceIndex === 0 if (!isFirstOverall) pdf.addPage() pdf.addImage(imgData, 'PNG', marginX, marginTop, imgWidthPt, imgHeightPt) yPx += currentSliceHeightPx sliceIndex++ } } // Split at explicit .pageBreak markers (in document order, even when nested) // to avoid cutting content between pages. const docRoot = wrapper.querySelector('.doc') as HTMLElement | null const pageRoot = docRoot ?? wrapper const breakEls = Array.from(pageRoot.querySelectorAll('.pageBreak')) as HTMLElement[] if (breakEls.length === 0) { const canvas: HTMLCanvasElement = await html2canvas(wrapper, { scale: Math.min(2, window.devicePixelRatio || 1), backgroundColor: '#ffffff', useCORS: true, }) renderCanvasToPdf(canvas, 0) } else { const range = document.createRange() range.setStart(pageRoot, 0) const fragments: DocumentFragment[] = [] for (const br of breakEls) { range.setEndBefore(br) const frag = range.cloneContents() if (frag.childNodes.length > 0) fragments.push(frag) range.setStartAfter(br) } range.setEnd(pageRoot, pageRoot.childNodes.length) const lastFrag = range.cloneContents() if (lastFrag.childNodes.length > 0) fragments.push(lastFrag) if (fragments.length === 0) { const canvas: HTMLCanvasElement = await html2canvas(wrapper, { scale: Math.min(2, window.devicePixelRatio || 1), backgroundColor: '#ffffff', useCORS: true, }) renderCanvasToPdf(canvas, 0) } else { for (let pageIndex = 0; pageIndex < fragments.length; pageIndex++) { const pageWrapper = document.createElement('div') pageWrapper.style.position = 'fixed' pageWrapper.style.left = '-10000px' pageWrapper.style.top = '0' pageWrapper.style.width = '794px' pageWrapper.style.background = '#ffffff' pageWrapper.innerHTML = `` const pageDoc = document.createElement('div') pageDoc.className = docRoot?.className || 'doc' pageDoc.appendChild(fragments[pageIndex]) pageWrapper.appendChild(pageDoc) document.body.appendChild(pageWrapper) try { const canvas: HTMLCanvasElement = await html2canvas(pageWrapper, { scale: Math.min(2, window.devicePixelRatio || 1), backgroundColor: '#ffffff', useCORS: true, }) renderCanvasToPdf(canvas, pageIndex) } finally { document.body.removeChild(pageWrapper) } } } } const blob = pdf.output('blob') as Blob const url = URL.createObjectURL(blob) setContractPdfUrl(url) setContractPdfKey(contractPdfCacheKey) } finally { document.body.removeChild(wrapper) } } catch (e: any) { setContractPdfError(e?.message || 'Failed to generate PDF preview.') } finally { setContractPdfLoading(false) } } useEffect(() => { try { 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); } } catch {} }, []); useEffect(() => { if (!showThanks) return; const items = Array.from({ length: 40 }).map(() => ({ left: Math.random() * 100, delay: Math.random() * 0.6, color: COLORS[Math.floor(Math.random() * COLORS.length)], })); setConfetti(items); }, [showThanks]); const selectedEntries = useMemo( () => Object.entries(selections) .map(([id, qty]) => { const coffee = coffees.find(c => c.id === id); return coffee ? { coffee, quantity: qty } : null; }) .filter(Boolean) as { coffee: ReturnType['coffees'][number]; quantity: number }[], [selections, coffees] ); // NEW: computed packs/capsules for validation const totalCapsules = useMemo( () => selectedEntries.reduce((sum, e) => sum + e.quantity, 0), [selectedEntries] ) const totalPacks = totalCapsules / 10 const requiredPacks = selectedPlanCapsules / 10 // NEW: capture logged-in user id for referral const rawUserId = user?.id const currentUserId = typeof rawUserId === 'number' ? rawUserId : (typeof rawUserId === 'string' && /^\d+$/.test(rawUserId) ? Number(rawUserId) : undefined) console.info('[SummaryPage] currentUserId:', currentUserId) // Countries list from backend VAT rates (fallback to current country if list empty) const countryOptions = useMemo(() => { const currentCode = (form.country || 'DE').toUpperCase(); const opts = vatRates.length > 0 ? vatRates.map(r => r.code) : [currentCode] if (!opts.includes(currentCode)) opts.unshift(currentCode) console.info('[SummaryPage] countryOptions:', opts) return opts }, [vatRates, form.country]); // Load VAT rates list from backend and set initial taxRate useEffect(() => { let active = true; (async () => { 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 = 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(mountCountry); console.info('[SummaryPage] Fallback taxRate via getStandardVatRate:', rate, 'country:', upper) setTaxRate(rate ?? 0.07); } })(); return () => { active = false; }; }, []); // mount-only // Update taxRate when country changes (from backend only) useEffect(() => { let active = true; (async () => { const upper = form.country.toUpperCase(); console.info('[SummaryPage] Country changed:', upper) const fromList = vatRates.find(r => r.code === upper)?.rate; if (fromList != null) { console.info('[SummaryPage] taxRate from existing list:', fromList) if (active) setTaxRate(fromList); return; } const rate = await getStandardVatRate(form.country); console.info('[SummaryPage] taxRate via getStandardVatRate:', rate) if (active) setTaxRate(rate ?? 0.07); })(); return () => { active = false; }; }, [form.country, vatRates]); const totalPrice = useMemo( () => 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 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) => { const { name, value } = e.target; setForm(prev => ({ ...prev, [name]: value })); }; const handleCheckbox = (e: React.ChangeEvent) => { const { name, checked } = e.target; setForm(prev => ({ ...prev, [name]: checked })); }; const fillFromLoggedInData = () => { if (!user) { setSubmitError('No logged-in user data found to fill the fields.'); return; } setSubmitError(null); setForm(prev => ({ ...prev, 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), })); }; const requiredSelfFields = [ 'firstName', 'lastName', 'email', 'street', 'postalCode', 'city', 'country', ] as const const hasRequiredSelfFields = requiredSelfFields.every(k => String(form[k]).trim() !== '') const hasRequiredInvoiceFields = form.invoiceSameAsShipping || form.invoiceEmail.trim() !== '' const hasSigningCity = form.signingCity.trim() !== '' const hasSignature = signatureDataUrl.trim() !== '' const canSubmit = selectedEntries.length > 0 && totalPacks === requiredPacks && hasRequiredSelfFields && hasRequiredInvoiceFields && hasSigningCity && hasSignature; const backToSelection = () => router.push('/coffee-abonnements'); const submit = async () => { if (!canSubmit || submitLoading) return // NEW: guard (defensive) — backend requires selected package size if (totalPacks !== requiredPacks) { setSubmitError(`Order must contain exactly ${requiredPacks} packs (${selectedPlanCapsules} capsules).`) return } if (!hasSigningCity) { setSubmitError('Signing city is required.') return } if (!hasSignature) { setSubmitError('Signature is required.') return } setSubmitError(null) setSubmitLoading(true) try { const payload = { items: selectedEntries.map(entry => ({ coffeeId: entry.coffee.id, quantity: Math.round(entry.quantity / 10), // packs })), billing_interval: 'month', interval_count: 1, is_auto_renew: true, is_for_self: true, firstName: form.firstName.trim(), lastName: form.lastName.trim(), email: form.email.trim(), street: form.street.trim(), postalCode: form.postalCode.trim(), city: form.city.trim(), country: form.country.trim(), frequency: 'monatlich', phone: form.phone.trim() || undefined, recipientContractName: `${form.firstName} ${form.lastName}`.trim() || undefined, paymentMethod: form.paymentMethod, invoiceByEmail: form.invoiceByEmail, invoiceSameAsShipping: form.invoiceSameAsShipping, ...(!form.invoiceSameAsShipping ? { invoiceFullName: form.invoiceFullName.trim() || undefined, invoiceStreet: form.invoiceStreet.trim() || undefined, invoicePostalCode: form.invoicePostalCode.trim() || undefined, invoiceCity: form.invoiceCity.trim() || undefined, invoicePhone: form.invoicePhone.trim() || undefined, invoiceEmail: form.invoiceEmail.trim() || undefined, } : {}), 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) // NEW: explicit JSON preview to match request body console.info('[SummaryPage] subscribeAbo payload JSON:', JSON.stringify(payload)) await subscribeAbo(payload) setGuestMailtoHref('') setGuestInviteLink('') setShowThanks(true); try { sessionStorage.removeItem('coffeeSelections'); } catch {} try { sessionStorage.removeItem('coffeeAboSizeCapsules'); } catch {} } catch (e: any) { setSubmitError(e?.message || 'Subscription could not be created.'); } finally { setSubmitLoading(false); } }; return (

Summary & Details

{/* Stepper */}
1 Selection
2 Summary
{error && (

{error}

)} {/* submit error */} {submitError && (

{submitError}

)} {loading ? (
) : selectedEntries.length === 0 ? (

No selection found.

) : (
{/* Left: Customer data */}

1. Your details

{/* inputs translated */}
{/* Payment method */}

Payment method

{(['sepa', 'card', 'sofort'] as const).map(method => ( ))}
{/* 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 && (
)}
{/* Contract preview + signature */}

Contract preview (ABO)

Contract variables are auto-populated from your form data.

{contractLoading ? (
Loading contract preview…
) : contractError ? (
Contract preview could not be loaded: {contractError}
) : populatedContractHtml ? ( ) : (
Contract template is not available.
)}
{!hasSigningCity && submitError && (

Ort ist erforderlich.

)}
ABO contract preview (PDF) {contractPdfError ? (
PDF preview could not be generated: {contractPdfError}
) : contractPdfLoading ? (
Generating PDF preview…
) : contractPdfUrl ? (