profit-planet-frontend/src/app/coffee-abonnements/summary/page.tsx

905 lines
49 KiB
TypeScript

'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, type SubscribeAboInput } from './hooks/subscribeAbo';
import useAuthStore from '../../store/authStore'
import { useShippingFees } from '../hooks/useShippingFees';
import { useTranslation } from '../../i18n/useTranslation';
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'
import SubscribeGuard from '../components/SubscribeGuard'
import {
COFFEE_SELECTIONS_STORAGE_KEY,
COFFEE_SELECTIONS_UNIT_STORAGE_KEY,
getOrderPackError,
getRemainingMinPacks,
MAX_ABO_PACKS,
MIN_ABO_PACKS,
normalizeStoredSelections,
packsToCapsules,
} from '../lib/orderRules'
const COLORS = ['#1C2B4A', '#233357', '#2A3B66', '#314475', '#3A4F88', '#5B6C9A'];
function extractTemplateVariables(templateHtml: string | null | undefined): string[] {
if (!templateHtml) return []
const vars = new Set<string>()
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
function hashString(value: string): number {
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 ''
}
// ── shared input class
const inputCls = 'block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm placeholder:text-slate-400 focus:ring-2 focus:ring-slate-900 focus:border-transparent';
const labelCls = 'block text-sm font-semibold text-slate-700 mb-1';
const requiredMarkCls = 'ml-1 text-red-500';
export default function SummaryPage() {
return (
<SubscribeGuard>
<SummaryPageContent />
</SubscribeGuard>
)
}
function SummaryPageContent() {
const { t } = useTranslation();
const router = useRouter();
const { coffees, loading, error } = useActiveCoffees();
const user = useAuthStore(state => state.user)
const { resolveShippingFee, loading: shippingLoading, error: shippingError } = useShippingFees();
const { html: contractHtml, loading: contractLoading, error: contractError } = useAboContractTemplateHtml()
const [isContractPreviewOpen, setIsContractPreviewOpen] = useState(false)
const [contractPdfUrl, setContractPdfUrl] = useState<string>('')
const [contractPdfKey, setContractPdfKey] = useState<string>('')
const [contractPdfLoading, setContractPdfLoading] = useState(false)
const [contractPdfError, setContractPdfError] = useState<string | null>(null)
const [selections, setSelections] = useState<Record<string, number>>({});
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 [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false);
const [guestMailtoHref, setGuestMailtoHref] = useState<string>('')
const [guestInviteLink, setGuestInviteLink] = useState<string>('')
const [confetti, setConfetti] = useState<{ left: number; delay: number; color: string }[]>([]);
const [taxRate, setTaxRate] = useState(0.07);
const [vatRates, setVatRates] = useState<{ code: string; rate: number | null }[]>([]);
const [submitError, setSubmitError] = useState<string | null>(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<Record<string, string>>({})
const normalizedUserType = String(user?.userType ?? user?.user_type ?? '').trim().toLowerCase()
const isCompanyCustomer = normalizedUserType === 'company'
const profileUidNumber = useMemo(() => {
if (!isCompanyCustomer) return ''
return normalizeUid(pickFirstString(
user?.uidNumber, user?.uid_number, user?.atuNumber, user?.atu_number,
user?.companyProfile?.uid_number, user?.companyProfile?.atu_number
))
}, [isCompanyCustomer, user])
const enteredUidNumber = useMemo(() => normalizeUid(form.uidNumber), [form.uidNumber])
const effectiveUidNumber = isCompanyCustomer ? (enteredUidNumber || profileUidNumber) : ''
const hasValidCompanyUid = isCompanyCustomer && isLikelyValidUid(effectiveUidNumber)
const isForeignInvoiceCountry = form.country.toUpperCase() !== HOME_COUNTRY_CODE
const isReverseCharge = isCompanyCustomer && hasValidCompanyUid && isForeignInvoiceCountry
useEffect(() => {
if (isCompanyCustomer) return
setForm(prev => (prev.uidNumber ? { ...prev, uidNumber: '' } : prev))
}, [isCompanyCustomer])
useEffect(() => {
if (!templateVariableNamesKey) return
const fullName = `${form.firstName} ${form.lastName}`.trim()
const invoiceSame = form.invoiceSameAsShipping
const computed: Record<string, string> = {
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: isCompanyCustomer ? 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
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 `<img alt="Signature" src="${src}" style="max-width: 100%; max-height: 120px;" />`
}
}
const value = contractVariables[varName] ?? ''
return escapeHtml(String(value))
})
}, [contractHtml, contractVariables, signatureDataUrl])
const contractPdfCacheKey = useMemo(() => {
if (!populatedContractHtml) return ''
return String(hashString(populatedContractHtml))
}, [populatedContractHtml])
useEffect(() => {
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)
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'
wrapper.style.background = '#ffffff'
wrapper.innerHTML = `<style>${styles}</style>${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, marginTop = 24, 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, 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++
}
}
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 = `<style>${styles}</style>`
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(COFFEE_SELECTIONS_STORAGE_KEY);
const unit = sessionStorage.getItem(COFFEE_SELECTIONS_UNIT_STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
setSelections(normalizeStoredSelections(parsed, unit));
}
}
} catch {}
}, []);
useEffect(() => {
if (!showThanks) return;
setConfetti(Array.from({ length: 40 }).map(() => ({
left: Math.random() * 100,
delay: Math.random() * 0.6,
color: COLORS[Math.floor(Math.random() * COLORS.length)],
})));
}, [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<typeof useActiveCoffees>['coffees'][number]; quantity: number }[],
[selections, coffees]
);
const totalPacks = useMemo(() => selectedEntries.reduce((sum, e) => sum + e.quantity, 0), [selectedEntries])
const totalCapsules = useMemo(() => packsToCapsules(totalPacks), [totalPacks])
const orderPackError = useMemo(() => getOrderPackError(totalPacks), [totalPacks])
const remainingMinPacks = useMemo(() => getRemainingMinPacks(totalPacks), [totalPacks])
const rawUserId = user?.id
const currentUserId = typeof rawUserId === 'number' ? rawUserId : (typeof rawUserId === 'string' && /^\d+$/.test(rawUserId) ? Number(rawUserId) : undefined)
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)
return opts
}, [vatRates, form.country]);
useEffect(() => {
let active = true;
(async () => {
const mountCountry = initialCountryRef.current
const list = await getVatRates();
if (!active) return;
setVatRates(list);
const upper = mountCountry.toUpperCase();
const match = list.find(r => r.code === upper);
if (match?.rate != null) { setTaxRate(match.rate); }
else { const rate = await getStandardVatRate(mountCountry); setTaxRate(rate ?? 0.07); }
})();
return () => { active = false; };
}, []);
useEffect(() => {
let active = true;
(async () => {
const upper = form.country.toUpperCase();
const fromList = vatRates.find(r => r.code === upper)?.rate;
if (fromList != null) { if (active) setTaxRate(fromList); return; }
const rate = await getStandardVatRate(form.country);
if (active) setTaxRate(rate ?? 0.07);
})();
return () => { active = false; };
}, [form.country, vatRates]);
const totalPrice = useMemo(() => selectedEntries.reduce((sum, e) => sum + e.quantity * e.coffee.pricePer10, 0), [selectedEntries]);
const shippingFee = useMemo(() => resolveShippingFee(totalCapsules), [resolveShippingFee, totalCapsules]);
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<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setForm(prev => ({ ...prev, [name]: value }));
};
const handleCheckbox = (e: React.ChangeEvent<HTMLInputElement>) => {
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: isCompanyCustomer
? 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 && !orderPackError && hasRequiredSelfFields && hasRequiredInvoiceFields && hasSigningCity && hasSignature;
const canAttemptSubmit = selectedEntries.length > 0 && !orderPackError && !submitLoading
const firstNameError = hasAttemptedSubmit && form.firstName.trim() === ''
const lastNameError = hasAttemptedSubmit && form.lastName.trim() === ''
const emailError = hasAttemptedSubmit && form.email.trim() === ''
const streetError = hasAttemptedSubmit && form.street.trim() === ''
const postalCodeError = hasAttemptedSubmit && form.postalCode.trim() === ''
const cityError = hasAttemptedSubmit && form.city.trim() === ''
const countryError = hasAttemptedSubmit && form.country.trim() === ''
const invoiceEmailError = hasAttemptedSubmit && !form.invoiceSameAsShipping && form.invoiceEmail.trim() === ''
const signingCityError = hasAttemptedSubmit && !hasSigningCity
const signatureError = hasAttemptedSubmit && !hasSignature
const getFieldClassName = (hasError: boolean, extraClassName = '') => {
const errorClassName = hasError ? 'border-red-400 focus:ring-red-400' : ''
return `${inputCls} ${errorClassName} ${extraClassName}`.trim()
}
const renderLabel = (label: string, required = false) => (
<>
{label}
{required && <span className={requiredMarkCls}>*</span>}
</>
)
const backToSelection = () => router.push('/coffee-abonnements');
const submit = async () => {
if (submitLoading) return
setHasAttemptedSubmit(true)
if (selectedEntries.length === 0) {
setSubmitError(`Order must contain at least ${MIN_ABO_PACKS} packs (${packsToCapsules(MIN_ABO_PACKS)} capsules).`)
return
}
if (orderPackError) { setSubmitError(orderPackError); return }
if (!hasRequiredSelfFields || !hasRequiredInvoiceFields) {
setSubmitError('Please fill in all required fields.')
return
}
if (!hasSigningCity) { setSubmitError('Signing city is required.'); return }
if (!hasSignature) { setSubmitError('Signature is required.'); return }
setSubmitError(null)
setSubmitLoading(true)
try {
const payload: SubscribeAboInput = {
items: selectedEntries.map(entry => ({ coffeeId: entry.coffee.id, quantity: entry.quantity })),
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,
...(isCompanyCustomer ? {
uidNumber: effectiveUidNumber || undefined,
atuNumber: effectiveUidNumber || undefined,
taxMode: isReverseCharge ? 'reverse_charge' : 'standard_vat',
} : {}),
referred_by: typeof currentUserId === 'number' ? currentUserId : undefined,
}
await subscribeAbo(payload)
setHasAttemptedSubmit(false)
setGuestMailtoHref(''); setGuestInviteLink(''); setShowThanks(true);
try { sessionStorage.removeItem(COFFEE_SELECTIONS_STORAGE_KEY); } catch {}
try { sessionStorage.removeItem(COFFEE_SELECTIONS_UNIT_STORAGE_KEY); } catch {}
} catch (e: any) {
setSubmitError(e?.message || 'Subscription could not be created.');
} finally {
setSubmitLoading(false);
}
};
return (
<PageLayout contentClassName="flex-1 relative w-full">
<div
className="min-h-screen"
style={{ background: 'radial-gradient(circle at top left,rgba(251,191,36,0.10),transparent 22%),radial-gradient(circle at top right,rgba(56,189,248,0.10),transparent 24%),linear-gradient(180deg,#f8fafc 0%,#f8fafc 50%,#eef2ff 100%)' }}
>
<div className="max-w-455 mx-auto px-4 sm:px-6 xl:px-10 py-8 space-y-5">
{/* Header card */}
<div className="rounded-[28px] border border-white/80 bg-white/90 px-8 py-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold tracking-tight text-slate-900">
<span>{t('autofix.k21361e0d')}</span>
</h1>
<p className="mt-1 text-sm text-slate-500">Step 2 of 2 &mdash; Review your order and complete checkout</p>
</div>
<button
onClick={backToSelection}
className="inline-flex items-center gap-2 rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-700 shadow-sm hover:bg-slate-50 transition"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
{t('autofix.k96839795')}
</button>
</div>
{/* Stepper */}
<div className="rounded-[28px] border border-white/80 bg-white/90 px-8 py-4 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur">
<div className="flex items-center gap-3 text-sm">
<div className="flex items-center gap-2 opacity-50">
<span className="h-7 w-7 rounded-full bg-slate-200 text-slate-500 flex items-center justify-center text-xs font-bold">1</span>
<span className="font-medium text-slate-500">Selection</span>
</div>
<div className="h-px flex-1 bg-slate-200" />
<div className="flex items-center gap-2">
<span className="h-7 w-7 rounded-full bg-slate-900 text-white flex items-center justify-center text-xs font-bold">2</span>
<span className="font-semibold text-slate-900">Summary</span>
</div>
</div>
</div>
{/* Errors */}
{(error || submitError) && (
<div className="rounded-[28px] border border-red-100 bg-red-50 px-6 py-4 shadow-sm text-sm text-red-700 space-y-1">
{error && <p>{error}</p>}
{submitError && <p>{submitError}</p>}
</div>
)}
{loading ? (
<div className="rounded-[28px] border border-white/80 bg-white/90 p-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur">
<div className="h-20 rounded-xl bg-slate-100 animate-pulse" />
</div>
) : selectedEntries.length === 0 ? (
<div className="rounded-[28px] border border-white/80 bg-white/90 p-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur">
<p className="text-sm text-slate-600 mb-4">{t('autofix.k20127e1c')}</p>
<button onClick={backToSelection} className="inline-flex items-center gap-2 rounded-xl bg-slate-900 text-white px-5 py-2.5 text-sm font-semibold hover:bg-slate-700 transition">
{t('autofix.k96839795')}
</button>
</div>
) : (
<div className="grid gap-5 xl:grid-cols-3">
{/* ── LEFT: Customer data + contract */}
<section className="xl:col-span-2 space-y-5">
{/* Customer details card */}
<div className="rounded-[28px] border border-white/80 bg-white/90 p-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur space-y-6">
<div className="flex items-center justify-between gap-4 flex-wrap">
<h2 className="text-lg font-bold text-slate-900">{t('autofix.kd6f8d7e9')}</h2>
<button
type="button"
onClick={fillFromLoggedInData}
className="inline-flex items-center gap-2 rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-700 shadow-sm hover:bg-slate-50 transition"
>{t('autofix.k9c1a5ecc')}</button>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className={labelCls}>{renderLabel(t('autofix.kfe9527d8'), true)}</label>
<input name="firstName" value={form.firstName} onChange={handleInput} className={getFieldClassName(firstNameError)} />
</div>
<div>
<label className={labelCls}>{renderLabel(t('autofix.k6a2c64e8'), true)}</label>
<input name="lastName" value={form.lastName} onChange={handleInput} className={getFieldClassName(lastNameError)} />
</div>
<div className="sm:col-span-2">
<label className={labelCls}>{renderLabel('Email', true)}</label>
<input type="email" name="email" value={form.email} onChange={handleInput} className={getFieldClassName(emailError)} />
</div>
<div className="sm:col-span-2">
<label className={labelCls}>{renderLabel(t('autofix.kd1a2772d'), true)}</label>
<input name="street" value={form.street} onChange={handleInput} className={getFieldClassName(streetError)} />
</div>
<div>
<label className={labelCls}>{renderLabel('ZIP', true)}</label>
<input name="postalCode" value={form.postalCode} onChange={handleInput} className={getFieldClassName(postalCodeError)} />
</div>
<div>
<label className={labelCls}>{renderLabel('City', true)}</label>
<input name="city" value={form.city} onChange={handleInput} className={getFieldClassName(cityError)} />
</div>
<div>
<label className={labelCls}>{renderLabel('Country', true)}</label>
<select name="country" value={form.country} onChange={handleInput} className={getFieldClassName(countryError)}>
{countryOptions.map(code => <option key={code} value={code}>{code}</option>)}
</select>
</div>
<div>
<label className={labelCls}>Phone (optional)</label>
<input name="phone" value={form.phone} onChange={handleInput} className={inputCls} />
</div>
</div>
{/* Payment method */}
<div className="border-t border-slate-100 pt-5">
<h3 className="text-sm font-bold text-slate-900 mb-3">{t('autofix.k3466b0e0')}</h3>
<div className="flex flex-wrap gap-3">
{(['sepa', 'card', 'sofort'] as const).map(method => (
<label key={method} className={`flex items-center gap-2 rounded-xl border px-4 py-2.5 cursor-pointer transition text-sm font-medium ${form.paymentMethod === method ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-200 bg-white text-slate-700 hover:bg-slate-50'}`}>
<input type="radio" name="paymentMethod" value={method} checked={form.paymentMethod === method} onChange={handleInput} className="sr-only" />
{method === 'sepa' ? 'SEPA' : method === 'card' ? t('autofix.k113e47af') : t('autofix.k2f00d2db')}
</label>
))}
</div>
<label className="mt-3 flex items-center gap-2 text-sm font-medium text-slate-700">
<input type="checkbox" name="invoiceByEmail" checked={form.invoiceByEmail} onChange={handleCheckbox} className="h-4 w-4 rounded border-slate-300 text-slate-900 focus:ring-slate-900" />
{t('autofix.ke33e6fbf')}
</label>
</div>
{/* Invoice address */}
<div className="border-t border-slate-100 pt-5">
<h3 className="text-sm font-bold text-slate-900 mb-3">{t('autofix.kce094582')}</h3>
{isCompanyCustomer && (
<div className="mb-3 rounded-xl border border-blue-200 bg-blue-50 px-4 py-3 text-xs text-blue-900">
Unternehmer mit ¼ltiger UID und Rechnungsland außerhalb von {HOME_COUNTRY_CODE} werden per Reverse Charge ohne ausgewiesene MwSt verrechnet.
</div>
)}
<label className="flex items-center gap-2 text-sm font-medium text-slate-700 mb-3">
<input type="checkbox" name="invoiceSameAsShipping" checked={form.invoiceSameAsShipping} onChange={handleCheckbox} className="h-4 w-4 rounded border-slate-300 text-slate-900 focus:ring-slate-900" />
{t('autofix.k528eede9')}
</label>
{isCompanyCustomer && (
<div className="mb-4">
<label className={labelCls}>UID Number (optional)</label>
<input name="uidNumber" value={form.uidNumber} onChange={handleInput} placeholder={t('autofix.kf1512f8f')} className={`${inputCls} uppercase`} />
<p className="mt-1 text-xs text-slate-500">{t('autofix.kefe5f0dd')}</p>
</div>
)}
{!form.invoiceSameAsShipping && (
<div className="grid gap-4 sm:grid-cols-2 mt-2">
<div className="sm:col-span-2">
<label className={labelCls}>{t('autofix.k28f1a9b1')}</label>
<input name="invoiceFullName" value={form.invoiceFullName} onChange={handleInput} className={inputCls} />
</div>
<div className="sm:col-span-2">
<label className={labelCls}>{t('autofix.kd1a2772d')}</label>
<input name="invoiceStreet" value={form.invoiceStreet} onChange={handleInput} className={inputCls} />
</div>
<div>
<label className={labelCls}>ZIP</label>
<input name="invoicePostalCode" value={form.invoicePostalCode} onChange={handleInput} className={inputCls} />
</div>
<div>
<label className={labelCls}>City</label>
<input name="invoiceCity" value={form.invoiceCity} onChange={handleInput} className={inputCls} />
</div>
<div>
<label className={labelCls}>Phone (optional)</label>
<input name="invoicePhone" value={form.invoicePhone} onChange={handleInput} className={inputCls} />
</div>
<div>
<label className={labelCls}>{renderLabel('Email', true)}</label>
<input type="email" name="invoiceEmail" value={form.invoiceEmail} onChange={handleInput} className={getFieldClassName(invoiceEmailError)} />
</div>
</div>
)}
</div>
</div>
{/* Contract + signature card */}
<div className="rounded-[28px] border border-white/80 bg-white/90 p-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur space-y-5">
<div>
<h2 className="text-lg font-bold text-slate-900">Contract preview (ABO)</h2>
<p className="mt-1 text-xs text-slate-500">{t('autofix.k155166db')}</p>
</div>
{contractLoading ? (
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">{t('autofix.k0bbc633d')}</div>
) : contractError ? (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">Contract preview could not be loaded: {contractError}</div>
) : populatedContractHtml ? (
<button type="button" onClick={openContractPreview}
className="inline-flex items-center gap-2 rounded-xl bg-slate-900 text-white px-5 py-2.5 text-sm font-semibold hover:bg-slate-700 transition">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
{t('autofix.kd379df9b')}
</button>
) : (
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">{t('autofix.ke74b1adf')}</div>
)}
<div className="space-y-4">
<div>
<label className={labelCls}>{renderLabel('Ort (Signing City)', true)}</label>
<input type="text" name="signingCity" value={form.signingCity} onChange={handleInput}
className={getFieldClassName(signingCityError, 'max-w-xs')}
placeholder={t('autofix.k1f0b2c48')} />
{signingCityError && <p className="mt-1 text-xs text-red-600">{t('autofix.k516705dd')}</p>}
</div>
<SignaturePad value={signatureDataUrl} onChange={setSignatureDataUrl} required error={signatureError ? 'Signature is required.' : null} />
</div>
<button
onClick={submit}
disabled={!canAttemptSubmit}
className={`w-full rounded-xl px-5 py-3.5 text-sm font-semibold transition inline-flex items-center justify-center gap-2 shadow-sm ${
canAttemptSubmit ? 'bg-slate-900 text-white hover:bg-slate-700' : 'bg-slate-100 text-slate-400 cursor-not-allowed'
}`}
>
{submitLoading && <svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z" /></svg>}
{submitLoading ? t('autofix.k27b5b842') : t('autofix.k737db983')}
{!submitLoading && <svg className="w-4 h-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fillRule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l5.999 6a1 1 0 010 1.414l-6 6a1 1 0 11-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clipRule="evenodd" /></svg>}
</button>
{!canSubmit && <p className="text-xs text-slate-500 text-center">{orderPackError ?? 'Fill in all required fields marked with * and provide your signature to finish the subscription.'}</p>}
</div>
</section>
{/* ── RIGHT: Order summary with pictures */}
<section className="xl:col-span-1 xl:sticky xl:top-6 space-y-5 self-start">
{/* Coffee items card */}
<div className="rounded-[28px] border border-white/80 bg-white/90 p-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur">
<h2 className="text-lg font-bold text-slate-900 mb-4">{t('autofix.k4aeb8688')}</h2>
<div className="space-y-4">
{selectedEntries.map(entry => (
<div key={entry.coffee.id} className="flex gap-3 items-start">
{/* Coffee picture */}
<div className="shrink-0 w-16 h-16 rounded-xl overflow-hidden border border-slate-100 bg-slate-50">
{entry.coffee.image ? (
<img src={entry.coffee.image} alt={entry.coffee.name} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-slate-300">
<svg className="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
)}
</div>
{/* Details */}
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-slate-900 truncate">{entry.coffee.name}</p>
<p className="text-xs text-slate-500 mt-0.5">{entry.quantity} capsules &middot; {entry.quantity / 10} pack{entry.quantity / 10 !== 1 ? 's' : ''}</p>
<p className="text-xs text-slate-400">&euro;{entry.coffee.pricePer10}/10</p>
</div>
{/* Line total */}
<p className="text-sm font-bold text-slate-900 whitespace-nowrap">&euro;{((entry.quantity / 10) * entry.coffee.pricePer10).toFixed(2)}</p>
</div>
))}
</div>
</div>
{/* Price breakdown card */}
<div className="rounded-[28px] border border-white/80 bg-white/90 p-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur space-y-3">
<h3 className="text-sm font-bold text-slate-900">Price breakdown</h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between text-slate-600">
<span>Subtotal</span>
<span>&euro;{totalPrice.toFixed(2)}</span>
</div>
<div className="flex justify-between text-slate-600">
<span>Shipping</span>
<span>
{shippingLoading ? <span className="text-slate-400">Loading&hellip;</span>
: shippingFee === 0 ? <span className="text-emerald-600 font-semibold">FREE</span>
: <span>&euro;{shippingFee.toFixed(2)}</span>}
</span>
</div>
{shippingError && (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">{shippingError}</div>
)}
<div className="flex justify-between text-slate-600">
<span>{isReverseCharge ? 'Tax (Reverse Charge)' : `Tax (${(effectiveTaxRate * 100).toFixed(1)}%)`}</span>
<span>&euro;{taxAmountWithShipping.toFixed(2)}</span>
</div>
</div>
<div className="border-t border-slate-100 pt-3 flex justify-between items-center">
<span className="text-base font-bold text-slate-900">{t('autofix.k11438b4c')}</span>
<span className="text-xl font-extrabold text-slate-900">&euro;{totalWithTax.toFixed(2)}</span>
</div>
{isReverseCharge && (
<div className="rounded-xl border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-900">{t('autofix.k74491338')}</div>
)}
{/* Pack validation */}
<div className="rounded-xl border border-slate-100 bg-slate-50 px-3 py-2 text-xs text-slate-600">
<span className="font-semibold">{totalPacks.toLocaleString('en-US')}</span> packs selected.
<div className="text-slate-500">{totalCapsules.toLocaleString('en-US')} capsules total · minimum {MIN_ABO_PACKS} packs · maximum {MAX_ABO_PACKS.toLocaleString('en-US')} packs.</div>
{orderPackError && (
<div className="mt-1 rounded-lg bg-red-50 border border-red-200 text-red-700 px-2 py-1 font-medium">
{remainingMinPacks > 0
? `${remainingMinPacks} more pack${remainingMinPacks === 1 ? '' : 's'} needed to reach the minimum order.`
: orderPackError}
</div>
)}
</div>
</div>
</section>
</div>
)}
</div>
</div>
{/* Contract PDF dialog */}
<Dialog open={isContractPreviewOpen} onClose={closeContractPreview} size="5xl">
<DialogTitle>ABO contract preview (PDF)</DialogTitle>
<DialogBody>
{contractPdfError ? (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">PDF preview could not be generated: {contractPdfError}</div>
) : contractPdfLoading ? (
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">{t('autofix.k0b2445d5')}</div>
) : contractPdfUrl ? (
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden">
<iframe title={t('autofix.kaa5e5363')} className="w-full h-[75vh]" src={contractPdfUrl} />
</div>
) : (
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">{t('autofix.ka56b7b2b')}</div>
)}
</DialogBody>
<DialogActions>
<button type="button" onClick={closeContractPreview} className="rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50 transition">Close</button>
</DialogActions>
</Dialog>
{/* Thank you overlay */}
{showThanks && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="relative mx-4 w-full max-w-md rounded-[28px] bg-white p-8 text-center shadow-2xl">
<div className="pointer-events-none absolute inset-0 overflow-hidden rounded-[28px]">
{confetti.map((c, i) => (
<span key={i} className="confetti" style={{ left: `${c.left}%`, animationDelay: `${c.delay}s`, background: c.color }} />
))}
</div>
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-slate-900/10 text-slate-900 pop">
<svg viewBox="0 0 24 24" className="h-9 w-9" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M20 6L9 17l-5-5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<h3 className="text-2xl font-bold text-slate-900">{t('autofix.k0853cfa6')}</h3>
<p className="mt-1 text-sm text-slate-500">{t('autofix.kd3092148')}</p>
<div className="mt-6 grid gap-3 sm:grid-cols-2">
<button onClick={() => { setShowThanks(false); backToSelection(); }} className="rounded-xl bg-slate-900 text-white px-4 py-2.5 text-sm font-semibold hover:bg-slate-700 transition">{t('autofix.k96839795')}</button>
<button onClick={() => setShowThanks(false)} className="rounded-xl border border-slate-200 px-4 py-2.5 text-sm font-semibold text-slate-700 hover:bg-slate-50 transition">Close</button>
</div>
<style jsx>{`
.confetti { position: absolute; top: -10%; width: 8px; height: 12px; border-radius: 2px; opacity: 0.9; animation: fall 1.8s linear forwards; }
@keyframes fall { 0% { transform: translateY(0) rotate(0deg); } 100% { transform: translateY(110vh) rotate(720deg); } }
.pop { animation: pop 450ms ease-out forwards; }
@keyframes pop { 0% { transform: scale(0.6); opacity: 0; } 60% { transform: scale(1.08); opacity: 1; } 100% { transform: scale(1); } }
`}</style>
</div>
</div>
)}
</PageLayout>
);
}