'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 } 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 [isForSelf, setIsForSelf] = useState(true); const [signatureDataUrl, setSignatureDataUrl] = useState('') const [form, setForm] = useState({ firstName: '', lastName: '', email: '', street: '', postalCode: '', city: '', country: 'DE', frequency: 'monatlich', startDate: '', recipientEmail: '', recipientName: '', recipientNotes: '', }); 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>({}) useEffect(() => { if (!templateVariableNamesKey) return setContractVariables(prev => { let changed = false const next: Record = { ...prev } for (const name of templateVariableNames) { if (next[name] === undefined) { next[name] = '' changed = true } } return changed ? next : prev }) }, [templateVariableNamesKey, templateVariableNames]) 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 taxAmount = useMemo(() => totalPrice * taxRate, [totalPrice, taxRate]); const taxAmountWithShipping = useMemo(() => netWithShipping * taxRate, [netWithShipping, taxRate]); const totalWithTax = useMemo(() => netWithShipping + taxAmountWithShipping, [netWithShipping, taxAmountWithShipping]); const handleInput = (e: React.ChangeEvent) => { const { name, value } = e.target; setForm(prev => ({ ...prev, [name]: value })); }; const handleRecipientNotes = (e: React.ChangeEvent) => { const { name, value } = e.target; setForm(prev => ({ ...prev, [name]: value })); }; const fillFromLoggedInData = () => { if (!user) { setSubmitError('No logged-in user data found to fill the fields.'); 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(), })); }; const requiredSelfFields: Array = [ 'firstName', 'lastName', 'email', 'street', 'postalCode', 'city', 'country', 'frequency', ] const hasRequiredSelfFields = requiredSelfFields.every(k => form[k].trim() !== '') const hasRequiredGiftFields = isForSelf || form.recipientEmail.trim() !== '' const canSubmit = selectedEntries.length > 0 && totalPacks === requiredPacks && hasRequiredSelfFields && hasRequiredGiftFields; 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 (!isForSelf && !form.recipientEmail.trim()) { setSubmitError('Recipient email is required when the subscription is for someone else.') return } setSubmitError(null) setSubmitLoading(true) try { const recipientEmail = form.recipientEmail.trim() const recipientName = form.recipientName.trim() 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: isForSelf, // NEW: pass customer fields 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: form.frequency.trim(), startDate: form.startDate.trim() || undefined, recipient_email: isForSelf ? undefined : form.recipientEmail.trim(), recipient_name: isForSelf ? undefined : (form.recipientName.trim() || undefined), recipient_notes: isForSelf ? undefined : (form.recipientNotes.trim() || undefined), // NEW: always include referred_by if available 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) // TEMP: Guest email workaround (ignore contract/PDF for mail) // Open an email draft to the recipient when subscription is for someone else. if (!isForSelf && recipientEmail) { try { // A referral token is required for /register, so we generate a 1-time referral link. const refRes = await createReferralLink({ expiresInDays: 7, maxUses: 1 }) const refBody: any = (refRes as any)?.body const refCode = refBody?.data?.code || refBody?.data?.token || refBody?.data?.ref || refBody?.code || refBody?.token || refBody?.ref || '' const origin = typeof window !== 'undefined' ? window.location.origin : '' const guestLink = refCode ? (origin ? `${origin}/register?ref=${encodeURIComponent(String(refCode))}&guest=true` : `/register?ref=${encodeURIComponent(String(refCode))}&guest=true`) : '' setGuestInviteLink(guestLink) if (!guestLink) { console.warn('[SummaryPage] Guest invite: could not generate referral token/link', { refBody }) setGuestMailtoHref('') } else { const subject = 'Profit Planet – Guest access for your coffee abonnement' const body = `Hallo${recipientName ? ` ${recipientName}` : ''},\n\n` + `du wurdest eingeladen, um Zugriff auf dein Kaffee-Abonnement zu erhalten.\n\n` + `Bitte registriere dich hier als Gast:\n${guestLink}\n\n` + `Liebe Grüße\nProfit Planet` const mailto = `mailto:${recipientEmail}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}` setGuestMailtoHref(mailto) try { window.location.href = mailto } catch {} } } catch (e) { console.warn('[SummaryPage] Guest invite: failed to create referral link', e) setGuestMailtoHref('') setGuestInviteLink('') } } else { 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

{/* Toggle: For myself / For someone else */}
{/* inputs translated */}
{!isForSelf && ( <>