905 lines
49 KiB
TypeScript
905 lines
49 KiB
TypeScript
'use client';
|
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
import PageLayout from '../../components/PageLayout';
|
|
import { useRouter } from 'next/navigation';
|
|
import { useActiveCoffees } from '../hooks/getActiveCoffees';
|
|
import { getStandardVatRate, getVatRates } from './hooks/getTaxRate';
|
|
import { subscribeAbo, type SubscribeAboInput } from './hooks/subscribeAbo';
|
|
import useAuthStore from '../../store/authStore'
|
|
import { useShippingFees } from '../hooks/useShippingFees';
|
|
import { useTranslation } from '../../i18n/useTranslation';
|
|
|
|
import { useAboContractTemplateHtml } from './hooks/useAboContractTemplateHtml'
|
|
import SignaturePad from './components/SignaturePad'
|
|
import { Dialog, DialogActions, DialogBody, DialogTitle } from '../../components/dialog'
|
|
import { createReferralLink } from '../../referral-management/hooks/generateReferralLink'
|
|
import SubscribeGuard from '../components/SubscribeGuard'
|
|
import {
|
|
COFFEE_SELECTIONS_STORAGE_KEY,
|
|
COFFEE_SELECTIONS_UNIT_STORAGE_KEY,
|
|
getOrderPackError,
|
|
getRemainingMinPacks,
|
|
MAX_ABO_PACKS,
|
|
MIN_ABO_PACKS,
|
|
normalizeStoredSelections,
|
|
packsToCapsules,
|
|
} from '../lib/orderRules'
|
|
|
|
const COLORS = ['#1C2B4A', '#233357', '#2A3B66', '#314475', '#3A4F88', '#5B6C9A'];
|
|
|
|
function extractTemplateVariables(templateHtml: string | null | undefined): string[] {
|
|
if (!templateHtml) return []
|
|
const vars = new Set<string>()
|
|
const re = /\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g
|
|
let match: RegExpExecArray | null
|
|
while ((match = re.exec(templateHtml)) !== null) {
|
|
if (match[1]) vars.add(match[1])
|
|
}
|
|
return Array.from(vars).sort((a, b) => a.localeCompare(b))
|
|
}
|
|
|
|
function escapeHtml(value: string): string {
|
|
return value
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''')
|
|
}
|
|
|
|
function hashString(value: string): number {
|
|
let hash = 5381
|
|
for (let i = 0; i < value.length; i++) {
|
|
hash = ((hash << 5) + hash) ^ value.charCodeAt(i)
|
|
}
|
|
return hash >>> 0
|
|
}
|
|
|
|
const HOME_COUNTRY_CODE = 'AT'
|
|
|
|
function normalizeUid(value: unknown): string {
|
|
if (typeof value !== 'string') return ''
|
|
return value.replace(/\s+/g, '').toUpperCase()
|
|
}
|
|
|
|
function isLikelyValidUid(value: string): boolean {
|
|
return /^[A-Z]{2}[A-Z0-9]{4,14}$/.test(value)
|
|
}
|
|
|
|
function pickFirstString(...values: unknown[]): string {
|
|
for (const value of values) {
|
|
if (typeof value === 'string' && value.trim() !== '') return value.trim()
|
|
}
|
|
return ''
|
|
}
|
|
|
|
// ── shared input class
|
|
const inputCls = 'block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm placeholder:text-slate-400 focus:ring-2 focus:ring-slate-900 focus:border-transparent';
|
|
const labelCls = 'block text-sm font-semibold text-slate-700 mb-1';
|
|
const requiredMarkCls = 'ml-1 text-red-500';
|
|
|
|
export default function SummaryPage() {
|
|
return (
|
|
<SubscribeGuard>
|
|
<SummaryPageContent />
|
|
</SubscribeGuard>
|
|
)
|
|
}
|
|
|
|
function SummaryPageContent() {
|
|
const { t } = useTranslation();
|
|
const router = useRouter();
|
|
const { coffees, loading, error } = useActiveCoffees();
|
|
const user = useAuthStore(state => state.user)
|
|
const { resolveShippingFee, loading: shippingLoading, error: shippingError } = useShippingFees();
|
|
const { html: contractHtml, loading: contractLoading, error: contractError } = useAboContractTemplateHtml()
|
|
const [isContractPreviewOpen, setIsContractPreviewOpen] = useState(false)
|
|
const [contractPdfUrl, setContractPdfUrl] = useState<string>('')
|
|
const [contractPdfKey, setContractPdfKey] = useState<string>('')
|
|
const [contractPdfLoading, setContractPdfLoading] = useState(false)
|
|
const [contractPdfError, setContractPdfError] = useState<string | null>(null)
|
|
const [selections, setSelections] = useState<Record<string, number>>({});
|
|
const [signatureDataUrl, setSignatureDataUrl] = useState('')
|
|
const [form, setForm] = useState({
|
|
firstName: '',
|
|
lastName: '',
|
|
email: '',
|
|
street: '',
|
|
postalCode: '',
|
|
city: '',
|
|
country: 'DE',
|
|
phone: '',
|
|
paymentMethod: 'sepa' as 'sepa' | 'card' | 'sofort',
|
|
invoiceByEmail: true,
|
|
invoiceSameAsShipping: true,
|
|
invoiceFullName: '',
|
|
invoiceStreet: '',
|
|
invoicePostalCode: '',
|
|
invoiceCity: '',
|
|
invoicePhone: '',
|
|
invoiceEmail: '',
|
|
uidNumber: '',
|
|
signingCity: '',
|
|
});
|
|
const [showThanks, setShowThanks] = useState(false);
|
|
const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false);
|
|
const [guestMailtoHref, setGuestMailtoHref] = useState<string>('')
|
|
const [guestInviteLink, setGuestInviteLink] = useState<string>('')
|
|
const [confetti, setConfetti] = useState<{ left: number; delay: number; color: string }[]>([]);
|
|
const [taxRate, setTaxRate] = useState(0.07);
|
|
const [vatRates, setVatRates] = useState<{ code: string; rate: number | null }[]>([]);
|
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
|
const [submitLoading, setSubmitLoading] = useState(false);
|
|
const initialCountryRef = useRef(form.country)
|
|
|
|
const templateVariableNames = useMemo(() => extractTemplateVariables(contractHtml), [contractHtml])
|
|
const templateVariableNamesKey = useMemo(() => templateVariableNames.join('|'), [templateVariableNames])
|
|
const [contractVariables, setContractVariables] = useState<Record<string, string>>({})
|
|
const normalizedUserType = String(user?.userType ?? user?.user_type ?? '').trim().toLowerCase()
|
|
const isCompanyCustomer = normalizedUserType === 'company'
|
|
|
|
const profileUidNumber = useMemo(() => {
|
|
if (!isCompanyCustomer) return ''
|
|
return normalizeUid(pickFirstString(
|
|
user?.uidNumber, user?.uid_number, user?.atuNumber, user?.atu_number,
|
|
user?.companyProfile?.uid_number, user?.companyProfile?.atu_number
|
|
))
|
|
}, [isCompanyCustomer, user])
|
|
|
|
const enteredUidNumber = useMemo(() => normalizeUid(form.uidNumber), [form.uidNumber])
|
|
const effectiveUidNumber = isCompanyCustomer ? (enteredUidNumber || profileUidNumber) : ''
|
|
const hasValidCompanyUid = isCompanyCustomer && isLikelyValidUid(effectiveUidNumber)
|
|
const isForeignInvoiceCountry = form.country.toUpperCase() !== HOME_COUNTRY_CODE
|
|
const isReverseCharge = isCompanyCustomer && hasValidCompanyUid && isForeignInvoiceCountry
|
|
|
|
useEffect(() => {
|
|
if (isCompanyCustomer) return
|
|
setForm(prev => (prev.uidNumber ? { ...prev, uidNumber: '' } : prev))
|
|
}, [isCompanyCustomer])
|
|
|
|
useEffect(() => {
|
|
if (!templateVariableNamesKey) return
|
|
const fullName = `${form.firstName} ${form.lastName}`.trim()
|
|
const invoiceSame = form.invoiceSameAsShipping
|
|
const computed: Record<string, string> = {
|
|
contractNumber: '(wird generiert)',
|
|
currentDate: new Date().toLocaleDateString('de-AT', { day: '2-digit', month: '2-digit', year: 'numeric' }),
|
|
recipientName: fullName,
|
|
recipientAddress: `${form.street}, ${form.postalCode} ${form.city}`.trim(),
|
|
shippingCustomerClass: isCompanyCustomer ? '' : 'checked',
|
|
shippingCompanyClass: isCompanyCustomer ? 'checked' : '',
|
|
shippingFullName: fullName,
|
|
shippingStreet: form.street,
|
|
shippingPostalCode: form.postalCode,
|
|
shippingCity: form.city,
|
|
shippingPhone: form.phone,
|
|
shippingEmail: form.email,
|
|
invoiceSameAsShippingMark: invoiceSame ? '✓' : '',
|
|
invoiceCompanyClass: isCompanyCustomer ? 'checked' : '',
|
|
invoiceCustomerClass: isCompanyCustomer ? '' : 'checked',
|
|
invoiceFullName: invoiceSame ? fullName : form.invoiceFullName,
|
|
invoiceStreet: invoiceSame ? form.street : form.invoiceStreet,
|
|
invoicePostalCode: invoiceSame ? form.postalCode : form.invoicePostalCode,
|
|
invoiceCity: invoiceSame ? form.city : form.invoiceCity,
|
|
invoicePhone: invoiceSame ? form.phone : form.invoicePhone,
|
|
invoiceEmail: invoiceSame ? form.email : form.invoiceEmail,
|
|
fnCheckedClass: '',
|
|
fnNumber: '',
|
|
atuCheckedClass: hasValidCompanyUid ? 'checked' : '',
|
|
atuNumber: isCompanyCustomer ? effectiveUidNumber : '',
|
|
entrepreneurClass: isCompanyCustomer ? 'checked' : '',
|
|
consumerClass: isCompanyCustomer ? '' : 'checked',
|
|
paymentSepaClass: form.paymentMethod === 'sepa' ? 'checked' : '',
|
|
paymentCardClass: form.paymentMethod === 'card' ? 'checked' : '',
|
|
paymentSofortClass: form.paymentMethod === 'sofort' ? 'checked' : '',
|
|
invoiceByEmailClass: form.invoiceByEmail ? 'checked' : '',
|
|
signingCity: form.signingCity,
|
|
fullName,
|
|
}
|
|
setContractVariables(computed)
|
|
}, [templateVariableNamesKey, form, signatureDataUrl, effectiveUidNumber, hasValidCompanyUid, isCompanyCustomer])
|
|
|
|
const populatedContractHtml = useMemo(() => {
|
|
if (!contractHtml) return null
|
|
return contractHtml.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (_whole, varName: string) => {
|
|
if (varName === 'signatureImage' && !contractVariables[varName] && signatureDataUrl) {
|
|
const safeUrl = String(signatureDataUrl)
|
|
if (safeUrl.startsWith('data:image/')) {
|
|
const src = escapeHtml(safeUrl)
|
|
return `<img alt="Signature" src="${src}" style="max-width: 100%; max-height: 120px;" />`
|
|
}
|
|
}
|
|
const value = contractVariables[varName] ?? ''
|
|
return escapeHtml(String(value))
|
|
})
|
|
}, [contractHtml, contractVariables, signatureDataUrl])
|
|
|
|
const contractPdfCacheKey = useMemo(() => {
|
|
if (!populatedContractHtml) return ''
|
|
return String(hashString(populatedContractHtml))
|
|
}, [populatedContractHtml])
|
|
|
|
useEffect(() => {
|
|
return () => { if (contractPdfUrl) URL.revokeObjectURL(contractPdfUrl) }
|
|
}, [contractPdfUrl])
|
|
|
|
const closeContractPreview = () => {
|
|
setIsContractPreviewOpen(false)
|
|
setContractPdfError(null)
|
|
setContractPdfLoading(false)
|
|
setContractPdfKey('')
|
|
if (contractPdfUrl) { URL.revokeObjectURL(contractPdfUrl); setContractPdfUrl('') }
|
|
}
|
|
|
|
const openContractPreview = async () => {
|
|
if (!populatedContractHtml) return
|
|
setIsContractPreviewOpen(true)
|
|
setContractPdfError(null)
|
|
if (contractPdfUrl && contractPdfKey === contractPdfCacheKey) return
|
|
if (contractPdfUrl) { URL.revokeObjectURL(contractPdfUrl); setContractPdfUrl('') }
|
|
setContractPdfLoading(true)
|
|
try {
|
|
const [jsPdfMod, html2canvasMod] = await Promise.all([import('jspdf'), import('html2canvas')])
|
|
const jsPDF: any = (jsPdfMod as any).jsPDF || (jsPdfMod as any).default
|
|
const html2canvas: any = (html2canvasMod as any).default || html2canvasMod
|
|
const parser = new DOMParser()
|
|
const doc = parser.parseFromString(populatedContractHtml, 'text/html')
|
|
const styles = Array.from(doc.querySelectorAll('style')).map(s => s.textContent || '').join('\n')
|
|
const bodyHtml = doc.body?.innerHTML || ''
|
|
const wrapper = document.createElement('div')
|
|
wrapper.style.position = 'fixed'
|
|
wrapper.style.left = '-10000px'
|
|
wrapper.style.top = '0'
|
|
wrapper.style.width = '794px'
|
|
wrapper.style.background = '#ffffff'
|
|
wrapper.innerHTML = `<style>${styles}</style>${bodyHtml}`
|
|
document.body.appendChild(wrapper)
|
|
try {
|
|
const pdf = new jsPDF({ orientation: 'p', unit: 'pt', format: 'a4' })
|
|
const pageWidth = pdf.internal.pageSize.getWidth()
|
|
const pageHeight = pdf.internal.pageSize.getHeight()
|
|
const marginX = 24, marginTop = 24, marginBottom = 24
|
|
const usableWidth = pageWidth - marginX * 2
|
|
const usableHeight = pageHeight - marginTop - marginBottom
|
|
const renderCanvasToPdf = (canvas: HTMLCanvasElement, pageIndex: number) => {
|
|
const imgWidthPt = usableWidth
|
|
const pxPerPt = canvas.width / imgWidthPt
|
|
const sliceHeightPx = Math.max(1, Math.floor(usableHeight * pxPerPt))
|
|
let yPx = 0, sliceIndex = 0
|
|
while (yPx < canvas.height) {
|
|
const remainingPx = canvas.height - yPx
|
|
const currentSliceHeightPx = Math.min(sliceHeightPx, remainingPx)
|
|
const sliceCanvas = document.createElement('canvas')
|
|
sliceCanvas.width = canvas.width
|
|
sliceCanvas.height = currentSliceHeightPx
|
|
const ctx = sliceCanvas.getContext('2d')
|
|
if (!ctx) break
|
|
ctx.drawImage(canvas, 0, yPx, canvas.width, currentSliceHeightPx, 0, 0, canvas.width, currentSliceHeightPx)
|
|
const imgData = sliceCanvas.toDataURL('image/png')
|
|
const imgHeightPt = currentSliceHeightPx / pxPerPt
|
|
const isFirstOverall = pageIndex === 0 && sliceIndex === 0
|
|
if (!isFirstOverall) pdf.addPage()
|
|
pdf.addImage(imgData, 'PNG', marginX, marginTop, imgWidthPt, imgHeightPt)
|
|
yPx += currentSliceHeightPx
|
|
sliceIndex++
|
|
}
|
|
}
|
|
const docRoot = wrapper.querySelector('.doc') as HTMLElement | null
|
|
const pageRoot = docRoot ?? wrapper
|
|
const breakEls = Array.from(pageRoot.querySelectorAll('.pageBreak')) as HTMLElement[]
|
|
if (breakEls.length === 0) {
|
|
const canvas: HTMLCanvasElement = await html2canvas(wrapper, { scale: Math.min(2, window.devicePixelRatio || 1), backgroundColor: '#ffffff', useCORS: true })
|
|
renderCanvasToPdf(canvas, 0)
|
|
} else {
|
|
const range = document.createRange()
|
|
range.setStart(pageRoot, 0)
|
|
const fragments: DocumentFragment[] = []
|
|
for (const br of breakEls) {
|
|
range.setEndBefore(br)
|
|
const frag = range.cloneContents()
|
|
if (frag.childNodes.length > 0) fragments.push(frag)
|
|
range.setStartAfter(br)
|
|
}
|
|
range.setEnd(pageRoot, pageRoot.childNodes.length)
|
|
const lastFrag = range.cloneContents()
|
|
if (lastFrag.childNodes.length > 0) fragments.push(lastFrag)
|
|
if (fragments.length === 0) {
|
|
const canvas: HTMLCanvasElement = await html2canvas(wrapper, { scale: Math.min(2, window.devicePixelRatio || 1), backgroundColor: '#ffffff', useCORS: true })
|
|
renderCanvasToPdf(canvas, 0)
|
|
} else {
|
|
for (let pageIndex = 0; pageIndex < fragments.length; pageIndex++) {
|
|
const pageWrapper = document.createElement('div')
|
|
pageWrapper.style.position = 'fixed'
|
|
pageWrapper.style.left = '-10000px'
|
|
pageWrapper.style.top = '0'
|
|
pageWrapper.style.width = '794px'
|
|
pageWrapper.style.background = '#ffffff'
|
|
pageWrapper.innerHTML = `<style>${styles}</style>`
|
|
const pageDoc = document.createElement('div')
|
|
pageDoc.className = docRoot?.className || 'doc'
|
|
pageDoc.appendChild(fragments[pageIndex])
|
|
pageWrapper.appendChild(pageDoc)
|
|
document.body.appendChild(pageWrapper)
|
|
try {
|
|
const canvas: HTMLCanvasElement = await html2canvas(pageWrapper, { scale: Math.min(2, window.devicePixelRatio || 1), backgroundColor: '#ffffff', useCORS: true })
|
|
renderCanvasToPdf(canvas, pageIndex)
|
|
} finally {
|
|
document.body.removeChild(pageWrapper)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
const blob = pdf.output('blob') as Blob
|
|
const url = URL.createObjectURL(blob)
|
|
setContractPdfUrl(url)
|
|
setContractPdfKey(contractPdfCacheKey)
|
|
} finally {
|
|
document.body.removeChild(wrapper)
|
|
}
|
|
} catch (e: any) {
|
|
setContractPdfError(e?.message || 'Failed to generate PDF preview.')
|
|
} finally {
|
|
setContractPdfLoading(false)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
try {
|
|
const raw = sessionStorage.getItem(COFFEE_SELECTIONS_STORAGE_KEY);
|
|
const unit = sessionStorage.getItem(COFFEE_SELECTIONS_UNIT_STORAGE_KEY);
|
|
if (raw) {
|
|
const parsed = JSON.parse(raw);
|
|
if (parsed && typeof parsed === 'object') {
|
|
setSelections(normalizeStoredSelections(parsed, unit));
|
|
}
|
|
}
|
|
} catch {}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!showThanks) return;
|
|
setConfetti(Array.from({ length: 40 }).map(() => ({
|
|
left: Math.random() * 100,
|
|
delay: Math.random() * 0.6,
|
|
color: COLORS[Math.floor(Math.random() * COLORS.length)],
|
|
})));
|
|
}, [showThanks]);
|
|
|
|
const selectedEntries = useMemo(
|
|
() => Object.entries(selections)
|
|
.map(([id, qty]) => { const coffee = coffees.find(c => c.id === id); return coffee ? { coffee, quantity: qty } : null; })
|
|
.filter(Boolean) as { coffee: ReturnType<typeof useActiveCoffees>['coffees'][number]; quantity: number }[],
|
|
[selections, coffees]
|
|
);
|
|
|
|
const totalPacks = useMemo(() => selectedEntries.reduce((sum, e) => sum + e.quantity, 0), [selectedEntries])
|
|
const totalCapsules = useMemo(() => packsToCapsules(totalPacks), [totalPacks])
|
|
const orderPackError = useMemo(() => getOrderPackError(totalPacks), [totalPacks])
|
|
const remainingMinPacks = useMemo(() => getRemainingMinPacks(totalPacks), [totalPacks])
|
|
|
|
const rawUserId = user?.id
|
|
const currentUserId = typeof rawUserId === 'number' ? rawUserId : (typeof rawUserId === 'string' && /^\d+$/.test(rawUserId) ? Number(rawUserId) : undefined)
|
|
|
|
const countryOptions = useMemo(() => {
|
|
const currentCode = (form.country || 'DE').toUpperCase();
|
|
const opts = vatRates.length > 0 ? vatRates.map(r => r.code) : [currentCode]
|
|
if (!opts.includes(currentCode)) opts.unshift(currentCode)
|
|
return opts
|
|
}, [vatRates, form.country]);
|
|
|
|
useEffect(() => {
|
|
let active = true;
|
|
(async () => {
|
|
const mountCountry = initialCountryRef.current
|
|
const list = await getVatRates();
|
|
if (!active) return;
|
|
setVatRates(list);
|
|
const upper = mountCountry.toUpperCase();
|
|
const match = list.find(r => r.code === upper);
|
|
if (match?.rate != null) { setTaxRate(match.rate); }
|
|
else { const rate = await getStandardVatRate(mountCountry); setTaxRate(rate ?? 0.07); }
|
|
})();
|
|
return () => { active = false; };
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
let active = true;
|
|
(async () => {
|
|
const upper = form.country.toUpperCase();
|
|
const fromList = vatRates.find(r => r.code === upper)?.rate;
|
|
if (fromList != null) { if (active) setTaxRate(fromList); return; }
|
|
const rate = await getStandardVatRate(form.country);
|
|
if (active) setTaxRate(rate ?? 0.07);
|
|
})();
|
|
return () => { active = false; };
|
|
}, [form.country, vatRates]);
|
|
|
|
const totalPrice = useMemo(() => selectedEntries.reduce((sum, e) => sum + e.quantity * e.coffee.pricePer10, 0), [selectedEntries]);
|
|
const shippingFee = useMemo(() => resolveShippingFee(totalCapsules), [resolveShippingFee, totalCapsules]);
|
|
const netWithShipping = useMemo(() => totalPrice + shippingFee, [totalPrice, shippingFee]);
|
|
const effectiveTaxRate = isReverseCharge ? 0 : taxRate
|
|
const taxAmount = useMemo(() => totalPrice * effectiveTaxRate, [totalPrice, effectiveTaxRate]);
|
|
const taxAmountWithShipping = useMemo(() => netWithShipping * effectiveTaxRate, [netWithShipping, effectiveTaxRate]);
|
|
const totalWithTax = useMemo(() => netWithShipping + taxAmountWithShipping, [netWithShipping, taxAmountWithShipping]);
|
|
|
|
const handleInput = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
|
|
const { name, value } = e.target;
|
|
setForm(prev => ({ ...prev, [name]: value }));
|
|
};
|
|
|
|
const handleCheckbox = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const { name, checked } = e.target;
|
|
setForm(prev => ({ ...prev, [name]: checked }));
|
|
};
|
|
|
|
const fillFromLoggedInData = () => {
|
|
if (!user) { setSubmitError('No logged-in user data found to fill the fields.'); return; }
|
|
setSubmitError(null);
|
|
setForm(prev => ({
|
|
...prev,
|
|
firstName: pickFirstString(user.firstName, user.firstname, user.givenName, user.first_name) || prev.firstName,
|
|
lastName: pickFirstString(user.lastName, user.lastname, user.familyName, user.last_name) || prev.lastName,
|
|
email: pickFirstString(user.email, user.mail) || prev.email,
|
|
street: pickFirstString(user.street, user.addressStreet, user.address?.street, user.address_line_1) || prev.street,
|
|
postalCode: pickFirstString(user.postalCode, user.zipCode, user.zip, user.addressPostalCode, user.address?.postalCode) || prev.postalCode,
|
|
city: pickFirstString(user.city, user.addressCity, user.town, user.address?.city) || prev.city,
|
|
country: (pickFirstString(user.country, user.countryCode, user.addressCountry, user.address?.country) || prev.country).toUpperCase(),
|
|
uidNumber: isCompanyCustomer
|
|
? normalizeUid(pickFirstString(user.uidNumber, user.uid_number, user.atuNumber, user.atu_number, user.companyProfile?.uid_number, user.companyProfile?.atu_number) || prev.uidNumber)
|
|
: '',
|
|
}));
|
|
};
|
|
|
|
const requiredSelfFields = ['firstName', 'lastName', 'email', 'street', 'postalCode', 'city', 'country'] as const
|
|
const hasRequiredSelfFields = requiredSelfFields.every(k => String(form[k]).trim() !== '')
|
|
const hasRequiredInvoiceFields = form.invoiceSameAsShipping || form.invoiceEmail.trim() !== ''
|
|
const hasSigningCity = form.signingCity.trim() !== ''
|
|
const hasSignature = signatureDataUrl.trim() !== ''
|
|
const canSubmit = selectedEntries.length > 0 && !orderPackError && hasRequiredSelfFields && hasRequiredInvoiceFields && hasSigningCity && hasSignature;
|
|
const canAttemptSubmit = selectedEntries.length > 0 && !orderPackError && !submitLoading
|
|
const firstNameError = hasAttemptedSubmit && form.firstName.trim() === ''
|
|
const lastNameError = hasAttemptedSubmit && form.lastName.trim() === ''
|
|
const emailError = hasAttemptedSubmit && form.email.trim() === ''
|
|
const streetError = hasAttemptedSubmit && form.street.trim() === ''
|
|
const postalCodeError = hasAttemptedSubmit && form.postalCode.trim() === ''
|
|
const cityError = hasAttemptedSubmit && form.city.trim() === ''
|
|
const countryError = hasAttemptedSubmit && form.country.trim() === ''
|
|
const invoiceEmailError = hasAttemptedSubmit && !form.invoiceSameAsShipping && form.invoiceEmail.trim() === ''
|
|
const signingCityError = hasAttemptedSubmit && !hasSigningCity
|
|
const signatureError = hasAttemptedSubmit && !hasSignature
|
|
const getFieldClassName = (hasError: boolean, extraClassName = '') => {
|
|
const errorClassName = hasError ? 'border-red-400 focus:ring-red-400' : ''
|
|
return `${inputCls} ${errorClassName} ${extraClassName}`.trim()
|
|
}
|
|
const renderLabel = (label: string, required = false) => (
|
|
<>
|
|
{label}
|
|
{required && <span className={requiredMarkCls}>*</span>}
|
|
</>
|
|
)
|
|
|
|
const backToSelection = () => router.push('/coffee-abonnements');
|
|
|
|
const submit = async () => {
|
|
if (submitLoading) return
|
|
setHasAttemptedSubmit(true)
|
|
if (selectedEntries.length === 0) {
|
|
setSubmitError(`Order must contain at least ${MIN_ABO_PACKS} packs (${packsToCapsules(MIN_ABO_PACKS)} capsules).`)
|
|
return
|
|
}
|
|
if (orderPackError) { setSubmitError(orderPackError); return }
|
|
if (!hasRequiredSelfFields || !hasRequiredInvoiceFields) {
|
|
setSubmitError('Please fill in all required fields.')
|
|
return
|
|
}
|
|
if (!hasSigningCity) { setSubmitError('Signing city is required.'); return }
|
|
if (!hasSignature) { setSubmitError('Signature is required.'); return }
|
|
setSubmitError(null)
|
|
setSubmitLoading(true)
|
|
try {
|
|
const payload: SubscribeAboInput = {
|
|
items: selectedEntries.map(entry => ({ coffeeId: entry.coffee.id, quantity: entry.quantity })),
|
|
billing_interval: 'month', interval_count: 1, is_auto_renew: true, is_for_self: true,
|
|
firstName: form.firstName.trim(), lastName: form.lastName.trim(), email: form.email.trim(),
|
|
street: form.street.trim(), postalCode: form.postalCode.trim(), city: form.city.trim(), country: form.country.trim(),
|
|
frequency: 'monatlich', phone: form.phone.trim() || undefined,
|
|
recipientContractName: `${form.firstName} ${form.lastName}`.trim() || undefined,
|
|
paymentMethod: form.paymentMethod, invoiceByEmail: form.invoiceByEmail, invoiceSameAsShipping: form.invoiceSameAsShipping,
|
|
...(!form.invoiceSameAsShipping ? {
|
|
invoiceFullName: form.invoiceFullName.trim() || undefined, invoiceStreet: form.invoiceStreet.trim() || undefined,
|
|
invoicePostalCode: form.invoicePostalCode.trim() || undefined, invoiceCity: form.invoiceCity.trim() || undefined,
|
|
invoicePhone: form.invoicePhone.trim() || undefined, invoiceEmail: form.invoiceEmail.trim() || undefined,
|
|
} : {}),
|
|
signingCity: form.signingCity.trim() || undefined, signatureDataUrl: signatureDataUrl || undefined,
|
|
...(isCompanyCustomer ? {
|
|
uidNumber: effectiveUidNumber || undefined,
|
|
atuNumber: effectiveUidNumber || undefined,
|
|
taxMode: isReverseCharge ? 'reverse_charge' : 'standard_vat',
|
|
} : {}),
|
|
referred_by: typeof currentUserId === 'number' ? currentUserId : undefined,
|
|
}
|
|
await subscribeAbo(payload)
|
|
setHasAttemptedSubmit(false)
|
|
setGuestMailtoHref(''); setGuestInviteLink(''); setShowThanks(true);
|
|
try { sessionStorage.removeItem(COFFEE_SELECTIONS_STORAGE_KEY); } catch {}
|
|
try { sessionStorage.removeItem(COFFEE_SELECTIONS_UNIT_STORAGE_KEY); } catch {}
|
|
} catch (e: any) {
|
|
setSubmitError(e?.message || 'Subscription could not be created.');
|
|
} finally {
|
|
setSubmitLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<PageLayout contentClassName="flex-1 relative w-full">
|
|
<div
|
|
className="min-h-screen"
|
|
style={{ background: 'radial-gradient(circle at top left,rgba(251,191,36,0.10),transparent 22%),radial-gradient(circle at top right,rgba(56,189,248,0.10),transparent 24%),linear-gradient(180deg,#f8fafc 0%,#f8fafc 50%,#eef2ff 100%)' }}
|
|
>
|
|
<div className="max-w-455 mx-auto px-4 sm:px-6 xl:px-10 py-8 space-y-5">
|
|
|
|
{/* Header card */}
|
|
<div className="rounded-[28px] border border-white/80 bg-white/90 px-8 py-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur flex flex-wrap items-center justify-between gap-4">
|
|
<div>
|
|
<h1 className="text-2xl font-bold tracking-tight text-slate-900">
|
|
<span>{t('autofix.k21361e0d')}</span>
|
|
</h1>
|
|
<p className="mt-1 text-sm text-slate-500">Step 2 of 2 — Review your order and complete checkout</p>
|
|
</div>
|
|
<button
|
|
onClick={backToSelection}
|
|
className="inline-flex items-center gap-2 rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-700 shadow-sm hover:bg-slate-50 transition"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
|
</svg>
|
|
{t('autofix.k96839795')}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Stepper */}
|
|
<div className="rounded-[28px] border border-white/80 bg-white/90 px-8 py-4 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur">
|
|
<div className="flex items-center gap-3 text-sm">
|
|
<div className="flex items-center gap-2 opacity-50">
|
|
<span className="h-7 w-7 rounded-full bg-slate-200 text-slate-500 flex items-center justify-center text-xs font-bold">1</span>
|
|
<span className="font-medium text-slate-500">Selection</span>
|
|
</div>
|
|
<div className="h-px flex-1 bg-slate-200" />
|
|
<div className="flex items-center gap-2">
|
|
<span className="h-7 w-7 rounded-full bg-slate-900 text-white flex items-center justify-center text-xs font-bold">2</span>
|
|
<span className="font-semibold text-slate-900">Summary</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Errors */}
|
|
{(error || submitError) && (
|
|
<div className="rounded-[28px] border border-red-100 bg-red-50 px-6 py-4 shadow-sm text-sm text-red-700 space-y-1">
|
|
{error && <p>{error}</p>}
|
|
{submitError && <p>{submitError}</p>}
|
|
</div>
|
|
)}
|
|
|
|
{loading ? (
|
|
<div className="rounded-[28px] border border-white/80 bg-white/90 p-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur">
|
|
<div className="h-20 rounded-xl bg-slate-100 animate-pulse" />
|
|
</div>
|
|
) : selectedEntries.length === 0 ? (
|
|
<div className="rounded-[28px] border border-white/80 bg-white/90 p-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur">
|
|
<p className="text-sm text-slate-600 mb-4">{t('autofix.k20127e1c')}</p>
|
|
<button onClick={backToSelection} className="inline-flex items-center gap-2 rounded-xl bg-slate-900 text-white px-5 py-2.5 text-sm font-semibold hover:bg-slate-700 transition">
|
|
{t('autofix.k96839795')}
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="grid gap-5 xl:grid-cols-3">
|
|
|
|
{/* ── LEFT: Customer data + contract */}
|
|
<section className="xl:col-span-2 space-y-5">
|
|
|
|
{/* Customer details card */}
|
|
<div className="rounded-[28px] border border-white/80 bg-white/90 p-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur space-y-6">
|
|
<div className="flex items-center justify-between gap-4 flex-wrap">
|
|
<h2 className="text-lg font-bold text-slate-900">{t('autofix.kd6f8d7e9')}</h2>
|
|
<button
|
|
type="button"
|
|
onClick={fillFromLoggedInData}
|
|
className="inline-flex items-center gap-2 rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-700 shadow-sm hover:bg-slate-50 transition"
|
|
>{t('autofix.k9c1a5ecc')}</button>
|
|
</div>
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<div>
|
|
<label className={labelCls}>{renderLabel(t('autofix.kfe9527d8'), true)}</label>
|
|
<input name="firstName" value={form.firstName} onChange={handleInput} className={getFieldClassName(firstNameError)} />
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>{renderLabel(t('autofix.k6a2c64e8'), true)}</label>
|
|
<input name="lastName" value={form.lastName} onChange={handleInput} className={getFieldClassName(lastNameError)} />
|
|
</div>
|
|
<div className="sm:col-span-2">
|
|
<label className={labelCls}>{renderLabel('Email', true)}</label>
|
|
<input type="email" name="email" value={form.email} onChange={handleInput} className={getFieldClassName(emailError)} />
|
|
</div>
|
|
<div className="sm:col-span-2">
|
|
<label className={labelCls}>{renderLabel(t('autofix.kd1a2772d'), true)}</label>
|
|
<input name="street" value={form.street} onChange={handleInput} className={getFieldClassName(streetError)} />
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>{renderLabel('ZIP', true)}</label>
|
|
<input name="postalCode" value={form.postalCode} onChange={handleInput} className={getFieldClassName(postalCodeError)} />
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>{renderLabel('City', true)}</label>
|
|
<input name="city" value={form.city} onChange={handleInput} className={getFieldClassName(cityError)} />
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>{renderLabel('Country', true)}</label>
|
|
<select name="country" value={form.country} onChange={handleInput} className={getFieldClassName(countryError)}>
|
|
{countryOptions.map(code => <option key={code} value={code}>{code}</option>)}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>Phone (optional)</label>
|
|
<input name="phone" value={form.phone} onChange={handleInput} className={inputCls} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Payment method */}
|
|
<div className="border-t border-slate-100 pt-5">
|
|
<h3 className="text-sm font-bold text-slate-900 mb-3">{t('autofix.k3466b0e0')}</h3>
|
|
<div className="flex flex-wrap gap-3">
|
|
{(['sepa', 'card', 'sofort'] as const).map(method => (
|
|
<label key={method} className={`flex items-center gap-2 rounded-xl border px-4 py-2.5 cursor-pointer transition text-sm font-medium ${form.paymentMethod === method ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-200 bg-white text-slate-700 hover:bg-slate-50'}`}>
|
|
<input type="radio" name="paymentMethod" value={method} checked={form.paymentMethod === method} onChange={handleInput} className="sr-only" />
|
|
{method === 'sepa' ? 'SEPA' : method === 'card' ? t('autofix.k113e47af') : t('autofix.k2f00d2db')}
|
|
</label>
|
|
))}
|
|
</div>
|
|
<label className="mt-3 flex items-center gap-2 text-sm font-medium text-slate-700">
|
|
<input type="checkbox" name="invoiceByEmail" checked={form.invoiceByEmail} onChange={handleCheckbox} className="h-4 w-4 rounded border-slate-300 text-slate-900 focus:ring-slate-900" />
|
|
{t('autofix.ke33e6fbf')}
|
|
</label>
|
|
</div>
|
|
|
|
{/* Invoice address */}
|
|
<div className="border-t border-slate-100 pt-5">
|
|
<h3 className="text-sm font-bold text-slate-900 mb-3">{t('autofix.kce094582')}</h3>
|
|
{isCompanyCustomer && (
|
|
<div className="mb-3 rounded-xl border border-blue-200 bg-blue-50 px-4 py-3 text-xs text-blue-900">
|
|
Unternehmer mit gültiger UID und Rechnungsland außerhalb von {HOME_COUNTRY_CODE} werden per Reverse Charge ohne ausgewiesene MwSt verrechnet.
|
|
</div>
|
|
)}
|
|
<label className="flex items-center gap-2 text-sm font-medium text-slate-700 mb-3">
|
|
<input type="checkbox" name="invoiceSameAsShipping" checked={form.invoiceSameAsShipping} onChange={handleCheckbox} className="h-4 w-4 rounded border-slate-300 text-slate-900 focus:ring-slate-900" />
|
|
{t('autofix.k528eede9')}
|
|
</label>
|
|
{isCompanyCustomer && (
|
|
<div className="mb-4">
|
|
<label className={labelCls}>UID Number (optional)</label>
|
|
<input name="uidNumber" value={form.uidNumber} onChange={handleInput} placeholder={t('autofix.kf1512f8f')} className={`${inputCls} uppercase`} />
|
|
<p className="mt-1 text-xs text-slate-500">{t('autofix.kefe5f0dd')}</p>
|
|
</div>
|
|
)}
|
|
{!form.invoiceSameAsShipping && (
|
|
<div className="grid gap-4 sm:grid-cols-2 mt-2">
|
|
<div className="sm:col-span-2">
|
|
<label className={labelCls}>{t('autofix.k28f1a9b1')}</label>
|
|
<input name="invoiceFullName" value={form.invoiceFullName} onChange={handleInput} className={inputCls} />
|
|
</div>
|
|
<div className="sm:col-span-2">
|
|
<label className={labelCls}>{t('autofix.kd1a2772d')}</label>
|
|
<input name="invoiceStreet" value={form.invoiceStreet} onChange={handleInput} className={inputCls} />
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>ZIP</label>
|
|
<input name="invoicePostalCode" value={form.invoicePostalCode} onChange={handleInput} className={inputCls} />
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>City</label>
|
|
<input name="invoiceCity" value={form.invoiceCity} onChange={handleInput} className={inputCls} />
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>Phone (optional)</label>
|
|
<input name="invoicePhone" value={form.invoicePhone} onChange={handleInput} className={inputCls} />
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>{renderLabel('Email', true)}</label>
|
|
<input type="email" name="invoiceEmail" value={form.invoiceEmail} onChange={handleInput} className={getFieldClassName(invoiceEmailError)} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Contract + signature card */}
|
|
<div className="rounded-[28px] border border-white/80 bg-white/90 p-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur space-y-5">
|
|
<div>
|
|
<h2 className="text-lg font-bold text-slate-900">Contract preview (ABO)</h2>
|
|
<p className="mt-1 text-xs text-slate-500">{t('autofix.k155166db')}</p>
|
|
</div>
|
|
|
|
{contractLoading ? (
|
|
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">{t('autofix.k0bbc633d')}</div>
|
|
) : contractError ? (
|
|
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">Contract preview could not be loaded: {contractError}</div>
|
|
) : populatedContractHtml ? (
|
|
<button type="button" onClick={openContractPreview}
|
|
className="inline-flex items-center gap-2 rounded-xl bg-slate-900 text-white px-5 py-2.5 text-sm font-semibold hover:bg-slate-700 transition">
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
|
|
{t('autofix.kd379df9b')}
|
|
</button>
|
|
) : (
|
|
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">{t('autofix.ke74b1adf')}</div>
|
|
)}
|
|
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className={labelCls}>{renderLabel('Ort (Signing City)', true)}</label>
|
|
<input type="text" name="signingCity" value={form.signingCity} onChange={handleInput}
|
|
className={getFieldClassName(signingCityError, 'max-w-xs')}
|
|
placeholder={t('autofix.k1f0b2c48')} />
|
|
{signingCityError && <p className="mt-1 text-xs text-red-600">{t('autofix.k516705dd')}</p>}
|
|
</div>
|
|
<SignaturePad value={signatureDataUrl} onChange={setSignatureDataUrl} required error={signatureError ? 'Signature is required.' : null} />
|
|
</div>
|
|
|
|
<button
|
|
onClick={submit}
|
|
disabled={!canAttemptSubmit}
|
|
className={`w-full rounded-xl px-5 py-3.5 text-sm font-semibold transition inline-flex items-center justify-center gap-2 shadow-sm ${
|
|
canAttemptSubmit ? 'bg-slate-900 text-white hover:bg-slate-700' : 'bg-slate-100 text-slate-400 cursor-not-allowed'
|
|
}`}
|
|
>
|
|
{submitLoading && <svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z" /></svg>}
|
|
{submitLoading ? t('autofix.k27b5b842') : t('autofix.k737db983')}
|
|
{!submitLoading && <svg className="w-4 h-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fillRule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l5.999 6a1 1 0 010 1.414l-6 6a1 1 0 11-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clipRule="evenodd" /></svg>}
|
|
</button>
|
|
{!canSubmit && <p className="text-xs text-slate-500 text-center">{orderPackError ?? 'Fill in all required fields marked with * and provide your signature to finish the subscription.'}</p>}
|
|
</div>
|
|
</section>
|
|
|
|
{/* ── RIGHT: Order summary with pictures */}
|
|
<section className="xl:col-span-1 xl:sticky xl:top-6 space-y-5 self-start">
|
|
|
|
{/* Coffee items card */}
|
|
<div className="rounded-[28px] border border-white/80 bg-white/90 p-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur">
|
|
<h2 className="text-lg font-bold text-slate-900 mb-4">{t('autofix.k4aeb8688')}</h2>
|
|
<div className="space-y-4">
|
|
{selectedEntries.map(entry => (
|
|
<div key={entry.coffee.id} className="flex gap-3 items-start">
|
|
{/* Coffee picture */}
|
|
<div className="shrink-0 w-16 h-16 rounded-xl overflow-hidden border border-slate-100 bg-slate-50">
|
|
{entry.coffee.image ? (
|
|
<img src={entry.coffee.image} alt={entry.coffee.name} className="w-full h-full object-cover" />
|
|
) : (
|
|
<div className="w-full h-full flex items-center justify-center text-slate-300">
|
|
<svg className="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
|
</svg>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{/* Details */}
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-semibold text-slate-900 truncate">{entry.coffee.name}</p>
|
|
<p className="text-xs text-slate-500 mt-0.5">{entry.quantity} capsules · {entry.quantity / 10} pack{entry.quantity / 10 !== 1 ? 's' : ''}</p>
|
|
<p className="text-xs text-slate-400">€{entry.coffee.pricePer10}/10</p>
|
|
</div>
|
|
{/* Line total */}
|
|
<p className="text-sm font-bold text-slate-900 whitespace-nowrap">€{((entry.quantity / 10) * entry.coffee.pricePer10).toFixed(2)}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Price breakdown card */}
|
|
<div className="rounded-[28px] border border-white/80 bg-white/90 p-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur space-y-3">
|
|
<h3 className="text-sm font-bold text-slate-900">Price breakdown</h3>
|
|
|
|
<div className="space-y-2 text-sm">
|
|
<div className="flex justify-between text-slate-600">
|
|
<span>Subtotal</span>
|
|
<span>€{totalPrice.toFixed(2)}</span>
|
|
</div>
|
|
<div className="flex justify-between text-slate-600">
|
|
<span>Shipping</span>
|
|
<span>
|
|
{shippingLoading ? <span className="text-slate-400">Loading…</span>
|
|
: shippingFee === 0 ? <span className="text-emerald-600 font-semibold">FREE</span>
|
|
: <span>€{shippingFee.toFixed(2)}</span>}
|
|
</span>
|
|
</div>
|
|
{shippingError && (
|
|
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">{shippingError}</div>
|
|
)}
|
|
<div className="flex justify-between text-slate-600">
|
|
<span>{isReverseCharge ? 'Tax (Reverse Charge)' : `Tax (${(effectiveTaxRate * 100).toFixed(1)}%)`}</span>
|
|
<span>€{taxAmountWithShipping.toFixed(2)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-t border-slate-100 pt-3 flex justify-between items-center">
|
|
<span className="text-base font-bold text-slate-900">{t('autofix.k11438b4c')}</span>
|
|
<span className="text-xl font-extrabold text-slate-900">€{totalWithTax.toFixed(2)}</span>
|
|
</div>
|
|
|
|
{isReverseCharge && (
|
|
<div className="rounded-xl border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-900">{t('autofix.k74491338')}</div>
|
|
)}
|
|
|
|
{/* Pack validation */}
|
|
<div className="rounded-xl border border-slate-100 bg-slate-50 px-3 py-2 text-xs text-slate-600">
|
|
<span className="font-semibold">{totalPacks.toLocaleString('en-US')}</span> packs selected.
|
|
<div className="text-slate-500">{totalCapsules.toLocaleString('en-US')} capsules total · minimum {MIN_ABO_PACKS} packs · maximum {MAX_ABO_PACKS.toLocaleString('en-US')} packs.</div>
|
|
{orderPackError && (
|
|
<div className="mt-1 rounded-lg bg-red-50 border border-red-200 text-red-700 px-2 py-1 font-medium">
|
|
{remainingMinPacks > 0
|
|
? `${remainingMinPacks} more pack${remainingMinPacks === 1 ? '' : 's'} needed to reach the minimum order.`
|
|
: orderPackError}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Contract PDF dialog */}
|
|
<Dialog open={isContractPreviewOpen} onClose={closeContractPreview} size="5xl">
|
|
<DialogTitle>ABO contract preview (PDF)</DialogTitle>
|
|
<DialogBody>
|
|
{contractPdfError ? (
|
|
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">PDF preview could not be generated: {contractPdfError}</div>
|
|
) : contractPdfLoading ? (
|
|
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">{t('autofix.k0b2445d5')}</div>
|
|
) : contractPdfUrl ? (
|
|
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden">
|
|
<iframe title={t('autofix.kaa5e5363')} className="w-full h-[75vh]" src={contractPdfUrl} />
|
|
</div>
|
|
) : (
|
|
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">{t('autofix.ka56b7b2b')}</div>
|
|
)}
|
|
</DialogBody>
|
|
<DialogActions>
|
|
<button type="button" onClick={closeContractPreview} className="rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50 transition">Close</button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
|
|
{/* Thank you overlay */}
|
|
{showThanks && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
|
<div className="relative mx-4 w-full max-w-md rounded-[28px] bg-white p-8 text-center shadow-2xl">
|
|
<div className="pointer-events-none absolute inset-0 overflow-hidden rounded-[28px]">
|
|
{confetti.map((c, i) => (
|
|
<span key={i} className="confetti" style={{ left: `${c.left}%`, animationDelay: `${c.delay}s`, background: c.color }} />
|
|
))}
|
|
</div>
|
|
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-slate-900/10 text-slate-900 pop">
|
|
<svg viewBox="0 0 24 24" className="h-9 w-9" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M20 6L9 17l-5-5" strokeLinecap="round" strokeLinejoin="round" />
|
|
</svg>
|
|
</div>
|
|
<h3 className="text-2xl font-bold text-slate-900">{t('autofix.k0853cfa6')}</h3>
|
|
<p className="mt-1 text-sm text-slate-500">{t('autofix.kd3092148')}</p>
|
|
<div className="mt-6 grid gap-3 sm:grid-cols-2">
|
|
<button onClick={() => { setShowThanks(false); backToSelection(); }} className="rounded-xl bg-slate-900 text-white px-4 py-2.5 text-sm font-semibold hover:bg-slate-700 transition">{t('autofix.k96839795')}</button>
|
|
<button onClick={() => setShowThanks(false)} className="rounded-xl border border-slate-200 px-4 py-2.5 text-sm font-semibold text-slate-700 hover:bg-slate-50 transition">Close</button>
|
|
</div>
|
|
<style jsx>{`
|
|
.confetti { position: absolute; top: -10%; width: 8px; height: 12px; border-radius: 2px; opacity: 0.9; animation: fall 1.8s linear forwards; }
|
|
@keyframes fall { 0% { transform: translateY(0) rotate(0deg); } 100% { transform: translateY(110vh) rotate(720deg); } }
|
|
.pop { animation: pop 450ms ease-out forwards; }
|
|
@keyframes pop { 0% { transform: scale(0.6); opacity: 0; } 60% { transform: scale(1.08); opacity: 1; } 100% { transform: scale(1); } }
|
|
`}</style>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</PageLayout>
|
|
);
|
|
}
|
|
|
|
|