dev #21

Merged
Seazn merged 35 commits from dev into main 2026-05-21 17:34:44 +00:00
15 changed files with 496 additions and 252 deletions
Showing only changes of commit b12417874b - Show all commits

View File

@ -302,7 +302,7 @@ export default function CreateSubscriptionPage() {
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
{/* Price */} {/* Price */}
<div> <div>
<label htmlFor="price" className="block text-sm font-semibold text-slate-700 mb-1">Price</label> <label htmlFor="price" className="block text-sm font-semibold text-slate-700 mb-1">Price per pack</label>
<input <input
id="price" id="price"
name="price" name="price"
@ -316,6 +316,7 @@ export default function CreateSubscriptionPage() {
onChange={e => setPrice(e.target.value)} onChange={e => setPrice(e.target.value)}
onBlur={e => { const n = parseFloat(e.target.value); if (!isNaN(n)) setPrice(n.toFixed(2)); }} onBlur={e => { const n = parseFloat(e.target.value); if (!isNaN(n)) setPrice(n.toFixed(2)); }}
/> />
<p className="mt-1 text-xs text-slate-500">Enter the gross price for one pack. The system converts it to the internal per-capsule value automatically.</p>
</div> </div>
{/* Currency */} {/* Currency */}

View File

@ -233,7 +233,7 @@ export default function EditSubscriptionPage() {
/> />
)} )}
<div className="max-w-[1820px] mx-auto px-4 sm:px-6 xl:px-10 py-8 space-y-5"> <div className="max-w-455 mx-auto px-4 sm:px-6 xl:px-10 py-8 space-y-5">
{/* Header card */} {/* 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 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">
@ -289,11 +289,11 @@ export default function EditSubscriptionPage() {
</div> </div>
)} )}
{showThumb && ( {showThumb && (
<div className="relative w-full h-full min-h-[340px] flex items-center justify-center bg-slate-100 p-6"> <div className="relative flex h-full min-h-85 w-full items-center justify-center bg-slate-100 p-6">
<img <img
src={previewUrl || existingThumbnail || ''} src={previewUrl || existingThumbnail || ''}
alt={previewUrl ? 'Preview' : item.title} alt={previewUrl ? 'Preview' : item.title}
className="max-h-[320px] max-w-full object-contain rounded-xl shadow-lg" className="max-h-80 max-w-full object-contain rounded-xl shadow-lg"
/> />
<div className="absolute top-4 right-4 flex gap-2"> <div className="absolute top-4 right-4 flex gap-2">
{previewUrl && ( {previewUrl && (
@ -334,11 +334,12 @@ export default function EditSubscriptionPage() {
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
{/* Price */} {/* Price */}
<div> <div>
<label htmlFor="price" className="block text-sm font-semibold text-slate-700 mb-1">Price</label> <label htmlFor="price" className="block text-sm font-semibold text-slate-700 mb-1">Price per pack</label>
<input id="price" type="number" min={0} step={0.01} required <input id="price" type="number" min={0} step={0.01} required
className="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" className="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"
value={price} onChange={e => setPrice(e.target.value)} value={price} onChange={e => setPrice(e.target.value)}
onBlur={e => { const n = parseFloat(e.target.value); if (!isNaN(n)) setPrice(n.toFixed(2)); }} /> onBlur={e => { const n = parseFloat(e.target.value); if (!isNaN(n)) setPrice(n.toFixed(2)); }} />
<p className="mt-1 text-xs text-slate-500">Admin input is handled per pack. The backend continues storing the internal per-capsule value automatically.</p>
</div> </div>
{/* Currency */} {/* Currency */}

View File

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

View File

