1033 lines
45 KiB
TypeScript
1033 lines
45 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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''')
|
||
}
|
||
|
||
function hashString(value: string): number {
|
||
// djb2
|
||
let hash = 5381
|
||
for (let i = 0; i < value.length; i++) {
|
||
hash = ((hash << 5) + hash) ^ value.charCodeAt(i)
|
||
}
|
||
return hash >>> 0
|
||
}
|
||
|
||
export default function SummaryPage() {
|
||
const router = useRouter();
|
||
const { coffees, loading, error } = useActiveCoffees();
|
||
const user = useAuthStore(state => state.user)
|
||
const { feeByPieceCount, loading: shippingLoading, error: shippingError } = useShippingFees();
|
||
const { html: contractHtml, loading: contractLoading, error: contractError } = useAboContractTemplateHtml()
|
||
const [isContractPreviewOpen, setIsContractPreviewOpen] = useState(false)
|
||
const [contractPdfUrl, setContractPdfUrl] = useState<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>
|
||
);
|
||
}
|