From c7225d70cb0028bf698e6d33f4e452bcba3264e2 Mon Sep 17 00:00:00 2001 From: seaznCode Date: Tue, 5 May 2026 22:04:03 +0200 Subject: [PATCH 01/13] Update layout and text for AdminSubscriptionsPage --- src/app/admin/subscriptions/page.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/app/admin/subscriptions/page.tsx b/src/app/admin/subscriptions/page.tsx index a802711..8c1c2fb 100644 --- a/src/app/admin/subscriptions/page.tsx +++ b/src/app/admin/subscriptions/page.tsx @@ -138,7 +138,7 @@ export default function AdminSubscriptionsPage() { return ( -
+
{/* Header */}
@@ -197,9 +197,9 @@ export default function AdminSubscriptionsPage() {
-
{pieceCount} pieces
+
{pieceCount} pieces
{typeof current?.price === 'number' && Number.isFinite(current.price) ? ( -
Current: €{formatPriceDraft(current.price)}
+
Current: €{formatPriceDraft(current.price)}
) : null} {savedAt ? (
@@ -270,7 +270,7 @@ export default function AdminSubscriptionsPage() {

{item.description}

-
Price
+
Price per pack
{item.currency || 'EUR'} {Number.isFinite(Number(item.price)) ? Number(item.price).toFixed(2) : String(item.price)}
@@ -318,7 +318,7 @@ export default function AdminSubscriptionsPage() {

{t('autofix.kddd4832f')}

-

You are about to delete the coffee "{deleteTarget.title}". This action cannot be undone.

+

You are about to delete the coffee "{deleteTarget.title}". This action cannot be undone.

View
- {active && ( -
-
- Quantity (10-{maxForCoffee} pcs) - - {qty} pcs - +
+
+
+
Pack selection
+
{packsToCapsules(qty).toLocaleString('en-US')} capsules
-
- -
- onChangeQuantity(coffee.id, parseInt(e.target.value, 10) - qty)} - className="w-full appearance-none cursor-pointer bg-transparent" - style={{ - background: - 'linear-gradient(to right,#0f172a 0%,#0f172a ' + - sliderProgress + - '%,#e2e8f0 ' + - sliderProgress + - '%,#e2e8f0 100%)', - height: '6px', - borderRadius: '999px', - }} - /> -
- -
-
- Subtotal - EUR {((qty / 10) * coffee.pricePer10).toFixed(2)} +
+ {qty.toLocaleString('en-US')} packs
- )} + +
+ + onSetQuantity(coffee.id, Number(e.target.value))} + className="h-10 w-full rounded-xl border border-slate-200 bg-white px-3 text-center text-sm font-semibold text-slate-900 shadow-sm focus:border-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-900/10" + /> + +
+ +
+ You can add {addableForCoffee.toLocaleString('en-US')} more packs here. + EUR {subtotal.toFixed(2)} +
+
); })} From 93886751a374ad36cc5fc36147a26ff09c7c0b60 Mon Sep 17 00:00:00 2001 From: seaznCode Date: Tue, 5 May 2026 22:08:30 +0200 Subject: [PATCH 06/13] Enhance SelectionSummaryCard to improve pack selection feedback and error handling --- .../components/SelectionSummaryCard.tsx | 41 ++++++++++++------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/src/app/coffee-abonnements/components/SelectionSummaryCard.tsx b/src/app/coffee-abonnements/components/SelectionSummaryCard.tsx index 2dc3c52..4ffe6a9 100644 --- a/src/app/coffee-abonnements/components/SelectionSummaryCard.tsx +++ b/src/app/coffee-abonnements/components/SelectionSummaryCard.tsx @@ -1,4 +1,5 @@ import type { CoffeeItem } from '../hooks/getActiveCoffees'; +import { MAX_ABO_PACKS, MIN_ABO_PACKS, packsToCapsules } from '../lib/orderRules'; type SelectedEntry = { coffee: CoffeeItem; @@ -12,9 +13,9 @@ type Props = { selectedShippingFee: number; totalNetWithShipping: number; totalCapsules: number; - packsSelected: number; - selectedPlanCapsules: number; - requiredPacks: number; + totalPacks: number; + orderPackError: string | null; + remainingMinPacks: number; canProceed: boolean; onProceed: () => void; title: string; @@ -29,9 +30,9 @@ export default function SelectionSummaryCard({ selectedShippingFee, totalNetWithShipping, totalCapsules, - packsSelected, - selectedPlanCapsules, - requiredPacks, + totalPacks, + orderPackError, + remainingMinPacks, canProceed, onProceed, title, @@ -49,10 +50,10 @@ export default function SelectionSummaryCard({
{entry.coffee.name} - {entry.quantity} pcs • EUR {entry.coffee.pricePer10}/10 + {entry.quantity} packs ({packsToCapsules(entry.quantity)} capsules) • EUR {entry.coffee.pricePer10.toFixed(2)}/pack
-
EUR {((entry.quantity / 10) * entry.coffee.pricePer10).toFixed(2)}
+
EUR {(entry.quantity * entry.coffee.pricePer10).toFixed(2)}
))} @@ -68,11 +69,21 @@ export default function SelectionSummaryCard({ EUR {totalNetWithShipping.toFixed(2)}
-
- Selected: {totalCapsules} capsules ({packsSelected} packs of 10). Target: {selectedPlanCapsules} capsules ({requiredPacks} packs). - {packsSelected !== requiredPacks && ( - - {packsSelected < requiredPacks ? `${requiredPacks - packsSelected} packs missing.` : `${packsSelected - requiredPacks} packs too many.`} +
+
+ {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 ? ( + + {remainingMinPacks > 0 + ? `${remainingMinPacks} more pack${remainingMinPacks === 1 ? '' : 's'} needed to reach the minimum order.` + : orderPackError} + + ) : ( + + Selection is within the allowed order range. )}
@@ -101,7 +112,9 @@ export default function SelectionSummaryCard({ {!canProceed && (

- You can continue once exactly {selectedPlanCapsules} capsules ({requiredPacks} packs) are selected. + {remainingMinPacks > 0 + ? `You can continue once at least ${MIN_ABO_PACKS} packs are selected.` + : `Please reduce the order to ${MAX_ABO_PACKS.toLocaleString('en-US')} packs or fewer.`}

)} From a01bf249286c7bf485d26cdf094a7e6ca67d9052 Mon Sep 17 00:00:00 2001 From: seaznCode Date: Tue, 5 May 2026 22:09:31 +0200 Subject: [PATCH 07/13] Add SubscribeGuard component to manage subscription access and loading state --- .../components/SubscribeGuard.tsx | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 src/app/coffee-abonnements/components/SubscribeGuard.tsx diff --git a/src/app/coffee-abonnements/components/SubscribeGuard.tsx b/src/app/coffee-abonnements/components/SubscribeGuard.tsx new file mode 100644 index 0000000..3c571e0 --- /dev/null +++ b/src/app/coffee-abonnements/components/SubscribeGuard.tsx @@ -0,0 +1,32 @@ +'use client'; + +import type { ReactNode } from 'react'; +import PageLayout from '../../components/PageLayout'; +import { useSubscribeGuard } from '../hooks/useSubscribeGuard'; + +type Props = { + children: ReactNode; +}; + +export default function SubscribeGuard({ children }: Props) { + const { isChecking, isAllowed } = useSubscribeGuard(); + + if (!isAllowed) { + return ( + +
+
+
+
+

+ {isChecking ? 'Checking subscription access...' : 'Redirecting...'} +

+
+
+
+ + ); + } + + return <>{children}; +} \ No newline at end of file From 6441a39e7100fa17083ae9766c8b1054a3987512 Mon Sep 17 00:00:00 2001 From: seaznCode Date: Tue, 5 May 2026 22:10:07 +0200 Subject: [PATCH 08/13] Refactor useCoffeePictures to handle loading state and picture URLs more effectively --- src/app/coffee-abonnements/hooks/useCoffeePictures.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/app/coffee-abonnements/hooks/useCoffeePictures.ts b/src/app/coffee-abonnements/hooks/useCoffeePictures.ts index f326651..a170e63 100644 --- a/src/app/coffee-abonnements/hooks/useCoffeePictures.ts +++ b/src/app/coffee-abonnements/hooks/useCoffeePictures.ts @@ -40,7 +40,6 @@ export function useCoffeePictures(coffeeId?: string) { useEffect(() => { if (!coffeeId) { - setPictureUrls([]); return; } @@ -51,9 +50,10 @@ export function useCoffeePictures(coffeeId?: string) { ]; let isCancelled = false; - setLoading(true); const loadPictures = async () => { + if (!isCancelled) setLoading(true); + for (const url of candidateUrls) { try { const response = await authFetch(url, { @@ -105,5 +105,8 @@ export function useCoffeePictures(coffeeId?: string) { }; }, [coffeeId]); - return { pictureUrls, loading }; + return { + pictureUrls: coffeeId ? pictureUrls : [], + loading: coffeeId ? loading : false, + }; } From ba13f378d7dbcdc8c30fa1bdc789dd87bbe41590 Mon Sep 17 00:00:00 2001 From: seaznCode Date: Tue, 5 May 2026 22:10:15 +0200 Subject: [PATCH 09/13] Add useSubscribeGuard hook to manage subscription access and loading state --- .../hooks/useSubscribeGuard.ts | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 src/app/coffee-abonnements/hooks/useSubscribeGuard.ts diff --git a/src/app/coffee-abonnements/hooks/useSubscribeGuard.ts b/src/app/coffee-abonnements/hooks/useSubscribeGuard.ts new file mode 100644 index 0000000..a91db67 --- /dev/null +++ b/src/app/coffee-abonnements/hooks/useSubscribeGuard.ts @@ -0,0 +1,107 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import useAuthStore from '../../store/authStore'; + +type GuardState = 'checking' | 'allowed' | 'redirecting'; + +function hasPermission(permsSrc: any, permission: string) { + if (Array.isArray(permsSrc)) { + return ( + permsSrc.includes?.(permission) || + permsSrc.some?.((perm: any) => perm?.name === permission || perm?.key === permission) + ); + } + + if (permsSrc && typeof permsSrc === 'object') { + return !!permsSrc[permission]; + } + + return false; +} + +export function useSubscribeGuard() { + const router = useRouter(); + const user = useAuthStore((state) => state.user); + const isAuthReady = useAuthStore((state) => state.isAuthReady); + const accessToken = useAuthStore((state) => state.accessToken); + const refreshAuthToken = useAuthStore((state) => state.refreshAuthToken); + const [guardState, setGuardState] = useState('checking'); + + useEffect(() => { + let cancelled = false; + + const run = async () => { + if (!isAuthReady) { + if (!cancelled) setGuardState('checking'); + return; + } + + if (!user) { + if (!cancelled) setGuardState('redirecting'); + router.replace('/login'); + return; + } + + const uid = (user as any)?.id ?? (user as any)?._id ?? (user as any)?.userId; + if (!uid) { + if (!cancelled) setGuardState('redirecting'); + router.replace('/'); + return; + } + + let tokenToUse = accessToken; + try { + if (!tokenToUse && refreshAuthToken) { + const ok = await refreshAuthToken(); + if (ok) tokenToUse = useAuthStore.getState().accessToken; + } + } catch (error) { + console.error('useSubscribeGuard.refreshAuthToken', error); + } + + const base = process.env.NEXT_PUBLIC_API_BASE_URL || ''; + const url = `${base}/api/users/${uid}/permissions`; + + try { + const res = await fetch(url, { + method: 'GET', + cache: 'no-store', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + ...(tokenToUse ? { Authorization: `Bearer ${tokenToUse}` } : {}), + }, + }); + + const body = await res.json().catch(() => null); + const permsSrc = body?.data?.permissions ?? body?.permissions ?? body; + const allowed = hasPermission(permsSrc, 'can_subscribe'); + + if (!allowed) { + if (!cancelled) setGuardState('redirecting'); + router.replace('/'); + return; + } + + if (!cancelled) setGuardState('allowed'); + } catch (error) { + console.error('useSubscribeGuard.permissions', error); + if (!cancelled) setGuardState('redirecting'); + router.replace('/'); + } + }; + + run(); + return () => { + cancelled = true; + }; + }, [isAuthReady, user, accessToken, refreshAuthToken, router]); + + return { + isChecking: guardState === 'checking', + isAllowed: guardState === 'allowed', + isRedirecting: guardState === 'redirecting', + }; +} \ No newline at end of file From 1261d54c953e07ded7878aaf7bf2d265c813d92e Mon Sep 17 00:00:00 2001 From: seaznCode Date: Tue, 5 May 2026 22:10:30 +0200 Subject: [PATCH 10/13] Add order rules for coffee subscriptions including pack normalization and error handling --- src/app/coffee-abonnements/lib/orderRules.ts | 52 ++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 src/app/coffee-abonnements/lib/orderRules.ts diff --git a/src/app/coffee-abonnements/lib/orderRules.ts b/src/app/coffee-abonnements/lib/orderRules.ts new file mode 100644 index 0000000..affc874 --- /dev/null +++ b/src/app/coffee-abonnements/lib/orderRules.ts @@ -0,0 +1,52 @@ +export const CAPSULES_PER_PACK = 10; +export const MIN_ABO_PACKS = 6; +export const MAX_ABO_PACKS = 10000; +export const COFFEE_SELECTIONS_STORAGE_KEY = 'coffeeSelections'; +export const COFFEE_SELECTIONS_UNIT_STORAGE_KEY = 'coffeeSelectionsUnit'; +export const COFFEE_SELECTIONS_UNIT = 'packs-v1'; + +export function packsToCapsules(packs: number) { + return Math.max(0, Math.floor(Number(packs) || 0)) * CAPSULES_PER_PACK; +} + +export function normalizePackCount(value: unknown) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return 0; + return Math.max(0, Math.floor(parsed)); +} + +export function getOrderPackError(totalPacks: number) { + if (totalPacks < MIN_ABO_PACKS) { + return `Order must contain at least ${MIN_ABO_PACKS} packs (${packsToCapsules(MIN_ABO_PACKS)} capsules).`; + } + + if (totalPacks > MAX_ABO_PACKS) { + return `Order cannot contain more than ${MAX_ABO_PACKS.toLocaleString('en-US')} packs (${packsToCapsules(MAX_ABO_PACKS).toLocaleString('en-US')} capsules).`; + } + + return null; +} + +export function getRemainingMinPacks(totalPacks: number) { + return Math.max(0, MIN_ABO_PACKS - totalPacks); +} + +export function normalizeStoredSelections( + rawSelections: Record, + unit: string | null, +) { + const entries = Object.entries(rawSelections || {}); + + return entries.reduce>((acc, [coffeeId, value]) => { + const normalizedValue = normalizePackCount(value); + if (normalizedValue <= 0) return acc; + + const packCount = unit === COFFEE_SELECTIONS_UNIT + ? normalizedValue + : (normalizedValue % CAPSULES_PER_PACK === 0 ? normalizedValue / CAPSULES_PER_PACK : normalizedValue); + + if (packCount <= 0) return acc; + acc[coffeeId] = Math.min(packCount, MAX_ABO_PACKS); + return acc; + }, {}); +} \ No newline at end of file From 646c399ae90fa8b11ee769652bfd13e9290cbc6f Mon Sep 17 00:00:00 2001 From: seaznCode Date: Tue, 5 May 2026 22:10:56 +0200 Subject: [PATCH 11/13] Refactor subscribeAbo to enforce supported package sizes using getOrderPackError --- src/app/coffee-abonnements/summary/hooks/subscribeAbo.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/app/coffee-abonnements/summary/hooks/subscribeAbo.ts b/src/app/coffee-abonnements/summary/hooks/subscribeAbo.ts index a4bfda4..c327570 100644 --- a/src/app/coffee-abonnements/summary/hooks/subscribeAbo.ts +++ b/src/app/coffee-abonnements/summary/hooks/subscribeAbo.ts @@ -1,4 +1,5 @@ import { authFetch } from '../../../utils/authFetch' +import { getOrderPackError } from '../../lib/orderRules' export type SubscribeAboItem = { coffeeId: string | number; quantity?: number } export type SubscribeAboInput = { @@ -123,11 +124,11 @@ export async function subscribeAbo(input: SubscribeAboInput) { coffeeId: i.coffeeId, quantity: i.quantity != null ? i.quantity : 1, })) - // NEW: enforce supported package sizes const sumPacks = body.items.reduce((s: number, it: any) => s + Number(it.quantity || 0), 0) - if (sumPacks !== 6 && sumPacks !== 12) { - console.warn('[subscribeAbo] Invalid pack total:', sumPacks, 'expected 6 or 12') - throw new Error('Order must contain exactly 6 packs (60 capsules) or 12 packs (120 capsules).') + const orderPackError = getOrderPackError(sumPacks) + if (orderPackError) { + console.warn('[subscribeAbo] Invalid pack total:', sumPacks, orderPackError) + throw new Error(orderPackError) } } else { body.coffeeId = input.coffeeId From 1e258e4db09a6c46e536e7a98cfe65446ead970d Mon Sep 17 00:00:00 2001 From: seaznCode Date: Tue, 5 May 2026 22:11:18 +0200 Subject: [PATCH 12/13] Add order validation for subscription packs and update display message --- src/app/profile/subscriptions/page.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/app/profile/subscriptions/page.tsx b/src/app/profile/subscriptions/page.tsx index 228b9f8..8e3fec2 100644 --- a/src/app/profile/subscriptions/page.tsx +++ b/src/app/profile/subscriptions/page.tsx @@ -13,6 +13,7 @@ import FinanceInvoices from '../components/financeInvoices' import { useActiveCoffees } from '../../coffee-abonnements/hooks/getActiveCoffees' import { changeSubscriptionStatus, editSubscriptionContent } from '../hooks/editAbo' import ConfirmActionModal from '../../components/modals/ConfirmActionModal' +import { getOrderPackError, packsToCapsules } from '../../coffee-abonnements/lib/orderRules' type UiLifecycleStatus = 'issued' | 'ongoing' | 'finished' | 'pause' | 'cancelled' @@ -157,8 +158,9 @@ export default function ProfileSubscriptionsPage() { setContentError('Please select at least one coffee with quantity greater than 0.') return } - if (draftTotalPacks < 6) { - setContentError('Total must be at least 6 packs (60 capsules).') + const orderPackError = getOrderPackError(draftTotalPacks) + if (orderPackError) { + setContentError(orderPackError) return } @@ -425,7 +427,7 @@ export default function ProfileSubscriptionsPage() {

{t('autofix.ke24abf9c')}

-

Selected packs: {draftTotalPacks} (minimum 6)

+

Selected: {draftTotalPacks} packs ({packsToCapsules(draftTotalPacks)} capsules) · minimum 6 packs

{coffeesLoading ? ( From 9fcb5a50a7f336a9a04e08a17df923d836465aef Mon Sep 17 00:00:00 2001 From: seaznCode Date: Tue, 5 May 2026 22:11:25 +0200 Subject: [PATCH 13/13] Add SubscribeGuard to SummaryPage and enhance order validation logic --- src/app/coffee-abonnements/summary/page.tsx | 148 ++++++++++++++------ 1 file changed, 103 insertions(+), 45 deletions(-) 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}
)}