diff --git a/src/app/admin/subscriptions/createSubscription/page.tsx b/src/app/admin/subscriptions/createSubscription/page.tsx index 1f4bff5..0488f46 100644 --- a/src/app/admin/subscriptions/createSubscription/page.tsx +++ b/src/app/admin/subscriptions/createSubscription/page.tsx @@ -302,7 +302,7 @@ export default function CreateSubscriptionPage() {
{/* Price */}
- + setPrice(e.target.value)} onBlur={e => { const n = parseFloat(e.target.value); if (!isNaN(n)) setPrice(n.toFixed(2)); }} /> +

Enter the gross price for one pack. The system converts it to the internal per-capsule value automatically.

{/* Currency */} diff --git a/src/app/admin/subscriptions/edit/[id]/page.tsx b/src/app/admin/subscriptions/edit/[id]/page.tsx index 5fd40b6..4066758 100644 --- a/src/app/admin/subscriptions/edit/[id]/page.tsx +++ b/src/app/admin/subscriptions/edit/[id]/page.tsx @@ -233,7 +233,7 @@ export default function EditSubscriptionPage() { /> )} -
+
{/* Header card */}
@@ -289,11 +289,11 @@ export default function EditSubscriptionPage() {
)} {showThumb && ( -
+
{previewUrl
{previewUrl && ( @@ -334,11 +334,12 @@ export default function EditSubscriptionPage() {
{/* Price */}
- + setPrice(e.target.value)} onBlur={e => { const n = parseFloat(e.target.value); if (!isNaN(n)) setPrice(n.toFixed(2)); }} /> +

Admin input is handled per pack. The backend continues storing the internal per-capsule value automatically.

{/* Currency */} diff --git a/src/app/admin/subscriptions/hooks/useCoffeeManagement.ts b/src/app/admin/subscriptions/hooks/useCoffeeManagement.ts index 76127c2..1c7b2e6 100644 --- a/src/app/admin/subscriptions/hooks/useCoffeeManagement.ts +++ b/src/app/admin/subscriptions/hooks/useCoffeeManagement.ts @@ -1,5 +1,6 @@ import { useCallback } from 'react'; import useAuthStore from '../../../store/authStore'; +import { CAPSULES_PER_PACK } from '../../../coffee-abonnements/lib/orderRules'; export type CoffeeItem = { id: number; @@ -63,7 +64,7 @@ export default function useCoffeeManagement() { const text = await res.text(); try { return JSON.parse(text) as T; } catch { return {} as T; } }, - [base] + [base, getState] ); const listProducts = useCallback(async (): Promise => { @@ -72,7 +73,7 @@ export default function useCoffeeManagement() { return data.map((r: any) => ({ ...r, id: Number(r.id), - price: r.price != null && r.price !== '' ? Number(r.price) : 0, + price: r.price != null && r.price !== '' ? Number(r.price) * CAPSULES_PER_PACK : 0, interval_count: r.interval_count != null && r.interval_count !== '' ? Number(r.interval_count) : null, state: !!r.state, })) as CoffeeItem[]; @@ -91,7 +92,7 @@ export default function useCoffeeManagement() { const appendBaseFields = (fd: FormData) => { fd.append('title', payload.title); fd.append('description', payload.description); - fd.append('price', String(payload.price)); + fd.append('price', String(payload.price / CAPSULES_PER_PACK)); if (payload.currency) fd.append('currency', payload.currency); if (typeof payload.is_featured === 'boolean') fd.append('is_featured', String(payload.is_featured)); if (typeof payload.state === 'boolean') fd.append('state', String(payload.state)); @@ -140,7 +141,7 @@ export default function useCoffeeManagement() { const fd = new FormData(); if (payload.title !== undefined) fd.append('title', String(payload.title)); if (payload.description !== undefined) fd.append('description', String(payload.description)); - if (payload.price !== undefined) fd.append('price', String(payload.price)); + if (payload.price !== undefined) fd.append('price', String(payload.price / CAPSULES_PER_PACK)); if (payload.currency !== undefined) fd.append('currency', payload.currency); if (payload.is_featured !== undefined) fd.append('is_featured', String(payload.is_featured)); if (payload.state !== undefined) fd.append('state', String(payload.state)); 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)} +
+
); })} 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.`}

)} 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 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, + }; } 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 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 diff --git a/src/app/coffee-abonnements/page.tsx b/src/app/coffee-abonnements/page.tsx index a9db8a5..04d5ba4 100644 --- a/src/app/coffee-abonnements/page.tsx +++ b/src/app/coffee-abonnements/page.tsx @@ -6,17 +6,32 @@ import { useActiveCoffees } from './hooks/getActiveCoffees'; import { useShippingFees } from './hooks/useShippingFees'; import AboHeroHeader from './components/AboHeroHeader'; import AboStepper from './components/AboStepper'; -import PlanSelectorCard from './components/PlanSelectorCard'; import CoffeeSelectionGrid from './components/CoffeeSelectionGrid'; import SelectionSummaryCard from './components/SelectionSummaryCard'; +import SubscribeGuard from './components/SubscribeGuard'; +import { + COFFEE_SELECTIONS_STORAGE_KEY, + COFFEE_SELECTIONS_UNIT, + COFFEE_SELECTIONS_UNIT_STORAGE_KEY, + getOrderPackError, + getRemainingMinPacks, + MAX_ABO_PACKS, + packsToCapsules, +} from './lib/orderRules'; import { useTranslation } from '../i18n/useTranslation'; export default function CoffeeAbonnementPage() { + return ( + + + + ); +} + +function CoffeeAbonnementPageContent() { const { t } = useTranslation(); const [selections, setSelections] = useState>({}); - const [bump, setBump] = useState>({}); - const [selectedPlanCapsules, setSelectedPlanCapsules] = useState(60); const router = useRouter(); // Fetch active coffees from the backend @@ -24,32 +39,6 @@ export default function CoffeeAbonnementPage() { // Shipping fees (threshold-based) const { resolveShippingFee, loading: shippingLoading, error: shippingError } = useShippingFees(); - const selectedShippingFee = resolveShippingFee(selectedPlanCapsules); - const isFreeShippingSelected = Number(selectedShippingFee) === 0; - - const changePlanSize = (delta: number) => { - setSelectedPlanCapsules(prev => { - const next = Math.max(60, prev + delta); - // Trim selections that exceed the new plan size - setSelections(sel => { - const trimmed = { ...sel }; - let running = 0; - for (const id of Object.keys(trimmed)) { - if (running + trimmed[id] <= next) { - running += trimmed[id]; - } else if (running < next) { - const allowed = Math.floor((next - running) / 10) * 10; - if (allowed >= 10) { trimmed[id] = allowed; running = next; } - else delete trimmed[id]; - } else { - delete trimmed[id]; - } - } - return trimmed; - }); - return next; - }); - }; const selectedEntries = useMemo( () => @@ -64,61 +53,62 @@ export default function CoffeeAbonnementPage() { const totalPrice = useMemo( () => selectedEntries.reduce( - (sum, entry) => sum + (entry.quantity / 10) * entry.coffee.pricePer10, + (sum, entry) => sum + entry.quantity * entry.coffee.pricePer10, 0 ), [selectedEntries] ); + const totalPacks = useMemo( + () => selectedEntries.reduce((sum, entry) => sum + entry.quantity, 0), + [selectedEntries] + ); + + const totalCapsules = useMemo(() => packsToCapsules(totalPacks), [totalPacks]); + const selectedShippingFee = resolveShippingFee(totalCapsules); + const isFreeShippingSelected = Number(selectedShippingFee) === 0; + const orderPackError = getOrderPackError(totalPacks); + const remainingMinPacks = getRemainingMinPacks(totalPacks); + const totalNetWithShipping = useMemo( () => totalPrice + (Number.isFinite(selectedShippingFee) ? selectedShippingFee : 0), [totalPrice, selectedShippingFee] ); - // NEW: enforce selected plan size (60 or 120 capsules) - const totalCapsules = useMemo( - () => selectedEntries.reduce((sum, entry) => sum + entry.quantity, 0), - [selectedEntries] - ); - const packsSelected = totalCapsules / 10; - const requiredPacks = selectedPlanCapsules / 10; - const canProceed = packsSelected === requiredPacks; + const canProceed = selectedEntries.length > 0 && !orderPackError; const proceedToSummary = () => { if (!canProceed) return; try { - sessionStorage.setItem('coffeeSelections', JSON.stringify(selections)); - sessionStorage.setItem('coffeeAboSizeCapsules', String(selectedPlanCapsules)); + sessionStorage.setItem(COFFEE_SELECTIONS_STORAGE_KEY, JSON.stringify(selections)); + sessionStorage.setItem(COFFEE_SELECTIONS_UNIT_STORAGE_KEY, COFFEE_SELECTIONS_UNIT); } catch {} router.push('/coffee-abonnements/summary'); }; - const toggleCoffee = (id: string) => { + const setQuantity = (id: string, nextValue: number) => { setSelections((prev) => { - const copy = { ...prev }; - if (id in copy) { - delete copy[id]; - } else { - const total = Object.values(copy).reduce((sum, qty) => sum + qty, 0); - if (total + 10 > selectedPlanCapsules) return prev; - copy[id] = 10; + const normalized = Math.max(0, Math.floor(Number(nextValue) || 0)); + const current = prev[id] || 0; + const otherTotal = Object.entries(prev).reduce((sum, [key, qty]) => key === id ? sum : sum + qty, 0); + const maxForCoffee = Math.max(0, MAX_ABO_PACKS - otherTotal); + const bounded = Math.min(normalized, maxForCoffee); + + if (bounded <= 0) { + if (!(id in prev)) return prev; + const next = { ...prev }; + delete next[id]; + return next; } - return copy; + + if (bounded === current) return prev; + return { ...prev, [id]: bounded }; }); }; - const changeQuantity = (id: string, delta: number) => { - setSelections((prev) => { - if (!(id in prev)) return prev; - const otherTotal = Object.entries(prev).reduce((sum, [key, qty]) => key === id ? sum : sum + qty, 0); - const maxForCoffee = selectedPlanCapsules - otherTotal; - const next = prev[id] + delta; - if (next < 10 || next > maxForCoffee) return prev; - const updated = { ...prev, [id]: next }; - setBump((b) => ({ ...b, [id]: true })); - setTimeout(() => setBump((b) => ({ ...b, [id]: false })), 250); - return updated; - }); + const adjustQuantity = (id: string, delta: number) => { + const current = selections[id] || 0; + setQuantity(id, current + delta); }; return ( @@ -132,28 +122,14 @@ export default function CoffeeAbonnementPage() { - changePlanSize(-10)} - onIncrease={() => changePlanSize(+10)} - loadingText={t('autofix.k12a86c71')} - freeShippingText={t('autofix.ke7f0a9e3')} - /> - @@ -164,9 +140,9 @@ export default function CoffeeAbonnementPage() { selectedShippingFee={selectedShippingFee} totalNetWithShipping={totalNetWithShipping} totalCapsules={totalCapsules} - packsSelected={packsSelected} - selectedPlanCapsules={selectedPlanCapsules} - requiredPacks={requiredPacks} + totalPacks={totalPacks} + orderPackError={orderPackError} + remainingMinPacks={remainingMinPacks} canProceed={canProceed} onProceed={proceedToSummary} title={t('autofix.ke7b634f2')} 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 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}
)}
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 ? (