diff --git a/src/app/coffee-abonnements/summary/page.tsx b/src/app/coffee-abonnements/summary/page.tsx index 6935a8b..eec9c1b 100644 --- a/src/app/coffee-abonnements/summary/page.tsx +++ b/src/app/coffee-abonnements/summary/page.tsx @@ -13,6 +13,17 @@ 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']; @@ -65,8 +76,17 @@ function pickFirstString(...values: unknown[]): string { // ── 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 ( + + + + ) +} + +function SummaryPageContent() { const { t } = useTranslation(); const router = useRouter(); const { coffees, loading, error } = useActiveCoffees(); @@ -79,7 +99,6 @@ export default function SummaryPage() { const [contractPdfLoading, setContractPdfLoading] = useState(false) const [contractPdfError, setContractPdfError] = useState(null) const [selections, setSelections] = useState>({}); - const [selectedPlanCapsules, setSelectedPlanCapsules] = useState(60); const [signatureDataUrl, setSignatureDataUrl] = useState('') const [form, setForm] = useState({ firstName: '', @@ -103,6 +122,7 @@ export default function SummaryPage() { signingCity: '', }); const [showThanks, setShowThanks] = useState(false); + const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false); const [guestMailtoHref, setGuestMailtoHref] = useState('') const [guestInviteLink, setGuestInviteLink] = useState('') const [confetti, setConfetti] = useState<{ left: number; delay: number; color: string }[]>([]); @@ -316,11 +336,14 @@ export default function SummaryPage() { useEffect(() => { try { - const raw = sessionStorage.getItem('coffeeSelections'); - if (raw) setSelections(JSON.parse(raw)); - const rawPlan = sessionStorage.getItem('coffeeAboSizeCapsules'); - const parsedPlan = rawPlan ? Number(rawPlan) : null; - if (parsedPlan && Number.isInteger(parsedPlan) && parsedPlan >= 60 && parsedPlan % 10 === 0) setSelectedPlanCapsules(parsedPlan); + 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 {} }, []); @@ -340,9 +363,10 @@ export default function SummaryPage() { [selections, coffees] ); - const totalCapsules = useMemo(() => selectedEntries.reduce((sum, e) => sum + e.quantity, 0), [selectedEntries]) - const totalPacks = totalCapsules / 10 - const requiredPacks = selectedPlanCapsules / 10 + 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) @@ -381,8 +405,8 @@ export default function SummaryPage() { 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(() => resolveShippingFee(selectedPlanCapsules), [resolveShippingFee, selectedPlanCapsules]); + 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]); @@ -420,20 +444,50 @@ export default function SummaryPage() { const hasRequiredInvoiceFields = form.invoiceSameAsShipping || form.invoiceEmail.trim() !== '' const hasSigningCity = form.signingCity.trim() !== '' const hasSignature = signatureDataUrl.trim() !== '' - const canSubmit = selectedEntries.length > 0 && totalPacks === requiredPacks && hasRequiredSelfFields && hasRequiredInvoiceFields && hasSigningCity && hasSignature; + const 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 && *} + + ) const backToSelection = () => router.push('/coffee-abonnements'); const submit = async () => { - if (!canSubmit || submitLoading) return - if (totalPacks !== requiredPacks) { setSubmitError(`Order must contain exactly ${requiredPacks} packs (${selectedPlanCapsules} capsules).`); return } + 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: Math.round(entry.quantity / 10) })), + 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(), @@ -451,9 +505,10 @@ export default function SummaryPage() { referred_by: typeof currentUserId === 'number' ? currentUserId : undefined, } await subscribeAbo(payload) + setHasAttemptedSubmit(false) setGuestMailtoHref(''); setGuestInviteLink(''); setShowThanks(true); - try { sessionStorage.removeItem('coffeeSelections'); } catch {} - try { sessionStorage.removeItem('coffeeAboSizeCapsules'); } catch {} + 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 { @@ -467,7 +522,7 @@ export default function SummaryPage() { 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%)' }} > -
+
{/* Header card */}
@@ -541,32 +596,32 @@ export default function SummaryPage() {
- - + +
- - + +
- - + +
- - + +
- - + +
- - + +
- - {countryOptions.map(code => )}
@@ -635,8 +690,8 @@ export default function SummaryPage() {
- - + +
)} @@ -666,27 +721,27 @@ export default function SummaryPage() {
- + - {!hasSigningCity && submitError &&

{t('autofix.k516705dd')}

} + {signingCityError &&

{t('autofix.k516705dd')}

}
- +
- {!canSubmit &&

{t('autofix.k1824f78d')}

} + {!canSubmit &&

{orderPackError ?? 'Fill in all required fields marked with * and provide your signature to finish the subscription.'}

}
@@ -700,7 +755,7 @@ export default function SummaryPage() { {selectedEntries.map(entry => (
{/* Coffee picture */} -
+
{entry.coffee.image ? ( {entry.coffee.name} ) : ( @@ -761,10 +816,13 @@ export default function SummaryPage() { {/* Pack validation */}
- {totalCapsules} capsules selected ({totalPacks} packs). Target: {selectedPlanCapsules} ({requiredPacks} packs). - {totalPacks !== requiredPacks && ( + {totalPacks.toLocaleString('en-US')} packs selected. +
{totalCapsules.toLocaleString('en-US')} capsules total · minimum {MIN_ABO_PACKS} packs · maximum {MAX_ABO_PACKS.toLocaleString('en-US')} packs.
+ {orderPackError && (
- Exactly {requiredPacks} packs required. + {remainingMinPacks > 0 + ? `${remainingMinPacks} more pack${remainingMinPacks === 1 ? '' : 's'} needed to reach the minimum order.` + : orderPackError}
)}