Merge branch 'dev' of https://git.profit-planet.partners/Seazn/profit-planet-frontend into dev sdafas
This commit is contained in:
commit
b12417874b
@ -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 */}
|
||||||
|
|||||||
@ -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 */}
|
||||||
|
|||||||
@ -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));
|
||||||
|
|||||||
@ -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 "{deleteTarget.title}". 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
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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">
|
||||||
|
{qty.toLocaleString('en-US')} packs
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => onChangeQuantity(coffee.id, -10)}
|
type="button"
|
||||||
disabled={qty <= 10}
|
onClick={() => onAdjustQuantity(coffee.id, -1)}
|
||||||
className="h-8 w-14 rounded-full bg-slate-100 hover:bg-slate-200 text-xs font-medium transition active:scale-95"
|
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"
|
||||||
>
|
>
|
||||||
-10
|
-
|
||||||
</button>
|
</button>
|
||||||
<div className="flex-1 relative">
|
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="number"
|
||||||
min={10}
|
min={0}
|
||||||
max={sliderMax}
|
max={maxForCoffee}
|
||||||
step={10}
|
step={1}
|
||||||
|
inputMode="numeric"
|
||||||
value={qty}
|
value={qty}
|
||||||
onChange={(e) => onChangeQuantity(coffee.id, parseInt(e.target.value, 10) - qty)}
|
onChange={(e) => onSetQuantity(coffee.id, Number(e.target.value))}
|
||||||
className="w-full appearance-none cursor-pointer bg-transparent"
|
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"
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
'linear-gradient(to right,#0f172a 0%,#0f172a ' +
|
|
||||||
sliderProgress +
|
|
||||||
'%,#e2e8f0 ' +
|
|
||||||
sliderProgress +
|
|
||||||
'%,#e2e8f0 100%)',
|
|
||||||
height: '6px',
|
|
||||||
borderRadius: '999px',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => onChangeQuantity(coffee.id, +10)}
|
type="button"
|
||||||
disabled={qty + 10 > maxForCoffee}
|
onClick={() => onAdjustQuantity(coffee.id, +1)}
|
||||||
className="h-8 w-14 rounded-full bg-slate-100 hover:bg-slate-200 text-xs font-medium transition active:scale-95"
|
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"
|
||||||
>
|
>
|
||||||
+10
|
+
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between text-[11px] text-slate-500">
|
|
||||||
<span>Subtotal</span>
|
<div className="mt-3 flex items-center justify-between text-[11px] text-slate-500">
|
||||||
<span className="font-semibold text-slate-700">EUR {((qty / 10) * coffee.pricePer10).toFixed(2)}</span>
|
<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>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
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(() => {
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
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 { 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')}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 ? (
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user