profit-planet-frontend/src/app/coffee-abonnements/summary/page.tsx
2026-04-28 19:23:03 +02:00

1049 lines
48 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 } 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<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 {
// 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<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 [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<string>('')
const [guestInviteLink, setGuestInviteLink] = useState<string>('')
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<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 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<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: 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 `<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(() => {
// 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 = `<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
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 = `<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('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<typeof useActiveCoffees>['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<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: 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 (
<PageLayout>
<div className="mx-auto max-w-6xl px-4 py-10 space-y-8 bg-gradient-to-b from-white to-[#1C2B4A0D]">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold tracking-tight">
<span className="text-[#1C2B4A]">Summary & Details</span>
</h1>
<button
onClick={backToSelection}
className="rounded-md border border-gray-300 px-3 py-2 text-sm hover:bg-gray-100"
>
Back to selection
</button>
</div>
{/* Stepper */}
<div className="flex items-center gap-3 text-sm text-gray-600">
<div className="flex items-center opacity-60">
<span className="h-8 w-8 rounded-full bg-gray-200 text-gray-600 flex items-center justify-center font-semibold">1</span>
<span className="ml-2 font-medium">Selection</span>
</div>
<div className="h-px flex-1 bg-gray-200" />
<div className="flex items-center">
<span className="h-8 w-8 rounded-full bg-[#1C2B4A] text-white flex items-center justify-center font-semibold">2</span>
<span className="ml-2 font-medium">Summary</span>
</div>
</div>
{error && (
<div className="rounded-xl border p-6 bg-white shadow-sm">
<p className="text-sm text-red-700 mb-4">{error}</p>
<button
onClick={backToSelection}
className="rounded-md bg-[#1C2B4A] text-white px-4 py-2 font-semibold hover:bg-[#1C2B4A]/90"
>
Back to selection
</button>
</div>
)}
{/* submit error */}
{submitError && (
<div className="rounded-xl border p-6 bg-white shadow-sm">
<p className="text-sm text-red-700">{submitError}</p>
</div>
)}
{loading ? (
<div className="rounded-xl border p-6 bg-white shadow-sm">
<div className="h-20 rounded-md bg-gray-100 animate-pulse" />
</div>
) : selectedEntries.length === 0 ? (
<div className="rounded-xl border p-6 bg-white shadow-sm">
<p className="text-sm text-gray-600 mb-4">No selection found.</p>
<button
onClick={backToSelection}
className="rounded-md bg-[#1C2B4A] text-white px-4 py-2 font-semibold hover:bg-[#1C2B4A]/90"
>
Back to selection
</button>
</div>
) : (
<div className="grid gap-8 lg:grid-cols-3">
{/* Left: Customer data */}
<section className="lg:col-span-2">
<h2 className="text-xl font-semibold mb-4">1. Your details</h2>
<div className="rounded-xl border border-[#1C2B4A]/20 bg-white/80 backdrop-blur-sm p-6 shadow-lg">
<button
type="button"
onClick={fillFromLoggedInData}
className="mb-4 w-full rounded-md border border-[#1C2B4A] px-3 py-2 text-sm font-medium text-[#1C2B4A] hover:bg-[#1C2B4A]/5"
>
Fill fields with logged in data
</button>
<div className="grid gap-4 sm:grid-cols-2">
{/* inputs translated */}
<div>
<label className="block text-sm font-medium mb-1">First name</label>
<input name="firstName" value={form.firstName} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
</div>
<div>
<label className="block text-sm font-medium mb-1">Last name</label>
<input name="lastName" value={form.lastName} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
</div>
<div className="sm:col-span-2">
<label className="block text-sm font-medium mb-1">Email</label>
<input type="email" name="email" value={form.email} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
</div>
<div className="sm:col-span-2">
<label className="block text-sm font-medium mb-1">Street & No.</label>
<input name="street" value={form.street} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
</div>
<div>
<label className="block text-sm font-medium mb-1">ZIP</label>
<input name="postalCode" value={form.postalCode} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
</div>
<div>
<label className="block text-sm font-medium mb-1">City</label>
<input name="city" value={form.city} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
</div>
<div>
<label className="block text-sm font-medium mb-1">Country</label>
<select name="country" value={form.country} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]">
{countryOptions.map(code => (
<option key={code} value={code}>{code}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Phone (optional)</label>
<input name="phone" value={form.phone} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
</div>
</div>
{/* Payment method */}
<div className="mt-6 border-t border-gray-200 pt-4">
<h3 className="text-base font-semibold text-gray-900 mb-3">Payment method</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-md border px-4 py-2 cursor-pointer transition ${form.paymentMethod === method ? 'border-[#1C2B4A] bg-[#1C2B4A]/5 font-medium' : 'border-gray-300 hover:bg-gray-50'}`}>
<input type="radio" name="paymentMethod" value={method} checked={form.paymentMethod === method} onChange={handleInput} className="accent-[#1C2B4A]" />
{method === 'sepa' ? 'SEPA' : method === 'card' ? 'Credit Card' : 'Sofort Banking'}
</label>
))}
</div>
<label className="mt-3 flex items-center gap-2 text-sm">
<input type="checkbox" name="invoiceByEmail" checked={form.invoiceByEmail} onChange={handleCheckbox} className="accent-[#1C2B4A]" />
Send invoice by email
</label>
</div>
{/* Invoice address */}
<div className="mt-6 border-t border-gray-200 pt-4">
<h3 className="text-base font-semibold text-gray-900 mb-3">Invoice address</h3>
{isCompanyCustomer && (
<div className="mb-3 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-900">
Unternehmer mit gueltiger UID und Rechnungsland ausserhalb von {HOME_COUNTRY_CODE} werden per Reverse Charge ohne ausgewiesene MwSt verrechnet.
</div>
)}
<label className="flex items-center gap-2 text-sm mb-3">
<input type="checkbox" name="invoiceSameAsShipping" checked={form.invoiceSameAsShipping} onChange={handleCheckbox} className="accent-[#1C2B4A]" />
Same as shipping address
</label>
{isCompanyCustomer && (
<div className="mb-4">
<label className="block text-sm font-medium mb-1">UID Number (optional)</label>
<input
name="uidNumber"
value={form.uidNumber}
onChange={handleInput}
placeholder="z.B. SI12345678"
className="w-full rounded border px-3 py-2 bg-white border-gray-300 uppercase focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]"
/>
<p className="mt-1 text-xs text-gray-600">
Ohne gueltige UID wird die Rechnung mit normaler MwSt erstellt.
</p>
</div>
)}
{!form.invoiceSameAsShipping && (
<div className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<label className="block text-sm font-medium mb-1">Full name</label>
<input name="invoiceFullName" value={form.invoiceFullName} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
</div>
<div className="sm:col-span-2">
<label className="block text-sm font-medium mb-1">Street & No.</label>
<input name="invoiceStreet" value={form.invoiceStreet} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
</div>
<div>
<label className="block text-sm font-medium mb-1">ZIP</label>
<input name="invoicePostalCode" value={form.invoicePostalCode} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
</div>
<div>
<label className="block text-sm font-medium mb-1">City</label>
<input name="invoiceCity" value={form.invoiceCity} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
</div>
<div>
<label className="block text-sm font-medium mb-1">Phone (optional)</label>
<input name="invoicePhone" value={form.invoicePhone} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
</div>
<div>
<label className="block text-sm font-medium mb-1">Email</label>
<input type="email" name="invoiceEmail" value={form.invoiceEmail} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
</div>
</div>
)}
</div>
{/* Contract preview + signature */}
<div className="mt-6 border-t border-gray-200 pt-6">
<h3 className="text-base font-semibold text-gray-900 mb-2">Contract preview (ABO)</h3>
<p className="text-xs text-gray-600 mb-3">
Contract variables are auto-populated from your form data.
</p>
{contractLoading ? (
<div className="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-700">
Loading contract preview
</div>
) : contractError ? (
<div className="rounded-lg 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 justify-center rounded-md bg-[#1C2B4A] text-white px-4 py-2 text-sm font-semibold hover:bg-[#1C2B4A]/90"
>
Open preview
</button>
) : (
<div className="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-700">
Contract template is not available.
</div>
)}
<div className="mt-4 space-y-3">
<div>
<label className="block text-sm font-medium mb-1">Ort (Signing City) *</label>
<input type="text" name="signingCity" value={form.signingCity} onChange={handleInput} className={`w-full max-w-xs rounded border px-3 py-2 bg-white focus:outline-none focus:ring-2 focus:ring-[#1C2B4A] ${!hasSigningCity && submitError ? 'border-red-400' : 'border-gray-300'}`} placeholder="z.B. Wien" />
{!hasSigningCity && submitError && (
<p className="mt-1 text-xs text-red-700">Ort ist erforderlich.</p>
)}
</div>
<SignaturePad value={signatureDataUrl} onChange={setSignatureDataUrl} required error={!hasSignature && submitError ? 'Signature is required.' : null} />
</div>
</div>
<Dialog open={isContractPreviewOpen} onClose={closeContractPreview} size="5xl">
<DialogTitle>ABO contract preview (PDF)</DialogTitle>
<DialogBody>
{contractPdfError ? (
<div className="rounded-lg 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-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-700">
Generating PDF preview
</div>
) : contractPdfUrl ? (
<div className="rounded-lg border border-gray-300 bg-white overflow-hidden">
<iframe
title="ABO Contract PDF Preview"
className="w-full h-[75vh]"
src={contractPdfUrl}
/>
</div>
) : (
<div className="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-700">
No PDF preview available.
</div>
)}
</DialogBody>
<DialogActions>
<button
type="button"
onClick={closeContractPreview}
className="rounded-md border border-gray-300 px-4 py-2 text-sm font-semibold hover:bg-gray-50"
>
Close
</button>
</DialogActions>
</Dialog>
<button
onClick={submit}
disabled={!canSubmit || submitLoading}
className={`group w-full mt-6 rounded-lg px-4 py-3 font-semibold transition inline-flex items-center justify-center ${
canSubmit && !submitLoading ? 'bg-[#1C2B4A] text-white hover:bg-[#1C2B4A]/90 shadow-md hover:shadow-lg' : 'bg-gray-200 text-gray-600 cursor-not-allowed'
}`}
>
{submitLoading ? 'Creating…' : 'Complete subscription'}
<svg className={`ml-2 h-5 w-5 transition-transform ${canSubmit ? 'group-hover:translate-x-0.5' : ''}`} 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-gray-500 mt-2">
Please select coffees and fill all required buyer fields, signing city, and signature.
</p>
)}
</div>
</section>
{/* Right: Order summary */}
<section className="lg:col-span-1">
<h2 className="text-xl font-semibold mb-4">2. Your selection</h2>
<div className="rounded-xl border border-[#1C2B4A]/20 bg-white/80 backdrop-blur-sm p-6 shadow-lg lg:sticky lg:top-6">
{selectedEntries.map(entry => (
<div key={entry.coffee.id} className="flex justify-between text-sm border-b last:border-b-0 pb-2 last:pb-0">
<div className="flex flex-col">
<span className="font-medium">{entry.coffee.name}</span>
<span className="text-xs text-gray-500">
{entry.quantity} pcs <span className="inline-flex items-center font-semibold text-[#1C2B4A]">{entry.coffee.pricePer10}/10</span>
</span>
</div>
<div className="text-right font-semibold">{((entry.quantity / 10) * entry.coffee.pricePer10).toFixed(2)}</div>
</div>
))}
{/* Shipping */}
<div className="flex justify-between text-sm border-b pb-2">
<span className="text-sm font-medium">Shipping</span>
<span className="text-sm font-semibold">
{shippingLoading ? (
'Loading…'
) : shippingFee === 0 ? (
'FREE SHIPPING'
) : (
`${shippingFee.toFixed(2)}`
)}
</span>
</div>
{shippingError && (
<div className="mt-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
Shipping fees could not be loaded: {shippingError}
</div>
)}
<div className="flex justify-between pt-2 border-t">
<span className="text-sm font-semibold">Total (net)</span>
<span className="text-lg font-extrabold tracking-tight text-[#1C2B4A]">{netWithShipping.toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span className="text-sm">{isReverseCharge ? 'Tax (Reverse Charge)' : `Tax (${(effectiveTaxRate * 100).toFixed(1)}%)`}</span>
<span className="text-sm font-medium">{taxAmountWithShipping.toFixed(2)}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-semibold">Total incl. tax</span>
<span className="text-xl font-extrabold text-[#1C2B4A]">{totalWithTax.toFixed(2)}</span>
</div>
{isReverseCharge && (
<div className="mt-2 rounded-md border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-900">
Reverse Charge aktiv: gueltige UID und auslaendisches Rechnungsland erkannt.
</div>
)}
{/* Validation summary (refined design) */}
<div className="mt-2 text-xs text-gray-700">
Selected: {totalCapsules} capsules ({totalPacks} packs of 10). Target: {selectedPlanCapsules} capsules ({requiredPacks} packs).
{totalPacks !== requiredPacks && (
<span className="ml-2 inline-flex items-center rounded-md bg-red-50 text-red-700 px-2 py-1 border border-red-200">
Exactly {requiredPacks} packs ({selectedPlanCapsules} capsules) are required.
</span>
)}
</div>
</div>
</section>
</div>
)}
</div>
{/* 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-2xl bg-white p-8 text-center shadow-2xl">
<div className="pointer-events-none absolute inset-0 overflow-hidden">
{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-[#1C2B4A]/10 text-[#1C2B4A] 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">Thanks for your subscription!</h3>
<p className="mt-1 text-sm text-gray-600">
Subscription created.
</p>
<div className="mt-6 grid gap-3 sm:grid-cols-2">
<button onClick={() => { setShowThanks(false); backToSelection(); }} className="rounded-lg bg-[#1C2B4A] text-white px-4 py-2 font-semibold hover:bg-[#1C2B4A]/90">
Back to selection
</button>
<button onClick={() => setShowThanks(false)} className="rounded-lg border border-gray-300 px-4 py-2 font-semibold hover:bg-gray-50">
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>
);
}