profit-planet-frontend/src/app/coffee-abonnements/summary/page.tsx
2026-03-16 20:04:13 +01:00

1033 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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
}
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 [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<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>>({})
useEffect(() => {
if (!templateVariableNamesKey) return
setContractVariables(prev => {
let changed = false
const next: Record<string, string> = { ...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 `<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 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<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setForm(prev => ({ ...prev, [name]: value }));
};
const handleRecipientNotes = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
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<keyof typeof form> = [
'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 (
<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>
{/* Toggle: For myself / For someone else */}
<div className="flex gap-2 mb-4">
<button
type="button"
onClick={() => setIsForSelf(true)}
className={`flex-1 rounded-md px-3 py-2 text-sm font-medium transition ${
isForSelf
? 'bg-[#1C2B4A] text-white shadow'
: 'border border-[#1C2B4A] text-[#1C2B4A] hover:bg-[#1C2B4A]/5'
}`}
>
For myself
</button>
<button
type="button"
onClick={() => setIsForSelf(false)}
className={`flex-1 rounded-md px-3 py-2 text-sm font-medium transition ${
!isForSelf
? 'bg-[#1C2B4A] text-white shadow'
: 'border border-[#1C2B4A] text-[#1C2B4A] hover:bg-[#1C2B4A]/5'
}`}
>
For someone else
</button>
</div>
<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">Delivery interval</label>
<select name="frequency" value={form.frequency} 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]">
<option value="monatlich">Monthly</option>
<option value="zweimonatlich">Every 2 months</option>
<option value="vierteljährlich">Quarterly</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Start date (optional)</label>
<input type="date" name="startDate" value={form.startDate} 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>
{!isForSelf && (
<>
<div className="sm:col-span-2">
<label className="block text-sm font-medium mb-1">Recipient email</label>
<input
type="email"
name="recipientEmail"
value={form.recipientEmail}
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">Recipient name (optional)</label>
<input
name="recipientName"
value={form.recipientName}
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">Recipient note (optional)</label>
<textarea
name="recipientNotes"
value={form.recipientNotes}
onChange={handleRecipientNotes}
className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]"
rows={3}
/>
</div>
</>
)}
</div>
{/* Contract preview + signature (frontend only for now) */}
<div className="mt-6 border-t border-gray-200 pt-6">
<h3 className="text-base font-semibold text-gray-900 mb-2">Contract template preview (ABO)</h3>
<p className="text-xs text-gray-600 mb-3">
This is the ABO contract HTML template (populated from the fields below, frontend-only).
</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 ? (
<>
{templateVariableNames.length > 0 && (
<div className="mb-4 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3">
<div className="text-sm font-semibold text-gray-900 mb-2">Contract variables</div>
<div className="grid gap-4">
{templateVariableNames.map(varName => (
<div key={varName}>
<label className="block text-xs font-medium mb-1 text-gray-700">{varName}</label>
<input
value={contractVariables[varName] ?? ''}
onChange={e =>
setContractVariables(prev => ({ ...prev, [varName]: e.target.value }))
}
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>
)}
<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">
<SignaturePad value={signatureDataUrl} onChange={setSignatureDataUrl} />
</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">
{isForSelf
? 'Please select coffees and fill all required buyer fields.'
: 'Please select coffees and fill all required buyer fields plus recipient email.'}
</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">Tax ({(taxRate * 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>
{/* 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">
{isForSelf
? 'Subscription created.'
: guestMailtoHref
? 'Subscription created. Email draft opened for the guest invite.'
: 'Subscription created. Guest invite email could not be prepared.'}
</p>
{!isForSelf && guestMailtoHref && (
<div className="mt-4">
<a
href={guestMailtoHref}
className="inline-flex items-center justify-center rounded-lg bg-[#1C2B4A] text-white px-4 py-2 font-semibold hover:bg-[#1C2B4A]/90"
>
Open guest email draft again
</a>
</div>
)}
{!isForSelf && guestInviteLink && (
<div className="mt-3 text-xs text-gray-600 break-words">
Guest registration link: <a className="underline" href={guestInviteLink} target="_blank" rel="noreferrer">{guestInviteLink}</a>
</div>
)}
<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>
);
}