dev #21
@ -302,7 +302,7 @@ export default function CreateSubscriptionPage() {
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
{/* Price */}
|
||||
<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"
|
||||
name="price"
|
||||
@ -316,6 +316,7 @@ export default function CreateSubscriptionPage() {
|
||||
onChange={e => setPrice(e.target.value)}
|
||||
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>
|
||||
|
||||
{/* Currency */}
|
||||
|
||||
@ -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 */}
|
||||
<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>
|
||||
)}
|
||||
{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
|
||||
src={previewUrl || existingThumbnail || ''}
|
||||
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">
|
||||
{previewUrl && (
|
||||
@ -334,11 +334,12 @@ export default function EditSubscriptionPage() {
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
{/* Price */}
|
||||
<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
|
||||
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)}
|
||||
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>
|
||||
|
||||
{/* Currency */}
|
||||
|
||||
@ -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<CoffeeItem[]> => {
|
||||
@ -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));
|
||||
|
||||
@ -138,7 +138,7 @@ export default function AdminSubscriptionsPage() {
|
||||
|
||||
return (
|
||||
<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 */}
|
||||
<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>
|
||||
@ -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="min-w-0">
|
||||
<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) ? (
|
||||
<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}
|
||||
{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">
|
||||
@ -270,7 +270,7 @@ export default function AdminSubscriptionsPage() {
|
||||
<p className="text-sm text-slate-600 line-clamp-3">{item.description}</p>
|
||||
<dl className="grid grid-cols-1 gap-y-1 text-sm">
|
||||
<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">
|
||||
{item.currency || 'EUR'} {Number.isFinite(Number(item.price)) ? Number(item.price).toFixed(2) : String(item.price)}
|
||||
</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="px-6 pt-6">
|
||||
<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 "{deleteTarget.title}". This action cannot be undone.</p>
|
||||
</div>
|
||||
<div className="px-6 pb-6 pt-4 flex justify-end gap-3">
|
||||
<button
|
||||
|
||||
@ -7,8 +7,17 @@ import { useActiveCoffees } from '../hooks/getActiveCoffees';
|
||||
import CoffeeDetailGallery from '../components/CoffeeDetailGallery';
|
||||
import { useCoffeePictures } from '../hooks/useCoffeePictures';
|
||||
import { useTranslation } from '../../i18n/useTranslation';
|
||||
import SubscribeGuard from '../components/SubscribeGuard';
|
||||
|
||||
export default function CoffeeAbonnementDetailPage() {
|
||||
return (
|
||||
<SubscribeGuard>
|
||||
<CoffeeAbonnementDetailPageContent />
|
||||
</SubscribeGuard>
|
||||
);
|
||||
}
|
||||
|
||||
function CoffeeAbonnementDetailPageContent() {
|
||||
const { t } = useTranslation();
|
||||
const params = useParams();
|
||||
const { coffees, loading, error } = useActiveCoffees();
|
||||
@ -23,7 +32,7 @@ export default function CoffeeAbonnementDetailPage() {
|
||||
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-[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>
|
||||
<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="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>
|
||||
</div>
|
||||
<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 font-semibold text-slate-900">EUR {(coffee.pricePer10 / 10).toFixed(2)}</span>
|
||||
<span className="text-sm text-slate-600">Capsules per pack</span>
|
||||
<span className="text-sm font-semibold text-slate-900">10 capsules</span>
|
||||
</div>
|
||||
<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>
|
||||
@ -88,7 +97,7 @@ export default function CoffeeAbonnementDetailPage() {
|
||||
</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">
|
||||
<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
|
||||
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"
|
||||
|
||||
@ -1,16 +1,15 @@
|
||||
import Link from 'next/link';
|
||||
import type { CoffeeItem } from '../hooks/getActiveCoffees';
|
||||
import { CAPSULES_PER_PACK, MAX_ABO_PACKS, MIN_ABO_PACKS, packsToCapsules } from '../lib/orderRules';
|
||||
|
||||
type Props = {
|
||||
coffees: CoffeeItem[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
selections: Record<string, number>;
|
||||
bump: Record<string, boolean>;
|
||||
selectedPlanCapsules: number;
|
||||
totalCapsules: number;
|
||||
onToggleCoffee: (id: string) => void;
|
||||
onChangeQuantity: (id: string, delta: number) => void;
|
||||
totalPacks: number;
|
||||
onAdjustQuantity: (id: string, delta: number) => void;
|
||||
onSetQuantity: (id: string, nextQuantity: number) => void;
|
||||
title: string;
|
||||
};
|
||||
|
||||
@ -19,16 +18,29 @@ export default function CoffeeSelectionGrid({
|
||||
loading,
|
||||
error,
|
||||
selections,
|
||||
bump,
|
||||
selectedPlanCapsules,
|
||||
totalCapsules,
|
||||
onToggleCoffee,
|
||||
onChangeQuantity,
|
||||
totalPacks,
|
||||
onAdjustQuantity,
|
||||
onSetQuantity,
|
||||
title,
|
||||
}: Props) {
|
||||
const remainingCapacity = Math.max(0, MAX_ABO_PACKS - totalPacks);
|
||||
|
||||
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">
|
||||
<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 && (
|
||||
<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) => {
|
||||
const active = coffee.id in selections;
|
||||
const qty = selections[coffee.id] || 0;
|
||||
const remainingCapsules = selectedPlanCapsules - totalCapsules;
|
||||
const maxForCoffee = active ? Math.min(120, qty + remainingCapsules) : 0;
|
||||
const sliderMax = Math.max(10, maxForCoffee);
|
||||
const sliderProgress = sliderMax <= 10
|
||||
? 100
|
||||
: Math.min(100, Math.max(0, ((qty - 10) / (sliderMax - 10)) * 100));
|
||||
const canAddCoffee = active || remainingCapsules >= 10;
|
||||
const maxForCoffee = qty + remainingCapacity;
|
||||
const addableForCoffee = remainingCapacity;
|
||||
const subtotal = qty * coffee.pricePer10;
|
||||
|
||||
return (
|
||||
<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 ${
|
||||
active ? 'bg-slate-900' : 'bg-slate-700/90'
|
||||
}`}>
|
||||
EUR {coffee.pricePer10}
|
||||
EUR {coffee.pricePer10.toFixed(2)}
|
||||
</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>
|
||||
</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>
|
||||
|
||||
<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
|
||||
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
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{active && (
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] font-medium text-slate-500">Quantity (10-{maxForCoffee} pcs)</span>
|
||||
<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'}`}>
|
||||
{qty} pcs
|
||||
</span>
|
||||
<div className="mt-4 rounded-2xl border border-slate-200 bg-slate-50 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-500">Pack selection</div>
|
||||
<div className="mt-1 text-sm text-slate-500">{packsToCapsules(qty).toLocaleString('en-US')} capsules</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
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 className="rounded-full bg-slate-900 px-3 py-1 text-xs font-semibold text-white">
|
||||
{qty.toLocaleString('en-US')} packs
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -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({
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-slate-800">{entry.coffee.name}</span>
|
||||
<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>
|
||||
</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>
|
||||
))}
|
||||
|
||||
@ -68,11 +69,21 @@ export default function SelectionSummaryCard({
|
||||
<span className="text-lg font-extrabold tracking-tight text-slate-900">EUR {totalNetWithShipping.toFixed(2)}</span>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-slate-700">
|
||||
Selected: {totalCapsules} capsules ({packsSelected} packs of 10). Target: {selectedPlanCapsules} capsules ({requiredPacks} packs).
|
||||
{packsSelected !== requiredPacks && (
|
||||
<span className="ml-2 inline-flex items-center rounded-md bg-rose-50 text-rose-700 px-2 py-1 border border-rose-200">
|
||||
{packsSelected < requiredPacks ? `${requiredPacks - packsSelected} packs missing.` : `${packsSelected - requiredPacks} packs too many.`}
|
||||
<div className="space-y-2 text-xs text-slate-700">
|
||||
<div>
|
||||
<span className="font-semibold">{totalPacks.toLocaleString('en-US')}</span> packs selected.
|
||||
<div className="text-slate-500">{totalCapsules.toLocaleString('en-US')} capsules total · minimum {MIN_ABO_PACKS} packs · maximum {MAX_ABO_PACKS.toLocaleString('en-US')} packs</div>
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
@ -101,7 +112,9 @@ export default function SelectionSummaryCard({
|
||||
|
||||
{!canProceed && (
|
||||
<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>
|
||||
)}
|
||||
</section>
|
||||
|
||||
32
src/app/coffee-abonnements/components/SubscribeGuard.tsx
Normal file
32
src/app/coffee-abonnements/components/SubscribeGuard.tsx
Normal 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}</>;
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
107
src/app/coffee-abonnements/hooks/useSubscribeGuard.ts
Normal file
107
src/app/coffee-abonnements/hooks/useSubscribeGuard.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
52
src/app/coffee-abonnements/lib/orderRules.ts
Normal file
52
src/app/coffee-abonnements/lib/orderRules.ts
Normal 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;
|
||||
}, {});
|
||||
}
|
||||
@ -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 (
|
||||
<SubscribeGuard>
|
||||
<CoffeeAbonnementPageContent />
|
||||
</SubscribeGuard>
|
||||
);
|
||||
}
|
||||
|
||||
function CoffeeAbonnementPageContent() {
|
||||
const { t } = useTranslation();
|
||||
const [selections, setSelections] = useState<Record<string, number>>({});
|
||||
const [bump, setBump] = useState<Record<string, boolean>>({});
|
||||
const [selectedPlanCapsules, setSelectedPlanCapsules] = useState<number>(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() {
|
||||
|
||||
<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
|
||||
coffees={coffees}
|
||||
loading={loading}
|
||||
error={error}
|
||||
selections={selections}
|
||||
bump={bump}
|
||||
selectedPlanCapsules={selectedPlanCapsules}
|
||||
totalCapsules={totalCapsules}
|
||||
onToggleCoffee={toggleCoffee}
|
||||
onChangeQuantity={changeQuantity}
|
||||
totalPacks={totalPacks}
|
||||
onAdjustQuantity={adjustQuantity}
|
||||
onSetQuantity={setQuantity}
|
||||
title={t('autofix.k0b03e660')}
|
||||
/>
|
||||
|
||||
@ -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')}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 (
|
||||
<SubscribeGuard>
|
||||
<SummaryPageContent />
|
||||
</SubscribeGuard>
|
||||
)
|
||||
}
|
||||
|
||||
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<string | null>(null)
|
||||
const [selections, setSelections] = useState<Record<string, number>>({});
|
||||
const [selectedPlanCapsules, setSelectedPlanCapsules] = useState<number>(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<string>('')
|
||||
const [guestInviteLink, setGuestInviteLink] = useState<string>('')
|
||||
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 && <span className={requiredMarkCls}>*</span>}
|
||||
</>
|
||||
)
|
||||
|
||||
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%)' }}
|
||||
>
|
||||
<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 */}
|
||||
<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>
|
||||
<label className={labelCls}>{t('autofix.kfe9527d8')}</label>
|
||||
<input name="firstName" value={form.firstName} onChange={handleInput} className={inputCls} />
|
||||
<label className={labelCls}>{renderLabel(t('autofix.kfe9527d8'), true)}</label>
|
||||
<input name="firstName" value={form.firstName} onChange={handleInput} className={getFieldClassName(firstNameError)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>{t('autofix.k6a2c64e8')}</label>
|
||||
<input name="lastName" value={form.lastName} onChange={handleInput} className={inputCls} />
|
||||
<label className={labelCls}>{renderLabel(t('autofix.k6a2c64e8'), true)}</label>
|
||||
<input name="lastName" value={form.lastName} onChange={handleInput} className={getFieldClassName(lastNameError)} />
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label className={labelCls}>Email</label>
|
||||
<input type="email" name="email" value={form.email} onChange={handleInput} className={inputCls} />
|
||||
<label className={labelCls}>{renderLabel('Email', true)}</label>
|
||||
<input type="email" name="email" value={form.email} onChange={handleInput} className={getFieldClassName(emailError)} />
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label className={labelCls}>{t('autofix.kd1a2772d')}</label>
|
||||
<input name="street" value={form.street} onChange={handleInput} className={inputCls} />
|
||||
<label className={labelCls}>{renderLabel(t('autofix.kd1a2772d'), true)}</label>
|
||||
<input name="street" value={form.street} onChange={handleInput} className={getFieldClassName(streetError)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>ZIP</label>
|
||||
<input name="postalCode" value={form.postalCode} onChange={handleInput} className={inputCls} />
|
||||
<label className={labelCls}>{renderLabel('ZIP', true)}</label>
|
||||
<input name="postalCode" value={form.postalCode} onChange={handleInput} className={getFieldClassName(postalCodeError)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>City</label>
|
||||
<input name="city" value={form.city} onChange={handleInput} className={inputCls} />
|
||||
<label className={labelCls}>{renderLabel('City', true)}</label>
|
||||
<input name="city" value={form.city} onChange={handleInput} className={getFieldClassName(cityError)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Country</label>
|
||||
<select name="country" value={form.country} onChange={handleInput} className={inputCls}>
|
||||
<label className={labelCls}>{renderLabel('Country', true)}</label>
|
||||
<select name="country" value={form.country} onChange={handleInput} className={getFieldClassName(countryError)}>
|
||||
{countryOptions.map(code => <option key={code} value={code}>{code}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
@ -635,8 +690,8 @@ export default function SummaryPage() {
|
||||
<input name="invoicePhone" value={form.invoicePhone} onChange={handleInput} className={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Email</label>
|
||||
<input type="email" name="invoiceEmail" value={form.invoiceEmail} onChange={handleInput} className={inputCls} />
|
||||
<label className={labelCls}>{renderLabel('Email', true)}</label>
|
||||
<input type="email" name="invoiceEmail" value={form.invoiceEmail} onChange={handleInput} className={getFieldClassName(invoiceEmailError)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -666,27 +721,27 @@ export default function SummaryPage() {
|
||||
|
||||
<div className="space-y-4">
|
||||
<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}
|
||||
className={`${inputCls} max-w-xs ${!hasSigningCity && submitError ? 'border-red-400 focus:ring-red-400' : ''}`}
|
||||
className={getFieldClassName(signingCityError, 'max-w-xs')}
|
||||
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>
|
||||
<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>
|
||||
|
||||
<button
|
||||
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 ${
|
||||
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 ? t('autofix.k27b5b842') : t('autofix.k737db983')}
|
||||
{!submitLoading && <svg className="w-4 h-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fillRule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l5.999 6a1 1 0 010 1.414l-6 6a1 1 0 11-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clipRule="evenodd" /></svg>}
|
||||
</button>
|
||||
{!canSubmit && <p className="text-xs text-slate-500 text-center">{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>
|
||||
</section>
|
||||
|
||||
@ -700,7 +755,7 @@ export default function SummaryPage() {
|
||||
{selectedEntries.map(entry => (
|
||||
<div key={entry.coffee.id} className="flex gap-3 items-start">
|
||||
{/* 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 ? (
|
||||
<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 */}
|
||||
<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).
|
||||
{totalPacks !== requiredPacks && (
|
||||
<span className="font-semibold">{totalPacks.toLocaleString('en-US')}</span> packs selected.
|
||||
<div className="text-slate-500">{totalCapsules.toLocaleString('en-US')} capsules total · minimum {MIN_ABO_PACKS} packs · maximum {MAX_ABO_PACKS.toLocaleString('en-US')} packs.</div>
|
||||
{orderPackError && (
|
||||
<div className="mt-1 rounded-lg bg-red-50 border border-red-200 text-red-700 px-2 py-1 font-medium">
|
||||
Exactly {requiredPacks} packs required.
|
||||
{remainingMinPacks > 0
|
||||
? `${remainingMinPacks} more pack${remainingMinPacks === 1 ? '' : 's'} needed to reach the minimum order.`
|
||||
: orderPackError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -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() {
|
||||
<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">
|
||||
<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>
|
||||
|
||||
{coffeesLoading ? (
|
||||
|
||||
Loading…
Reference in New Issue
Block a user