@ -138,7 +138,7 @@ export default function AdminSubscriptionsPage() {
return ( return (
<PageLayout contentClassName="flex-1 relative w-full"> <PageLayout contentClassName="flex-1 relative w-full">
<div className="max-w-[1820px] mx-auto px-4 sm:px-6 xl:px-10 py-8 space-y-5"> <div className="max-w-455 mx-auto px-4 sm:px-6 xl:px-10 py-8 space-y-5">
{/* Header */} {/* Header */}
<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 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> <div>
@ -197,9 +197,9 @@ export default function AdminSubscriptionsPage() {
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4"> <div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="text-sm font-semibold text-slate-900 break-words">{pieceCount} pieces</div> <div className="text-sm font-semibold text-slate-900 wrap-break-word">{pieceCount} pieces</div>
{typeof current?.price === 'number' && Number.isFinite(current.price) ? ( {typeof current?.price === 'number' && Number.isFinite(current.price) ? (
<div className="text-xs text-slate-500 break-words">Current: {formatPriceDraft(current.price)}</div> <div className="text-xs text-slate-500 wrap-break-word">Current: {formatPriceDraft(current.price)}</div>
) : null} ) : null}
{savedAt ? ( {savedAt ? (
<div className="text-xs text-emerald-700 bg-emerald-50 ring-1 ring-inset ring-emerald-200 px-2 py-0.5 rounded-full"> <div className="text-xs text-emerald-700 bg-emerald-50 ring-1 ring-inset ring-emerald-200 px-2 py-0.5 rounded-full">
@ -270,7 +270,7 @@ export default function AdminSubscriptionsPage() {
<p className="text-sm text-slate-600 line-clamp-3">{item.description}</p> <p className="text-sm text-slate-600 line-clamp-3">{item.description}</p>
<dl className="grid grid-cols-1 gap-y-1 text-sm"> <dl className="grid grid-cols-1 gap-y-1 text-sm">
<div> <div>
<dt className="text-xs text-slate-400">Price</dt> <dt className="text-xs text-slate-400">Price per pack</dt>
<dd className="font-semibold text-slate-900"> <dd className="font-semibold text-slate-900">
{item.currency || 'EUR'} {Number.isFinite(Number(item.price)) ? Number(item.price).toFixed(2) : String(item.price)} {item.currency || 'EUR'} {Number.isFinite(Number(item.price)) ? Number(item.price).toFixed(2) : String(item.price)}
</dd> </dd>
@ -318,7 +318,7 @@ export default function AdminSubscriptionsPage() {
<div className="w-full max-w-md rounded-[28px] border border-white/80 bg-white/90 backdrop-blur shadow-[0_32px_80px_-40px_rgba(15,23,42,0.42)]"> <div className="w-full max-w-md rounded-[28px] border border-white/80 bg-white/90 backdrop-blur shadow-[0_32px_80px_-40px_rgba(15,23,42,0.42)]">
<div className="px-6 pt-6"> <div className="px-6 pt-6">
<h3 className="text-lg font-semibold text-slate-900">{t('autofix.kddd4832f')}</h3> <h3 className="text-lg font-semibold text-slate-900">{t('autofix.kddd4832f')}</h3>
<p className="mt-2 text-sm text-slate-600">You are about to delete the coffee "{deleteTarget.title}". This action cannot be undone.</p> <p className="mt-2 text-sm text-slate-600">You are about to delete the coffee &quot;{deleteTarget.title}&quot;. This action cannot be undone.</p>
</div> </div>
<div className="px-6 pb-6 pt-4 flex justify-end gap-3"> <div className="px-6 pb-6 pt-4 flex justify-end gap-3">
<button <button

View File

@ -7,8 +7,17 @@ import { useActiveCoffees } from '../hooks/getActiveCoffees';
import CoffeeDetailGallery from '../components/CoffeeDetailGallery'; import CoffeeDetailGallery from '../components/CoffeeDetailGallery';
import { useCoffeePictures } from '../hooks/useCoffeePictures'; import { useCoffeePictures } from '../hooks/useCoffeePictures';
import { useTranslation } from '../../i18n/useTranslation'; import { useTranslation } from '../../i18n/useTranslation';
import SubscribeGuard from '../components/SubscribeGuard';
export default function CoffeeAbonnementDetailPage() { export default function CoffeeAbonnementDetailPage() {
return (
<SubscribeGuard>
<CoffeeAbonnementDetailPageContent />
</SubscribeGuard>
);
}
function CoffeeAbonnementDetailPageContent() {
const { t } = useTranslation(); const { t } = useTranslation();
const params = useParams(); const params = useParams();
const { coffees, loading, error } = useActiveCoffees(); const { coffees, loading, error } = useActiveCoffees();
@ -23,7 +32,7 @@ export default function CoffeeAbonnementDetailPage() {
return ( return (
<PageLayout contentClassName="flex-1 relative w-full"> <PageLayout contentClassName="flex-1 relative w-full">
<div className="min-h-screen bg-[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="min-h-screen bg-[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-[1820px] mx-auto px-4 sm:px-6 xl:px-10 py-8 space-y-5"> <div className="max-w-455 mx-auto px-4 sm:px-6 xl:px-10 py-8 space-y-5">
<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 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> <div>
<div className="inline-flex items-center rounded-full border border-slate-200 bg-white px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.28em] text-slate-500">{t('autofix.kbdc4e405')}</div> <div className="inline-flex items-center rounded-full border border-slate-200 bg-white px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.28em] text-slate-500">{t('autofix.kbdc4e405')}</div>
@ -71,12 +80,12 @@ export default function CoffeeAbonnementDetailPage() {
<div className="mt-5 space-y-2"> <div className="mt-5 space-y-2">
<div className="flex items-center justify-between rounded-xl border border-slate-200 bg-slate-50 px-4 py-2"> <div className="flex items-center justify-between rounded-xl border border-slate-200 bg-slate-50 px-4 py-2">
<span className="text-sm text-slate-600">{t('autofix.kab208d8e')}</span> <span className="text-sm text-slate-600">Price per pack</span>
<span className="text-sm font-semibold text-slate-900">EUR {coffee.pricePer10.toFixed(2)}</span> <span className="text-sm font-semibold text-slate-900">EUR {coffee.pricePer10.toFixed(2)}</span>
</div> </div>
<div className="flex items-center justify-between rounded-xl border border-slate-200 bg-slate-50 px-4 py-2"> <div className="flex items-center justify-between rounded-xl border border-slate-200 bg-slate-50 px-4 py-2">
<span className="text-sm text-slate-600">{t('autofix.ke0eb10f2')}</span> <span className="text-sm text-slate-600">Capsules per pack</span>
<span className="text-sm font-semibold text-slate-900">EUR {(coffee.pricePer10 / 10).toFixed(2)}</span> <span className="text-sm font-semibold text-slate-900">10 capsules</span>
</div> </div>
<div className="flex items-center justify-between rounded-xl border border-slate-200 bg-slate-50 px-4 py-2"> <div className="flex items-center justify-between rounded-xl border border-slate-200 bg-slate-50 px-4 py-2">
<span className="text-sm text-slate-600">{t('autofix.k5bd8edf9')}</span> <span className="text-sm text-slate-600">{t('autofix.k5bd8edf9')}</span>
@ -88,7 +97,7 @@ export default function CoffeeAbonnementDetailPage() {
</div> </div>
<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"> <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">
<p className="text-sm text-slate-600">{t('autofix.k31c4f5d9')}</p> <p className="text-sm text-slate-600">Ready to add this coffee to your plan? Go back to the selection page and choose how many packs you want.</p>
<Link <Link
href="/coffee-abonnements" href="/coffee-abonnements"
className="mt-4 inline-flex items-center justify-center rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-800 transition" className="mt-4 inline-flex items-center justify-center rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-800 transition"

View File

@ -1,16 +1,15 @@
import Link from 'next/link'; import Link from 'next/link';
import type { CoffeeItem } from '../hooks/getActiveCoffees'; import type { CoffeeItem } from '../hooks/getActiveCoffees';
import { CAPSULES_PER_PACK, MAX_ABO_PACKS, MIN_ABO_PACKS, packsToCapsules } from '../lib/orderRules';
type Props = { type Props = {
coffees: CoffeeItem[]; coffees: CoffeeItem[];
loading: boolean; loading: boolean;
error: string | null; error: string | null;
selections: Record<string, number>; selections: Record<string, number>;
bump: Record<string, boolean>; totalPacks: number;
selectedPlanCapsules: number; onAdjustQuantity: (id: string, delta: number) => void;
totalCapsules: number; onSetQuantity: (id: string, nextQuantity: number) => void;
onToggleCoffee: (id: string) => void;
onChangeQuantity: (id: string, delta: number) => void;
title: string; title: string;
}; };
@ -19,16 +18,29 @@ export default function CoffeeSelectionGrid({
loading, loading,
error, error,
selections, selections,
bump, totalPacks,
selectedPlanCapsules, onAdjustQuantity,
totalCapsules, onSetQuantity,
onToggleCoffee,
onChangeQuantity,
title, title,
}: Props) { }: Props) {
const remainingCapacity = Math.max(0, MAX_ABO_PACKS - totalPacks);
return ( return (
<section 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"> <section 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-semibold text-slate-900 mb-4">{title}</h2> <div className="mb-4 flex flex-wrap items-end justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-slate-900">{title}</h2>
<p className="mt-1 text-sm text-slate-500">Choose your coffees in packs. One pack contains {CAPSULES_PER_PACK} capsules.</p>
<div className="mt-3 inline-flex items-center rounded-2xl border border-sky-200 bg-sky-50 px-4 py-2 text-sm font-medium text-sky-900">
Minimum order: {MIN_ABO_PACKS} packs ({packsToCapsules(MIN_ABO_PACKS)} capsules).
</div>
</div>
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-right">
<div className="text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">Capacity</div>
<div className="mt-1 text-lg font-bold text-slate-900">{remainingCapacity.toLocaleString('en-US')} packs left</div>
<div className="text-xs text-slate-500">up to {MAX_ABO_PACKS.toLocaleString('en-US')} packs per subscription</div>
</div>
</div>
{error && ( {error && (
<div className="mb-4 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{error}</div> <div className="mb-4 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{error}</div>
@ -46,13 +58,9 @@ export default function CoffeeSelectionGrid({
{coffees.map((coffee) => { {coffees.map((coffee) => {
const active = coffee.id in selections; const active = coffee.id in selections;
const qty = selections[coffee.id] || 0; const qty = selections[coffee.id] || 0;
const remainingCapsules = selectedPlanCapsules - totalCapsules; const maxForCoffee = qty + remainingCapacity;
const maxForCoffee = active ? Math.min(120, qty + remainingCapsules) : 0; const addableForCoffee = remainingCapacity;
const sliderMax = Math.max(10, maxForCoffee); const subtotal = qty * coffee.pricePer10;
const sliderProgress = sliderMax <= 10
? 100
: Math.min(100, Math.max(0, ((qty - 10) / (sliderMax - 10)) * 100));
const canAddCoffee = active || remainingCapsules >= 10;
return ( return (
<div <div
@ -77,9 +85,9 @@ export default function CoffeeSelectionGrid({
<span className={`inline-flex items-center justify-center rounded-full text-white text-[11px] font-bold px-3 py-1 shadow-lg ring-2 ring-white/50 backdrop-blur-sm ${ <span className={`inline-flex items-center justify-center rounded-full text-white text-[11px] font-bold px-3 py-1 shadow-lg ring-2 ring-white/50 backdrop-blur-sm ${
active ? 'bg-slate-900' : 'bg-slate-700/90' active ? 'bg-slate-900' : 'bg-slate-700/90'
}`}> }`}>
EUR {coffee.pricePer10} EUR {coffee.pricePer10.toFixed(2)}
</span> </span>
<span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-slate-900/90 text-white border border-white/20 shadow-sm backdrop-blur-sm">per 10</span> <span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-slate-900/90 text-white border border-white/20 shadow-sm backdrop-blur-sm">per pack</span>
</div> </div>
</Link> </Link>
@ -89,79 +97,59 @@ export default function CoffeeSelectionGrid({
<p className="mt-2 text-xs text-slate-600 leading-relaxed line-clamp-3">{coffee.description}</p> <p className="mt-2 text-xs text-slate-600 leading-relaxed line-clamp-3">{coffee.description}</p>
<div className="mt-3 flex gap-2"> <div className="mt-3 flex gap-2">
<button
type="button"
onClick={() => onToggleCoffee(coffee.id)}
disabled={!canAddCoffee}
className={`flex-1 text-xs font-semibold rounded-xl px-3 py-2 border transition ${
active
? 'border-slate-900 text-slate-900 bg-white hover:bg-slate-100'
: canAddCoffee
? 'border-slate-300 hover:bg-slate-100 text-slate-700'
: 'border-slate-200 bg-slate-100 text-slate-400 cursor-not-allowed'
}`}
>
{active ? 'Remove' : 'Add'}
</button>
<Link <Link
href={`/coffee-abonnements/${coffee.id}`} href={`/coffee-abonnements/${coffee.id}`}
className="inline-flex items-center text-xs font-semibold rounded-xl px-3 py-2 border border-slate-200 text-slate-600 hover:bg-slate-100 transition" className="inline-flex items-center justify-center text-xs font-semibold rounded-xl px-3 py-2 border border-slate-200 text-slate-600 hover:bg-slate-100 transition"
> >
View View
</Link> </Link>
</div> </div>
{active && ( <div className="mt-4 rounded-2xl border border-slate-200 bg-slate-50 p-3">
<div className="mt-4 flex flex-col gap-3"> <div className="flex items-center justify-between gap-3">
<div className="flex items-center justify-between"> <div>
<span className="text-[11px] font-medium text-slate-500">Quantity (10-{maxForCoffee} pcs)</span> <div className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-500">Pack selection</div>
<span className={`inline-flex items-center justify-center rounded-full bg-slate-900 text-white px-3 py-1 text-xs font-semibold transition-transform duration-300 ${bump[coffee.id] ? 'scale-110' : 'scale-100'}`}> <div className="mt-1 text-sm text-slate-500">{packsToCapsules(qty).toLocaleString('en-US')} capsules</div>
{qty} pcs
</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="rounded-full bg-slate-900 px-3 py-1 text-xs font-semibold text-white">
<button {qty.toLocaleString('en-US')} packs
onClick={() => onChangeQuantity(coffee.id, -10)}
disabled={qty <= 10}
className="h-8 w-14 rounded-full bg-slate-100 hover:bg-slate-200 text-xs font-medium transition active:scale-95"
>
-10
</button>
<div className="flex-1 relative">
<input
type="range"
min={10}
max={sliderMax}
step={10}
value={qty}
onChange={(e) => 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',
}}
/>
</div>
<button
onClick={() => onChangeQuantity(coffee.id, +10)}
disabled={qty + 10 > maxForCoffee}
className="h-8 w-14 rounded-full bg-slate-100 hover:bg-slate-200 text-xs font-medium transition active:scale-95"
>
+10
</button>
</div>
<div className="flex items-center justify-between text-[11px] text-slate-500">
<span>Subtotal</span>
<span className="font-semibold text-slate-700">EUR {((qty / 10) * coffee.pricePer10).toFixed(2)}</span>
</div> </div>
</div> </div>
)}
<div className="mt-3 flex items-center gap-2">
<button
type="button"
onClick={() => onAdjustQuantity(coffee.id, -1)}
disabled={qty <= 0}
className="h-10 w-10 rounded-full bg-white text-base font-bold text-slate-900 shadow-sm ring-1 ring-slate-200 transition hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-40"
>
-
</button>
<input
type="number"
min={0}
max={maxForCoffee}
step={1}
inputMode="numeric"
value={qty}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => onAdjustQuantity(coffee.id, +1)}
disabled={qty >= maxForCoffee}
className="h-10 w-10 rounded-full bg-white text-base font-bold text-slate-900 shadow-sm ring-1 ring-slate-200 transition hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-40"
>
+
</button>
</div>
<div className="mt-3 flex items-center justify-between text-[11px] text-slate-500">
<span>You can add {addableForCoffee.toLocaleString('en-US')} more packs here.</span>
<span className="font-semibold text-slate-700">EUR {subtotal.toFixed(2)}</span>
</div>
</div>
</div> </div>
); );
})} })}

View File

@ -1,4 +1,5 @@
import type { CoffeeItem } from '../hooks/getActiveCoffees'; import type { CoffeeItem } from '../hooks/getActiveCoffees';
import { MAX_ABO_PACKS, MIN_ABO_PACKS, packsToCapsules } from '../lib/orderRules';
type SelectedEntry = { type SelectedEntry = {
coffee: CoffeeItem; coffee: CoffeeItem;
@ -12,9 +13,9 @@ type Props = {
selectedShippingFee: number; selectedShippingFee: number;
totalNetWithShipping: number; totalNetWithShipping: number;
totalCapsules: number; totalCapsules: number;
packsSelected: number; totalPacks: number;
selectedPlanCapsules: number; orderPackError: string | null;
requiredPacks: number; remainingMinPacks: number;
canProceed: boolean; canProceed: boolean;
onProceed: () => void; onProceed: () => void;
title: string; title: string;
@ -29,9 +30,9 @@ export default function SelectionSummaryCard({
selectedShippingFee, selectedShippingFee,
totalNetWithShipping, totalNetWithShipping,
totalCapsules, totalCapsules,
packsSelected, totalPacks,
selectedPlanCapsules, orderPackError,
requiredPacks, remainingMinPacks,
canProceed, canProceed,
onProceed, onProceed,
title, title,
@ -49,10 +50,10 @@ export default function SelectionSummaryCard({
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-medium text-slate-800">{entry.coffee.name}</span> <span className="font-medium text-slate-800">{entry.coffee.name}</span>
<span className="text-xs text-slate-500"> <span className="text-xs text-slate-500">
{entry.quantity} pcs <span className="inline-flex items-center font-semibold text-slate-900">EUR {entry.coffee.pricePer10}/10</span> {entry.quantity} packs ({packsToCapsules(entry.quantity)} capsules) <span className="inline-flex items-center font-semibold text-slate-900">EUR {entry.coffee.pricePer10.toFixed(2)}/pack</span>
</span> </span>
</div> </div>
<div className="text-right font-semibold text-slate-800">EUR {((entry.quantity / 10) * entry.coffee.pricePer10).toFixed(2)}</div> <div className="text-right font-semibold text-slate-800">EUR {(entry.quantity * entry.coffee.pricePer10).toFixed(2)}</div>
</div> </div>
))} ))}
@ -68,11 +69,21 @@ export default function SelectionSummaryCard({
<span className="text-lg font-extrabold tracking-tight text-slate-900">EUR {totalNetWithShipping.toFixed(2)}</span> <span className="text-lg font-extrabold tracking-tight text-slate-900">EUR {totalNetWithShipping.toFixed(2)}</span>
</div> </div>
<div className="text-xs text-slate-700"> <div className="space-y-2 text-xs text-slate-700">
Selected: {totalCapsules} capsules ({packsSelected} packs of 10). Target: {selectedPlanCapsules} capsules ({requiredPacks} packs). <div>
{packsSelected !== requiredPacks && ( <span className="font-semibold">{totalPacks.toLocaleString('en-US')}</span> packs selected.
<span className="ml-2 inline-flex items-center rounded-md bg-rose-50 text-rose-700 px-2 py-1 border border-rose-200"> <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>
{packsSelected < requiredPacks ? `${requiredPacks - packsSelected} packs missing.` : `${packsSelected - requiredPacks} packs too many.`} </div>
{orderPackError ? (
<span className="inline-flex items-center rounded-md bg-rose-50 text-rose-700 px-2 py-1 border border-rose-200">
{remainingMinPacks > 0
? `${remainingMinPacks} more pack${remainingMinPacks === 1 ? '' : 's'} needed to reach the minimum order.`
: orderPackError}
</span>
) : (
<span className="inline-flex items-center rounded-md bg-emerald-50 text-emerald-700 px-2 py-1 border border-emerald-200">
Selection is within the allowed order range.
</span> </span>
)} )}
</div> </div>
@ -101,7 +112,9 @@ export default function SelectionSummaryCard({
{!canProceed && ( {!canProceed && (
<p className="text-xs text-slate-600"> <p className="text-xs text-slate-600">
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.`}
</p> </p>
)} )}
</section> </section>

View File

@ -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 (
<PageLayout contentClassName="flex-1 relative w-full">
<div className="min-h-screen bg-[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">
<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 text-center">
<div className="mx-auto h-10 w-10 rounded-full border-2 border-slate-200 border-t-slate-900 animate-spin" />
<p className="mt-4 text-sm text-slate-600">
{isChecking ? 'Checking subscription access...' : 'Redirecting...'}
</p>
</div>
</div>
</div>
</PageLayout>
);
}
return <>{children}</>;
}

View File

@ -40,7 +40,6 @@ export function useCoffeePictures(coffeeId?: string) {
useEffect(() => { useEffect(() => {
if (!coffeeId) { if (!coffeeId) {
setPictureUrls([]);
return; return;
} }
@ -51,9 +50,10 @@ export function useCoffeePictures(coffeeId?: string) {
]; ];
let isCancelled = false; let isCancelled = false;
setLoading(true);
const loadPictures = async () => { const loadPictures = async () => {
if (!isCancelled) setLoading(true);
for (const url of candidateUrls) { for (const url of candidateUrls) {
try { try {
const response = await authFetch(url, { const response = await authFetch(url, {
@ -105,5 +105,8 @@ export function useCoffeePictures(coffeeId?: string) {
}; };
}, [coffeeId]); }, [coffeeId]);
return { pictureUrls, loading }; return {
pictureUrls: coffeeId ? pictureUrls : [],
loading: coffeeId ? loading : false,
};
} }

View File

@ -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<GuardState>('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',
};
}

View File

@ -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<string, unknown>,
unit: string | null,
) {
const entries = Object.entries(rawSelections || {});
return entries.reduce<Record<string, number>>((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;
}, {});
}

View File

@ -6,17 +6,32 @@ import { useActiveCoffees } from './hooks/getActiveCoffees';
import { useShippingFees } from './hooks/useShippingFees'; import { useShippingFees } from './hooks/useShippingFees';
import AboHeroHeader from './components/AboHeroHeader'; import AboHeroHeader from './components/AboHeroHeader';
import AboStepper from './components/AboStepper'; import AboStepper from './components/AboStepper';
import PlanSelectorCard from './components/PlanSelectorCard';
import CoffeeSelectionGrid from './components/CoffeeSelectionGrid'; import CoffeeSelectionGrid from './components/CoffeeSelectionGrid';
import SelectionSummaryCard from './components/SelectionSummaryCard'; 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'; import { useTranslation } from '../i18n/useTranslation';
export default function CoffeeAbonnementPage() { export default function CoffeeAbonnementPage() {
return (
<SubscribeGuard>
<CoffeeAbonnementPageContent />
</SubscribeGuard>
);
}
function CoffeeAbonnementPageContent() {
const { t } = useTranslation(); const { t } = useTranslation();
const [selections, setSelections] = useState<Record<string, number>>({}); const [selections, setSelections] = useState<Record<string, number>>({});
const [bump, setBump] = useState<Record<string, boolean>>({});
const [selectedPlanCapsules, setSelectedPlanCapsules] = useState<number>(60);
const router = useRouter(); const router = useRouter();
// Fetch active coffees from the backend // Fetch active coffees from the backend
@ -24,32 +39,6 @@ export default function CoffeeAbonnementPage() {
// Shipping fees (threshold-based) // Shipping fees (threshold-based)
const { resolveShippingFee, loading: shippingLoading, error: shippingError } = useShippingFees(); 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( const selectedEntries = useMemo(
() => () =>
@ -64,61 +53,62 @@ export default function CoffeeAbonnementPage() {
const totalPrice = useMemo( const totalPrice = useMemo(
() => () =>
selectedEntries.reduce( selectedEntries.reduce(
(sum, entry) => sum + (entry.quantity / 10) * entry.coffee.pricePer10, (sum, entry) => sum + entry.quantity * entry.coffee.pricePer10,
0 0
), ),
[selectedEntries] [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( const totalNetWithShipping = useMemo(
() => totalPrice + (Number.isFinite(selectedShippingFee) ? selectedShippingFee : 0), () => totalPrice + (Number.isFinite(selectedShippingFee) ? selectedShippingFee : 0),
[totalPrice, selectedShippingFee] [totalPrice, selectedShippingFee]
); );
// NEW: enforce selected plan size (60 or 120 capsules) const canProceed = selectedEntries.length > 0 && !orderPackError;
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 proceedToSummary = () => { const proceedToSummary = () => {
if (!canProceed) return; if (!canProceed) return;
try { try {
sessionStorage.setItem('coffeeSelections', JSON.stringify(selections)); sessionStorage.setItem(COFFEE_SELECTIONS_STORAGE_KEY, JSON.stringify(selections));
sessionStorage.setItem('coffeeAboSizeCapsules', String(selectedPlanCapsules)); sessionStorage.setItem(COFFEE_SELECTIONS_UNIT_STORAGE_KEY, COFFEE_SELECTIONS_UNIT);
} catch {} } catch {}
router.push('/coffee-abonnements/summary'); router.push('/coffee-abonnements/summary');
}; };
const toggleCoffee = (id: string) => { const setQuantity = (id: string, nextValue: number) => {
setSelections((prev) => { setSelections((prev) => {
const copy = { ...prev }; const normalized = Math.max(0, Math.floor(Number(nextValue) || 0));
if (id in copy) { const current = prev[id] || 0;
delete copy[id]; const otherTotal = Object.entries(prev).reduce((sum, [key, qty]) => key === id ? sum : sum + qty, 0);
} else { const maxForCoffee = Math.max(0, MAX_ABO_PACKS - otherTotal);
const total = Object.values(copy).reduce((sum, qty) => sum + qty, 0); const bounded = Math.min(normalized, maxForCoffee);
if (total + 10 > selectedPlanCapsules) return prev;
copy[id] = 10; 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) => { const adjustQuantity = (id: string, delta: number) => {
setSelections((prev) => { const current = selections[id] || 0;
if (!(id in prev)) return prev; setQuantity(id, current + delta);
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;
});
}; };
return ( return (
@ -132,28 +122,14 @@ export default function CoffeeAbonnementPage() {
<AboStepper currentStep={1} /> <AboStepper currentStep={1} />
<PlanSelectorCard
selectedPlanCapsules={selectedPlanCapsules}
shippingLoading={shippingLoading}
isFreeShippingSelected={isFreeShippingSelected}
selectedShippingFee={selectedShippingFee}
shippingError={shippingError}
onDecrease={() => changePlanSize(-10)}
onIncrease={() => changePlanSize(+10)}
loadingText={t('autofix.k12a86c71')}
freeShippingText={t('autofix.ke7f0a9e3')}
/>
<CoffeeSelectionGrid <CoffeeSelectionGrid
coffees={coffees} coffees={coffees}
loading={loading} loading={loading}
error={error} error={error}
selections={selections} selections={selections}
bump={bump} totalPacks={totalPacks}
selectedPlanCapsules={selectedPlanCapsules} onAdjustQuantity={adjustQuantity}
totalCapsules={totalCapsules} onSetQuantity={setQuantity}
onToggleCoffee={toggleCoffee}
onChangeQuantity={changeQuantity}
title={t('autofix.k0b03e660')} title={t('autofix.k0b03e660')}
/> />
@ -164,9 +140,9 @@ export default function CoffeeAbonnementPage() {
selectedShippingFee={selectedShippingFee} selectedShippingFee={selectedShippingFee}
totalNetWithShipping={totalNetWithShipping} totalNetWithShipping={totalNetWithShipping}
totalCapsules={totalCapsules} totalCapsules={totalCapsules}
packsSelected={packsSelected} totalPacks={totalPacks}
selectedPlanCapsules={selectedPlanCapsules} orderPackError={orderPackError}
requiredPacks={requiredPacks} remainingMinPacks={remainingMinPacks}
canProceed={canProceed} canProceed={canProceed}
onProceed={proceedToSummary} onProceed={proceedToSummary}
title={t('autofix.ke7b634f2')} title={t('autofix.ke7b634f2')}

View File

@ -1,4 +1,5 @@
import { authFetch } from '../../../utils/authFetch' import { authFetch } from '../../../utils/authFetch'
import { getOrderPackError } from '../../lib/orderRules'
export type SubscribeAboItem = { coffeeId: string | number; quantity?: number } export type SubscribeAboItem = { coffeeId: string | number; quantity?: number }
export type SubscribeAboInput = { export type SubscribeAboInput = {
@ -123,11 +124,11 @@ export async function subscribeAbo(input: SubscribeAboInput) {
coffeeId: i.coffeeId, coffeeId: i.coffeeId,
quantity: i.quantity != null ? i.quantity : 1, 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) const sumPacks = body.items.reduce((s: number, it: any) => s + Number(it.quantity || 0), 0)
if (sumPacks !== 6 && sumPacks !== 12) { const orderPackError = getOrderPackError(sumPacks)
console.warn('[subscribeAbo] Invalid pack total:', sumPacks, 'expected 6 or 12') if (orderPackError) {
throw new Error('Order must contain exactly 6 packs (60 capsules) or 12 packs (120 capsules).') console.warn('[subscribeAbo] Invalid pack total:', sumPacks, orderPackError)
throw new Error(orderPackError)
} }
} else { } else {
body.coffeeId = input.coffeeId body.coffeeId = input.coffeeId

View File

@ -13,6 +13,17 @@ import { useAboContractTemplateHtml } from './hooks/useAboContractTemplateHtml'
import SignaturePad from './components/SignaturePad' import SignaturePad from './components/SignaturePad'
import { Dialog, DialogActions, DialogBody, DialogTitle } from '../../components/dialog' import { Dialog, DialogActions, DialogBody, DialogTitle } from '../../components/dialog'
import { createReferralLink } from '../../referral-management/hooks/generateReferralLink' 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']; const COLORS = ['#1C2B4A', '#233357', '#2A3B66', '#314475', '#3A4F88', '#5B6C9A'];
@ -65,8 +76,17 @@ function pickFirstString(...values: unknown[]): string {
// ── shared input class // ── 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 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 labelCls = 'block text-sm font-semibold text-slate-700 mb-1';
const requiredMarkCls = 'ml-1 text-red-500';
export default function SummaryPage() { export default function SummaryPage() {
return (
<SubscribeGuard>
<SummaryPageContent />
</SubscribeGuard>
)
}
function SummaryPageContent() {
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter(); const router = useRouter();
const { coffees, loading, error } = useActiveCoffees(); const { coffees, loading, error } = useActiveCoffees();
@ -79,7 +99,6 @@ export default function SummaryPage() {
const [contractPdfLoading, setContractPdfLoading] = useState(false) const [contractPdfLoading, setContractPdfLoading] = useState(false)
const [contractPdfError, setContractPdfError] = useState<string | null>(null) const [contractPdfError, setContractPdfError] = useState<string | null>(null)
const [selections, setSelections] = useState<Record<string, number>>({}); const [selections, setSelections] = useState<Record<string, number>>({});
const [selectedPlanCapsules, setSelectedPlanCapsules] = useState<number>(60);
const [signatureDataUrl, setSignatureDataUrl] = useState('') const [signatureDataUrl, setSignatureDataUrl] = useState('')
const [form, setForm] = useState({ const [form, setForm] = useState({
firstName: '', firstName: '',
@ -103,6 +122,7 @@ export default function SummaryPage() {
signingCity: '', signingCity: '',
}); });
const [showThanks, setShowThanks] = useState(false); const [showThanks, setShowThanks] = useState(false);
const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false);
const [guestMailtoHref, setGuestMailtoHref] = useState<string>('') const [guestMailtoHref, setGuestMailtoHref] = useState<string>('')
const [guestInviteLink, setGuestInviteLink] = useState<string>('') const [guestInviteLink, setGuestInviteLink] = useState<string>('')
const [confetti, setConfetti] = useState<{ left: number; delay: number; color: string }[]>([]); const [confetti, setConfetti] = useState<{ left: number; delay: number; color: string }[]>([]);
@ -316,11 +336,14 @@ export default function SummaryPage() {
useEffect(() => { useEffect(() => {
try { try {
const raw = sessionStorage.getItem('coffeeSelections'); const raw = sessionStorage.getItem(COFFEE_SELECTIONS_STORAGE_KEY);
if (raw) setSelections(JSON.parse(raw)); const unit = sessionStorage.getItem(COFFEE_SELECTIONS_UNIT_STORAGE_KEY);
const rawPlan = sessionStorage.getItem('coffeeAboSizeCapsules'); if (raw) {
const parsedPlan = rawPlan ? Number(rawPlan) : null; const parsed = JSON.parse(raw);
if (parsedPlan && Number.isInteger(parsedPlan) && parsedPlan >= 60 && parsedPlan % 10 === 0) setSelectedPlanCapsules(parsedPlan); if (parsed && typeof parsed === 'object') {
setSelections(normalizeStoredSelections(parsed, unit));
}
}
} catch {} } catch {}
}, []); }, []);
@ -340,9 +363,10 @@ export default function SummaryPage() {
[selections, coffees] [selections, coffees]
); );
const totalCapsules = useMemo(() => selectedEntries.reduce((sum, e) => sum + e.quantity, 0), [selectedEntries]) const totalPacks = useMemo(() => selectedEntries.reduce((sum, e) => sum + e.quantity, 0), [selectedEntries])
const totalPacks = totalCapsules / 10 const totalCapsules = useMemo(() => packsToCapsules(totalPacks), [totalPacks])
const requiredPacks = selectedPlanCapsules / 10 const orderPackError = useMemo(() => getOrderPackError(totalPacks), [totalPacks])
const remainingMinPacks = useMemo(() => getRemainingMinPacks(totalPacks), [totalPacks])
const rawUserId = user?.id const rawUserId = user?.id
const currentUserId = typeof rawUserId === 'number' ? rawUserId : (typeof rawUserId === 'string' && /^\d+$/.test(rawUserId) ? Number(rawUserId) : undefined) 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; }; return () => { active = false; };
}, [form.country, vatRates]); }, [form.country, vatRates]);
const totalPrice = useMemo(() => selectedEntries.reduce((sum, e) => sum + (e.quantity / 10) * e.coffee.pricePer10, 0), [selectedEntries]); const totalPrice = useMemo(() => selectedEntries.reduce((sum, e) => sum + e.quantity * e.coffee.pricePer10, 0), [selectedEntries]);
const shippingFee = useMemo(() => resolveShippingFee(selectedPlanCapsules), [resolveShippingFee, selectedPlanCapsules]); const shippingFee = useMemo(() => resolveShippingFee(totalCapsules), [resolveShippingFee, totalCapsules]);
const netWithShipping = useMemo(() => totalPrice + shippingFee, [totalPrice, shippingFee]); const netWithShipping = useMemo(() => totalPrice + shippingFee, [totalPrice, shippingFee]);
const effectiveTaxRate = isReverseCharge ? 0 : taxRate const effectiveTaxRate = isReverseCharge ? 0 : taxRate
const taxAmount = useMemo(() => totalPrice * effectiveTaxRate, [totalPrice, effectiveTaxRate]); const taxAmount = useMemo(() => totalPrice * effectiveTaxRate, [totalPrice, effectiveTaxRate]);
@ -420,20 +444,50 @@ export default function SummaryPage() {
const hasRequiredInvoiceFields = form.invoiceSameAsShipping || form.invoiceEmail.trim() !== '' const hasRequiredInvoiceFields = form.invoiceSameAsShipping || form.invoiceEmail.trim() !== ''
const hasSigningCity = form.signingCity.trim() !== '' const hasSigningCity = form.signingCity.trim() !== ''
const hasSignature = signatureDataUrl.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 && <span className={requiredMarkCls}>*</span>}
</>
)
const backToSelection = () => router.push('/coffee-abonnements'); const backToSelection = () => router.push('/coffee-abonnements');
const submit = async () => { const submit = async () => {
if (!canSubmit || submitLoading) return if (submitLoading) return
if (totalPacks !== requiredPacks) { setSubmitError(`Order must contain exactly ${requiredPacks} packs (${selectedPlanCapsules} capsules).`); 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 (!hasSigningCity) { setSubmitError('Signing city is required.'); return }
if (!hasSignature) { setSubmitError('Signature is required.'); return } if (!hasSignature) { setSubmitError('Signature is required.'); return }
setSubmitError(null) setSubmitError(null)
setSubmitLoading(true) setSubmitLoading(true)
try { try {
const payload: SubscribeAboInput = { 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, 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(), 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(), 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, referred_by: typeof currentUserId === 'number' ? currentUserId : undefined,
} }
await subscribeAbo(payload) await subscribeAbo(payload)
setHasAttemptedSubmit(false)
setGuestMailtoHref(''); setGuestInviteLink(''); setShowThanks(true); setGuestMailtoHref(''); setGuestInviteLink(''); setShowThanks(true);
try { sessionStorage.removeItem('coffeeSelections'); } catch {} try { sessionStorage.removeItem(COFFEE_SELECTIONS_STORAGE_KEY); } catch {}
try { sessionStorage.removeItem('coffeeAboSizeCapsules'); } catch {} try { sessionStorage.removeItem(COFFEE_SELECTIONS_UNIT_STORAGE_KEY); } catch {}
} catch (e: any) { } catch (e: any) {
setSubmitError(e?.message || 'Subscription could not be created.'); setSubmitError(e?.message || 'Subscription could not be created.');
} finally { } finally {
@ -467,7 +522,7 @@ export default function SummaryPage() {
className="min-h-screen" 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%)' }} 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-[1820px] mx-auto px-4 sm:px-6 xl:px-10 py-8 space-y-5"> <div className="max-w-455 mx-auto px-4 sm:px-6 xl:px-10 py-8 space-y-5">
{/* Header card */} {/* 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 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">
@ -541,32 +596,32 @@ export default function SummaryPage() {
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div> <div>
<label className={labelCls}>{t('autofix.kfe9527d8')}</label> <label className={labelCls}>{renderLabel(t('autofix.kfe9527d8'), true)}</label>
<input name="firstName" value={form.firstName} onChange={handleInput} className={inputCls} /> <input name="firstName" value={form.firstName} onChange={handleInput} className={getFieldClassName(firstNameError)} />
</div> </div>
<div> <div>
<label className={labelCls}>{t('autofix.k6a2c64e8')}</label> <label className={labelCls}>{renderLabel(t('autofix.k6a2c64e8'), true)}</label>
<input name="lastName" value={form.lastName} onChange={handleInput} className={inputCls} /> <input name="lastName" value={form.lastName} onChange={handleInput} className={getFieldClassName(lastNameError)} />
</div> </div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<label className={labelCls}>Email</label> <label className={labelCls}>{renderLabel('Email', true)}</label>
<input type="email" name="email" value={form.email} onChange={handleInput} className={inputCls} /> <input type="email" name="email" value={form.email} onChange={handleInput} className={getFieldClassName(emailError)} />
</div> </div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<label className={labelCls}>{t('autofix.kd1a2772d')}</label> <label className={labelCls}>{renderLabel(t('autofix.kd1a2772d'), true)}</label>
<input name="street" value={form.street} onChange={handleInput} className={inputCls} /> <input name="street" value={form.street} onChange={handleInput} className={getFieldClassName(streetError)} />
</div> </div>
<div> <div>
<label className={labelCls}>ZIP</label> <label className={labelCls}>{renderLabel('ZIP', true)}</label>
<input name="postalCode" value={form.postalCode} onChange={handleInput} className={inputCls} /> <input name="postalCode" value={form.postalCode} onChange={handleInput} className={getFieldClassName(postalCodeError)} />
</div> </div>
<div> <div>
<label className={labelCls}>City</label> <label className={labelCls}>{renderLabel('City', true)}</label>
<input name="city" value={form.city} onChange={handleInput} className={inputCls} /> <input name="city" value={form.city} onChange={handleInput} className={getFieldClassName(cityError)} />
</div> </div>
<div> <div>
<label className={labelCls}>Country</label> <label className={labelCls}>{renderLabel('Country', true)}</label>
<select name="country" value={form.country} onChange={handleInput} className={inputCls}> <select name="country" value={form.country} onChange={handleInput} className={getFieldClassName(countryError)}>
{countryOptions.map(code => <option key={code} value={code}>{code}</option>)} {countryOptions.map(code => <option key={code} value={code}>{code}</option>)}
</select> </select>
</div> </div>
@ -635,8 +690,8 @@ export default function SummaryPage() {
<input name="invoicePhone" value={form.invoicePhone} onChange={handleInput} className={inputCls} /> <input name="invoicePhone" value={form.invoicePhone} onChange={handleInput} className={inputCls} />
</div> </div>
<div> <div>
<label className={labelCls}>Email</label> <label className={labelCls}>{renderLabel('Email', true)}</label>
<input type="email" name="invoiceEmail" value={form.invoiceEmail} onChange={handleInput} className={inputCls} /> <input type="email" name="invoiceEmail" value={form.invoiceEmail} onChange={handleInput} className={getFieldClassName(invoiceEmailError)} />
</div> </div>
</div> </div>
)} )}
@ -666,27 +721,27 @@ export default function SummaryPage() {
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className={labelCls}>Ort (Signing City) *</label> <label className={labelCls}>{renderLabel('Ort (Signing City)', true)}</label>
<input type="text" name="signingCity" value={form.signingCity} onChange={handleInput} <input type="text" name="signingCity" value={form.signingCity} onChange={handleInput}
className={`${inputCls} max-w-xs ${!hasSigningCity && submitError ? 'border-red-400 focus:ring-red-400' : ''}`} className={getFieldClassName(signingCityError, 'max-w-xs')}
placeholder={t('autofix.k1f0b2c48')} /> placeholder={t('autofix.k1f0b2c48')} />
{!hasSigningCity && submitError && <p className="mt-1 text-xs text-red-600">{t('autofix.k516705dd')}</p>} {signingCityError && <p className="mt-1 text-xs text-red-600">{t('autofix.k516705dd')}</p>}
</div> </div>
<SignaturePad value={signatureDataUrl} onChange={setSignatureDataUrl} required error={!hasSignature && submitError ? 'Signature is required.' : null} /> <SignaturePad value={signatureDataUrl} onChange={setSignatureDataUrl} required error={signatureError ? 'Signature is required.' : null} />
</div> </div>
<button <button
onClick={submit} onClick={submit}
disabled={!canSubmit || submitLoading} 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 ${ 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 ${
canSubmit && !submitLoading ? 'bg-slate-900 text-white hover:bg-slate-700' : 'bg-slate-100 text-slate-400 cursor-not-allowed' 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 && <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 ? 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>} {!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> </button>
{!canSubmit && <p className="text-xs text-slate-500 text-center">{t('autofix.k1824f78d')}</p>} {!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> </div>
</section> </section>
@ -700,7 +755,7 @@ export default function SummaryPage() {
{selectedEntries.map(entry => ( {selectedEntries.map(entry => (
<div key={entry.coffee.id} className="flex gap-3 items-start"> <div key={entry.coffee.id} className="flex gap-3 items-start">
{/* Coffee picture */} {/* Coffee picture */}
<div className="flex-shrink-0 w-16 h-16 rounded-xl overflow-hidden border border-slate-100 bg-slate-50"> <div className="shrink-0 w-16 h-16 rounded-xl overflow-hidden border border-slate-100 bg-slate-50">
{entry.coffee.image ? ( {entry.coffee.image ? (
<img src={entry.coffee.image} alt={entry.coffee.name} className="w-full h-full object-cover" /> <img src={entry.coffee.image} alt={entry.coffee.name} className="w-full h-full object-cover" />
) : ( ) : (
@ -761,10 +816,13 @@ export default function SummaryPage() {
{/* Pack validation */} {/* Pack validation */}
<div className="rounded-xl border border-slate-100 bg-slate-50 px-3 py-2 text-xs text-slate-600"> <div className="rounded-xl border border-slate-100 bg-slate-50 px-3 py-2 text-xs text-slate-600">
<span className="font-semibold">{totalCapsules}</span> capsules selected ({totalPacks} packs). Target: <span className="font-semibold">{selectedPlanCapsules}</span> ({requiredPacks} packs). <span className="font-semibold">{totalPacks.toLocaleString('en-US')}</span> packs selected.
{totalPacks !== requiredPacks && ( <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"> <div className="mt-1 rounded-lg bg-red-50 border border-red-200 text-red-700 px-2 py-1 font-medium">
Exactly {requiredPacks} packs required. {remainingMinPacks > 0
? `${remainingMinPacks} more pack${remainingMinPacks === 1 ? '' : 's'} needed to reach the minimum order.`
: orderPackError}
</div> </div>
)} )}
</div> </div>

View File

@ -13,6 +13,7 @@ import FinanceInvoices from '../components/financeInvoices'
import { useActiveCoffees } from '../../coffee-abonnements/hooks/getActiveCoffees' import { useActiveCoffees } from '../../coffee-abonnements/hooks/getActiveCoffees'
import { changeSubscriptionStatus, editSubscriptionContent } from '../hooks/editAbo' import { changeSubscriptionStatus, editSubscriptionContent } from '../hooks/editAbo'
import ConfirmActionModal from '../../components/modals/ConfirmActionModal' import ConfirmActionModal from '../../components/modals/ConfirmActionModal'
import { getOrderPackError, packsToCapsules } from '../../coffee-abonnements/lib/orderRules'
type UiLifecycleStatus = 'issued' | 'ongoing' | 'finished' | 'pause' | 'cancelled' 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.') setContentError('Please select at least one coffee with quantity greater than 0.')
return return
} }
if (draftTotalPacks < 6) { const orderPackError = getOrderPackError(draftTotalPacks)
setContentError('Total must be at least 6 packs (60 capsules).') if (orderPackError) {
setContentError(orderPackError)
return return
} }
@ -425,7 +427,7 @@ export default function ProfileSubscriptionsPage() {
<div className="mt-4 rounded-md border border-gray-200 bg-white/90 p-4"> <div className="mt-4 rounded-md border border-gray-200 bg-white/90 p-4">
<div className="flex items-center justify-between gap-2 flex-wrap mb-3"> <div className="flex items-center justify-between gap-2 flex-wrap mb-3">
<h3 className="text-sm font-semibold text-gray-900">{t('autofix.ke24abf9c')}</h3> <h3 className="text-sm font-semibold text-gray-900">{t('autofix.ke24abf9c')}</h3>
<p className="text-xs text-gray-600">Selected packs: {draftTotalPacks} (minimum 6)</p> <p className="text-xs text-gray-600">Selected: {draftTotalPacks} packs ({packsToCapsules(draftTotalPacks)} capsules) · minimum 6 packs</p>
</div> </div>
{coffeesLoading ? ( {coffeesLoading ? (