355 lines
16 KiB
TypeScript
355 lines
16 KiB
TypeScript
'use client';
|
||
import React, { useState, useMemo } from 'react';
|
||
import PageLayout from '../components/PageLayout';
|
||
import { useRouter } from 'next/navigation';
|
||
import { useActiveCoffees } from './hooks/getActiveCoffees';
|
||
|
||
export default function CoffeeAbonnementPage() {
|
||
const [selections, setSelections] = useState<Record<string, number>>({});
|
||
const [bump, setBump] = useState<Record<string, boolean>>({});
|
||
const [selectedPlanCapsules, setSelectedPlanCapsules] = useState<60 | 120>(120);
|
||
const router = useRouter();
|
||
|
||
// Fetch active coffees from the backend
|
||
const { coffees, loading, error } = useActiveCoffees();
|
||
|
||
const selectedEntries = useMemo(
|
||
() =>
|
||
Object.entries(selections).map(([id, qty]) => {
|
||
const coffee = coffees.find((c) => c.id === id);
|
||
if (!coffee) return null;
|
||
return { coffee, quantity: qty };
|
||
}).filter(Boolean) as { coffee: ReturnType<typeof useActiveCoffees>['coffees'][number]; quantity: number }[],
|
||
[selections, coffees]
|
||
);
|
||
|
||
const totalPrice = useMemo(
|
||
() =>
|
||
selectedEntries.reduce(
|
||
(sum, entry) => sum + (entry.quantity / 10) * entry.coffee.pricePer10,
|
||
0
|
||
),
|
||
[selectedEntries]
|
||
);
|
||
|
||
// 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 proceedToSummary = () => {
|
||
if (!canProceed) return;
|
||
try {
|
||
sessionStorage.setItem('coffeeSelections', JSON.stringify(selections));
|
||
sessionStorage.setItem('coffeeAboSizeCapsules', String(selectedPlanCapsules));
|
||
} catch {}
|
||
router.push('/coffee-abonnements/summary');
|
||
};
|
||
|
||
const toggleCoffee = (id: string) => {
|
||
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;
|
||
}
|
||
return copy;
|
||
});
|
||
};
|
||
|
||
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 = Math.min(120, 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 (
|
||
<PageLayout>
|
||
<div className="mx-auto max-w-7xl px-4 py-10 space-y-10 bg-gradient-to-b from-white to-[#1C2B4A0D]">
|
||
<h1 className="text-3xl font-bold tracking-tight">
|
||
<span className="text-[#1C2B4A]">Configure Coffee Subscription</span>
|
||
</h1>
|
||
|
||
{/* Stepper */}
|
||
<div className="flex items-center gap-3 text-sm text-gray-600">
|
||
<div className="flex items-center">
|
||
<span className="h-8 w-8 rounded-full bg-[#1C2B4A] text-white flex items-center justify-center font-semibold">1</span>
|
||
<span className="ml-2 font-medium">Selection</span>
|
||
</div>
|
||
<div className="h-px flex-1 bg-gray-200" />
|
||
<div className="flex items-center opacity-60">
|
||
<span className="h-8 w-8 rounded-full bg-gray-200 text-gray-600 flex items-center justify-center font-semibold">2</span>
|
||
<span className="ml-2 font-medium">Summary</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Section 1: Multi coffee selection + per-coffee quantity */}
|
||
<section>
|
||
<h2 className="text-xl font-semibold mb-4">1. Select subscription size</h2>
|
||
<div className="mb-6 rounded-xl border border-[#1C2B4A]/20 p-4 bg-white/80 backdrop-blur-sm shadow-lg">
|
||
<div className="grid gap-3 sm:grid-cols-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setSelectedPlanCapsules(60)}
|
||
className={`rounded-lg border px-4 py-3 text-left transition ${
|
||
selectedPlanCapsules === 60
|
||
? 'border-[#1C2B4A] bg-[#1C2B4A]/5 text-[#1C2B4A]'
|
||
: 'border-gray-300 hover:bg-gray-50'
|
||
}`}
|
||
>
|
||
<div className="font-semibold">60 piece abo</div>
|
||
<div className="text-xs text-gray-600">6 packs of 10 capsules</div>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setSelectedPlanCapsules(120)}
|
||
className={`rounded-lg border px-4 py-3 text-left transition ${
|
||
selectedPlanCapsules === 120
|
||
? 'border-[#1C2B4A] bg-[#1C2B4A]/5 text-[#1C2B4A]'
|
||
: 'border-gray-300 hover:bg-gray-50'
|
||
}`}
|
||
>
|
||
<div className="font-semibold">120 piece abo</div>
|
||
<div className="text-xs text-gray-600">12 packs of 10 capsules</div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<h2 className="text-xl font-semibold mb-4">2. Choose coffees & quantities</h2>
|
||
|
||
{error && (
|
||
<div className="mb-4 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{loading ? (
|
||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||
<div className="h-44 rounded-xl bg-gray-100 animate-pulse" />
|
||
<div className="h-44 rounded-xl bg-gray-100 animate-pulse" />
|
||
<div className="h-44 rounded-xl bg-gray-100 animate-pulse" />
|
||
</div>
|
||
) : (
|
||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||
{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;
|
||
return (
|
||
<div
|
||
key={coffee.id}
|
||
className={`group rounded-xl border p-4 shadow-sm transition ${
|
||
active ? 'border-[#1C2B4A] bg-[#1C2B4A]/5 shadow-md' : 'border-gray-200 bg-white'
|
||
}`}
|
||
>
|
||
<div className="relative overflow-hidden rounded-md mb-3">
|
||
{coffee.image ? (
|
||
<img
|
||
src={coffee.image}
|
||
alt={coffee.name}
|
||
className="h-36 w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||
loading="lazy"
|
||
/>
|
||
) : (
|
||
<div className="h-36 w-full bg-gray-100 rounded-md" />
|
||
)}
|
||
{/* price badge (per 10) */}
|
||
<div className="absolute top-2 right-2 flex flex-col items-end gap-1">
|
||
<span
|
||
aria-label={`Price €${coffee.pricePer10} per 10 capsules`}
|
||
className={`relative 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 transition-transform group-hover:scale-105 ${
|
||
active ? 'bg-[#1C2B4A]' : 'bg-[#1C2B4A]/80'
|
||
}`}
|
||
>
|
||
€{coffee.pricePer10}
|
||
</span>
|
||
<span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-[#1C2B4A]/90 text-white border border-white/20 shadow-sm backdrop-blur-sm">
|
||
per 10 pcs
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-start justify-between">
|
||
<h3 className="font-semibold text-sm">{coffee.name}</h3>
|
||
</div>
|
||
<p className="mt-2 text-xs text-gray-600 leading-relaxed">
|
||
{coffee.description}
|
||
</p>
|
||
<button
|
||
type="button"
|
||
onClick={() => toggleCoffee(coffee.id)}
|
||
disabled={!canAddCoffee}
|
||
className={`mt-3 w-full text-xs font-medium rounded px-3 py-2 border transition ${
|
||
active
|
||
? 'border-[#1C2B4A] text-[#1C2B4A] bg-white hover:bg-[#1C2B4A]/10'
|
||
: canAddCoffee
|
||
? 'border-gray-300 hover:bg-gray-100'
|
||
: 'border-gray-200 bg-gray-100 text-gray-400 cursor-not-allowed'
|
||
}`}
|
||
>
|
||
{active ? 'Remove' : 'Add'}
|
||
</button>
|
||
{active && (
|
||
<div className="mt-4 flex flex-col gap-3">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-[11px] font-medium text-gray-500">Quantity (10–120)</span>
|
||
<span
|
||
className={`inline-flex items-center justify-center rounded-full bg-[#1C2B4A] 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>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={() => changeQuantity(coffee.id, -10)}
|
||
disabled={qty <= 10}
|
||
className="h-8 w-14 rounded-full bg-gray-100 hover:bg-gray-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) =>
|
||
changeQuantity(coffee.id, parseInt(e.target.value, 10) - qty)
|
||
}
|
||
className="w-full appearance-none cursor-pointer bg-transparent"
|
||
style={{
|
||
background:
|
||
'linear-gradient(to right,#1C2B4A 0%,#1C2B4A ' +
|
||
sliderProgress +
|
||
'%,#e5e7eb ' +
|
||
sliderProgress +
|
||
'%,#e5e7eb 100%)',
|
||
height: '6px',
|
||
borderRadius: '999px',
|
||
}}
|
||
/>
|
||
</div>
|
||
<button
|
||
onClick={() => changeQuantity(coffee.id, +10)}
|
||
disabled={qty + 10 > maxForCoffee}
|
||
className="h-8 w-14 rounded-full bg-gray-100 hover:bg-gray-200 text-xs font-medium transition active:scale-95"
|
||
>
|
||
+10
|
||
</button>
|
||
</div>
|
||
<div className="flex items-center justify-between text-[11px] text-gray-500">
|
||
<span>Subtotal</span>
|
||
<span className="font-semibold text-gray-700">
|
||
€{((qty / 10) * coffee.pricePer10).toFixed(2)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
{/* Section 2: Compact preview + next steps */}
|
||
<section>
|
||
<h2 className="text-xl font-semibold mb-4">3. Preview</h2>
|
||
<div className="rounded-xl border border-[#1C2B4A]/20 p-6 bg-white/80 backdrop-blur-sm space-y-4 shadow-lg">
|
||
{selectedEntries.length === 0 && (
|
||
<p className="text-sm text-gray-600">No coffees selected yet.</p>
|
||
)}
|
||
{selectedEntries.map((entry) => (
|
||
<div key={entry.coffee.id} className="flex justify-between text-sm border-b last:border-b-0 pb-2 last:pb-0">
|
||
<div className="flex flex-col">
|
||
<span className="font-medium">{entry.coffee.name}</span>
|
||
<span className="text-xs text-gray-500">
|
||
{entry.quantity} Stk •{' '}
|
||
<span className="inline-flex items-center font-semibold text-[#1C2B4A]">
|
||
€{entry.coffee.pricePer10}/10
|
||
</span>
|
||
</span>
|
||
</div>
|
||
<div className="text-right font-semibold">
|
||
€{((entry.quantity / 10) * entry.coffee.pricePer10).toFixed(2)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
<div className="flex justify-between pt-2 border-t">
|
||
<span className="text-sm font-semibold">Total (net)</span>
|
||
<span className="text-lg font-extrabold tracking-tight text-[#1C2B4A]">
|
||
€{totalPrice.toFixed(2)}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Packs/capsules summary and validation hint (refined design) */}
|
||
<div className="text-xs text-gray-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-red-50 text-red-700 px-2 py-1 border border-red-200">
|
||
Please select exactly {selectedPlanCapsules} capsules ({requiredPacks} packs).
|
||
{packsSelected < requiredPacks ? ` ${requiredPacks - packsSelected} packs missing.` : ` ${packsSelected - requiredPacks} packs too many.`}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<button
|
||
onClick={proceedToSummary}
|
||
disabled={!canProceed}
|
||
className={`group w-full mt-2 rounded-lg px-4 py-3 font-semibold transition inline-flex items-center justify-center ${
|
||
canProceed
|
||
? 'bg-[#1C2B4A] text-white hover:bg-[#1C2B4A]/90 shadow-md hover:shadow-lg'
|
||
: 'bg-gray-200 text-gray-600 cursor-not-allowed'
|
||
}`}
|
||
>
|
||
Next steps
|
||
<svg
|
||
className={`ml-2 h-5 w-5 transition-transform ${
|
||
canProceed ? 'group-hover:translate-x-0.5' : ''
|
||
}`}
|
||
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>
|
||
{!canProceed && (
|
||
<p className="text-xs text-gray-600">
|
||
You can continue once exactly {selectedPlanCapsules} capsules ({requiredPacks} packs) are selected.
|
||
</p>
|
||
)}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</PageLayout>
|
||
);
|
||
}
|