feat: abo sub + dependency

This commit is contained in:
DeathKaioken 2026-02-18 10:24:31 +01:00
parent 49aee7b7ff
commit 004a8f4baa
7 changed files with 2433 additions and 1832 deletions

4013
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -14,55 +14,55 @@
"@heroicons/react": "^2.2.0", "@heroicons/react": "^2.2.0",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@lottiefiles/react-lottie-player": "^3.6.0", "@lottiefiles/react-lottie-player": "^3.6.0",
"@react-pdf/renderer": "^4.3.0", "@react-pdf/renderer": "^4.3.2",
"@react-three/drei": "^10.7.7", "@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0", "@react-three/fiber": "^9.5.0",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tailwindplus/elements": "^1.0.15", "@tailwindplus/elements": "^1.0.22",
"@tailwindui/react": "^0.1.1", "@tailwindui/react": "^0.1.1",
"axios": "^1.12.2", "axios": "^1.13.5",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"country-flag-icons": "^1.5.21", "country-flag-icons": "^1.6.13",
"country-select-js": "^2.1.0", "country-select-js": "^2.1.0",
"gsap": "^3.14.2", "gsap": "^3.14.2",
"intl-tel-input": "^25.15.0", "intl-tel-input": "^26.4.1",
"lucide-react": "^0.562.0", "lucide-react": "^0.574.0",
"motion": "^12.23.22", "motion": "^12.34.1",
"next": "^16.0.7", "next": "^16.1.6",
"pdfjs-dist": "^5.4.149", "pdfjs-dist": "^5.4.624",
"react": "^19.2.1", "react": "^19.2.4",
"react-dom": "^19.2.1", "react-dom": "^19.2.4",
"react-easy-crop": "^5.5.6", "react-easy-crop": "^5.5.6",
"react-hook-form": "^7.63.0", "react-hook-form": "^7.71.1",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-pdf": "^10.1.0", "react-pdf": "^10.3.0",
"react-phone-number-input": "^3.4.12", "react-phone-number-input": "^3.4.14",
"react-toastify": "^11.0.5", "react-toastify": "^11.0.5",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"three": "^0.167.1", "three": "^0.182.0",
"winston": "^3.17.0", "winston": "^3.19.0",
"yup": "^1.7.1", "yup": "^1.7.1",
"zustand": "^5.0.8" "zustand": "^5.0.11"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
"@eslint/js": "^9.36.0", "@eslint/js": "^10.0.1",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^25",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.24",
"baseline-browser-mapping": "^2.9.14", "baseline-browser-mapping": "^2.9.19",
"eslint": "^9", "eslint": "^10.0.0",
"eslint-config-next": "15.5.4", "eslint-config-next": "^16.1.6",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^7.0.1",
"globals": "^16.4.0", "globals": "^17.3.0",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"postcss-preset-env": "^10.4.0", "postcss-preset-env": "^11.1.3",
"tailwindcss": "^4.1.13", "tailwindcss": "^4.1.18",
"typescript": "^5" "typescript": "^5"
} }
} }

View File

@ -17,7 +17,7 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
const [statusMsg, setStatusMsg] = useState<string | null>(null); const [statusMsg, setStatusMsg] = useState<string | null>(null);
const [lang, setLang] = useState<'en' | 'de'>('en'); const [lang, setLang] = useState<'en' | 'de'>('en');
const [type, setType] = useState<'contract' | 'bill' | 'other'>('contract'); const [type, setType] = useState<'contract' | 'bill' | 'invoice' | 'other'>('contract');
const [contractType, setContractType] = useState<'contract' | 'gdpr'>('contract'); const [contractType, setContractType] = useState<'contract' | 'gdpr'>('contract');
const [userType, setUserType] = useState<'personal' | 'company' | 'both'>('personal'); const [userType, setUserType] = useState<'personal' | 'company' | 'both'>('personal');
const [description, setDescription] = useState<string>(''); const [description, setDescription] = useState<string>('');
@ -51,7 +51,7 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
setHtmlCode(tpl.html || ''); setHtmlCode(tpl.html || '');
setDescription(((tpl as any)?.description as string) || ''); // FIX: DocumentTemplate may not declare `description` setDescription(((tpl as any)?.description as string) || ''); // FIX: DocumentTemplate may not declare `description`
setLang((tpl.lang as any) || 'en'); setLang((tpl.lang as any) || 'en');
setType((tpl.type as any) || 'contract'); setType(((tpl.type as any) || 'contract') as 'contract' | 'bill' | 'invoice' | 'other');
setContractType(((tpl.contract_type as any) || 'contract') as 'contract' | 'gdpr'); setContractType(((tpl.contract_type as any) || 'contract') as 'contract' | 'gdpr');
setUserType(((tpl.user_type as any) || 'both') as 'personal' | 'company' | 'both'); setUserType(((tpl.user_type as any) || 'both') as 'personal' | 'company' | 'both');
setEditingMeta({ setEditingMeta({
@ -273,12 +273,13 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
<div className="flex flex-col sm:flex-row gap-4"> <div className="flex flex-col sm:flex-row gap-4">
<select <select
value={type} value={type}
onChange={(e) => setType(e.target.value as 'contract' | 'bill' | 'other')} onChange={(e) => setType(e.target.value as 'contract' | 'bill' | 'invoice' | 'other')}
required required
className="w-full sm:w-1/3 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-indigo-500/50 shadow" className="w-full sm:w-1/3 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-indigo-500/50 shadow"
> >
<option value="contract">Contract</option> <option value="contract">Contract</option>
<option value="bill">Bill</option> <option value="bill">Bill</option>
<option value="invoice">Invoice</option>
<option value="other">Other</option> <option value="other">Other</option>
</select> </select>
{type === 'contract' && ( {type === 'contract' && (
@ -322,6 +323,14 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
</div> </div>
{!isPreview && ( {!isPreview && (
<div className="space-y-3">
{type === 'invoice' && (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-900">
<p className="font-semibold">Invoice template variables</p>
<p className="mt-1">Use these placeholders in your HTML: invoiceNumber, customerName, issuedAt, totalNet, totalTax, totalGross, itemsHtml.</p>
<p className="mt-1">Important: include <span className="font-semibold">itemsHtml</span> to render invoice line items.</p>
</div>
)}
<textarea <textarea
value={htmlCode} value={htmlCode}
onChange={(e) => setHtmlCode(e.target.value)} onChange={(e) => setHtmlCode(e.target.value)}
@ -329,6 +338,7 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
required required
className="min-h-[320px] w-full rounded-lg border border-gray-300 bg-white px-4 py-3 text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 font-mono shadow" className="min-h-[320px] w-full rounded-lg border border-gray-300 bg-white px-4 py-3 text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 font-mono shadow"
/> />
</div>
)} )}
{isPreview && ( {isPreview && (

View File

@ -124,6 +124,9 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-900">
For invoice emails, provide active invoice templates for the language/user type combinations you need (en/de × personal/company/both). If no active invoice template matches, backend falls back to text-only email.
</div>
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<input <input
placeholder="Search templates…" placeholder="Search templates…"
@ -148,7 +151,7 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
<StatusBadge status={c.status} /> <StatusBadge status={c.status} />
{c.type && ( {c.type && (
<Pill className="bg-slate-50 text-slate-800 border-slate-200"> <Pill className="bg-slate-50 text-slate-800 border-slate-200">
{c.type === 'contract' ? 'Contract' : c.type === 'bill' ? 'Bill' : 'Other'} {c.type === 'contract' ? 'Contract' : c.type === 'bill' ? 'Bill' : c.type === 'invoice' ? 'Invoice' : 'Other'}
</Pill> </Pill>
)} )}
{c.type === 'contract' && ( {c.type === 'contract' && (

View File

@ -7,6 +7,7 @@ import { useActiveCoffees } from './hooks/getActiveCoffees';
export default function CoffeeAbonnementPage() { export default function CoffeeAbonnementPage() {
const [selections, setSelections] = useState<Record<string, number>>({}); const [selections, setSelections] = useState<Record<string, number>>({});
const [bump, setBump] = useState<Record<string, boolean>>({}); const [bump, setBump] = useState<Record<string, boolean>>({});
const [selectedPlanCapsules, setSelectedPlanCapsules] = useState<60 | 120>(120);
const router = useRouter(); const router = useRouter();
// Fetch active coffees from the backend // Fetch active coffees from the backend
@ -31,22 +32,20 @@ export default function CoffeeAbonnementPage() {
[selectedEntries] [selectedEntries]
); );
// NEW: enforce exactly 120 capsules (12 packs) // NEW: enforce selected plan size (60 or 120 capsules)
const totalCapsules = useMemo( const totalCapsules = useMemo(
() => selectedEntries.reduce((sum, entry) => sum + entry.quantity, 0), () => selectedEntries.reduce((sum, entry) => sum + entry.quantity, 0),
[selectedEntries] [selectedEntries]
); );
const packsSelected = totalCapsules / 10; const packsSelected = totalCapsules / 10;
const canProceed = packsSelected === 12; // CHANGED: require exactly 12 packs const requiredPacks = selectedPlanCapsules / 10;
const canProceed = packsSelected === requiredPacks;
const TAX_RATE = 0.07;
const taxAmount = useMemo(() => totalPrice * TAX_RATE, [totalPrice]);
const totalWithTax = useMemo(() => totalPrice + taxAmount, [totalPrice, taxAmount]);
const proceedToSummary = () => { const proceedToSummary = () => {
if (!canProceed) return; if (!canProceed) return;
try { try {
sessionStorage.setItem('coffeeSelections', JSON.stringify(selections)); sessionStorage.setItem('coffeeSelections', JSON.stringify(selections));
sessionStorage.setItem('coffeeAboSizeCapsules', String(selectedPlanCapsules));
} catch {} } catch {}
router.push('/coffee-abonnements/summary'); router.push('/coffee-abonnements/summary');
}; };
@ -57,6 +56,8 @@ export default function CoffeeAbonnementPage() {
if (id in copy) { if (id in copy) {
delete copy[id]; delete copy[id];
} else { } else {
const total = Object.values(copy).reduce((sum, qty) => sum + qty, 0);
if (total + 10 > selectedPlanCapsules) return prev;
copy[id] = 10; copy[id] = 10;
} }
return copy; return copy;
@ -66,8 +67,10 @@ export default function CoffeeAbonnementPage() {
const changeQuantity = (id: string, delta: number) => { const changeQuantity = (id: string, delta: number) => {
setSelections((prev) => { setSelections((prev) => {
if (!(id in prev)) return 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; const next = prev[id] + delta;
if (next < 10 || next > 120) return prev; if (next < 10 || next > maxForCoffee) return prev;
const updated = { ...prev, [id]: next }; const updated = { ...prev, [id]: next };
setBump((b) => ({ ...b, [id]: true })); setBump((b) => ({ ...b, [id]: true }));
setTimeout(() => setBump((b) => ({ ...b, [id]: false })), 250); setTimeout(() => setBump((b) => ({ ...b, [id]: false })), 250);
@ -97,7 +100,37 @@ export default function CoffeeAbonnementPage() {
{/* Section 1: Multi coffee selection + per-coffee quantity */} {/* Section 1: Multi coffee selection + per-coffee quantity */}
<section> <section>
<h2 className="text-xl font-semibold mb-4">1. Choose coffees & quantities</h2> <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 && ( {error && (
<div className="mb-4 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"> <div className="mb-4 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
@ -116,6 +149,15 @@ export default function CoffeeAbonnementPage() {
{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 = 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 ( return (
<div <div
key={coffee.id} key={coffee.id}
@ -158,10 +200,13 @@ export default function CoffeeAbonnementPage() {
<button <button
type="button" type="button"
onClick={() => toggleCoffee(coffee.id)} onClick={() => toggleCoffee(coffee.id)}
disabled={!canAddCoffee}
className={`mt-3 w-full text-xs font-medium rounded px-3 py-2 border transition ${ className={`mt-3 w-full text-xs font-medium rounded px-3 py-2 border transition ${
active active
? 'border-[#1C2B4A] text-[#1C2B4A] bg-white hover:bg-[#1C2B4A]/10' ? 'border-[#1C2B4A] text-[#1C2B4A] bg-white hover:bg-[#1C2B4A]/10'
: 'border-gray-300 hover:bg-gray-100' : canAddCoffee
? 'border-gray-300 hover:bg-gray-100'
: 'border-gray-200 bg-gray-100 text-gray-400 cursor-not-allowed'
}`} }`}
> >
{active ? 'Remove' : 'Add'} {active ? 'Remove' : 'Add'}
@ -179,6 +224,7 @@ export default function CoffeeAbonnementPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={() => changeQuantity(coffee.id, -10)} 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" className="h-8 w-14 rounded-full bg-gray-100 hover:bg-gray-200 text-xs font-medium transition active:scale-95"
> >
-10 -10
@ -187,7 +233,7 @@ export default function CoffeeAbonnementPage() {
<input <input
type="range" type="range"
min={10} min={10}
max={120} max={sliderMax}
step={10} step={10}
value={qty} value={qty}
onChange={(e) => onChange={(e) =>
@ -197,9 +243,9 @@ export default function CoffeeAbonnementPage() {
style={{ style={{
background: background:
'linear-gradient(to right,#1C2B4A 0%,#1C2B4A ' + 'linear-gradient(to right,#1C2B4A 0%,#1C2B4A ' +
((qty - 10) / (120 - 10)) * 100 + sliderProgress +
'%,#e5e7eb ' + '%,#e5e7eb ' +
((qty - 10) / (120 - 10)) * 100 + sliderProgress +
'%,#e5e7eb 100%)', '%,#e5e7eb 100%)',
height: '6px', height: '6px',
borderRadius: '999px', borderRadius: '999px',
@ -208,6 +254,7 @@ export default function CoffeeAbonnementPage() {
</div> </div>
<button <button
onClick={() => changeQuantity(coffee.id, +10)} 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" className="h-8 w-14 rounded-full bg-gray-100 hover:bg-gray-200 text-xs font-medium transition active:scale-95"
> >
+10 +10
@ -230,7 +277,7 @@ export default function CoffeeAbonnementPage() {
{/* Section 2: Compact preview + next steps */} {/* Section 2: Compact preview + next steps */}
<section> <section>
<h2 className="text-xl font-semibold mb-4">2. Preview</h2> <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"> <div className="rounded-xl border border-[#1C2B4A]/20 p-6 bg-white/80 backdrop-blur-sm space-y-4 shadow-lg">
{selectedEntries.length === 0 && ( {selectedEntries.length === 0 && (
<p className="text-sm text-gray-600">No coffees selected yet.</p> <p className="text-sm text-gray-600">No coffees selected yet.</p>
@ -260,11 +307,11 @@ export default function CoffeeAbonnementPage() {
{/* Packs/capsules summary and validation hint (refined design) */} {/* Packs/capsules summary and validation hint (refined design) */}
<div className="text-xs text-gray-700"> <div className="text-xs text-gray-700">
Selected: {totalCapsules} capsules ({packsSelected} packs of 10). Selected: {totalCapsules} capsules ({packsSelected} packs of 10). Target: {selectedPlanCapsules} capsules ({requiredPacks} packs).
{packsSelected !== 12 && ( {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"> <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 120 capsules (12 packs). Please select exactly {selectedPlanCapsules} capsules ({requiredPacks} packs).
{packsSelected < 12 ? ` ${12 - packsSelected} packs missing.` : ` ${packsSelected - 12} packs too many.`} {packsSelected < requiredPacks ? ` ${requiredPacks - packsSelected} packs missing.` : ` ${packsSelected - requiredPacks} packs too many.`}
</span> </span>
)} )}
</div> </div>
@ -296,7 +343,7 @@ export default function CoffeeAbonnementPage() {
</button> </button>
{!canProceed && ( {!canProceed && (
<p className="text-xs text-gray-600"> <p className="text-xs text-gray-600">
You can continue once exactly 120 capsules (12 packs) are selected. You can continue once exactly {selectedPlanCapsules} capsules ({requiredPacks} packs) are selected.
</p> </p>
)} )}
</div> </div>

View File

@ -76,11 +76,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 exactly 12 packs // 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 !== 12) { if (sumPacks !== 6 && sumPacks !== 12) {
console.warn('[subscribeAbo] Invalid pack total:', sumPacks, 'expected 12') console.warn('[subscribeAbo] Invalid pack total:', sumPacks, 'expected 6 or 12')
throw new Error('Order must contain exactly 12 packs (120 capsules).') throw new Error('Order must contain exactly 6 packs (60 capsules) or 12 packs (120 capsules).')
} }
} else { } else {
body.coffeeId = input.coffeeId body.coffeeId = input.coffeeId

View File

@ -10,7 +10,9 @@ import useAuthStore from '../../store/authStore'
export default function SummaryPage() { export default function SummaryPage() {
const router = useRouter(); const router = useRouter();
const { coffees, loading, error } = useActiveCoffees(); const { coffees, loading, error } = useActiveCoffees();
const user = useAuthStore(state => state.user)
const [selections, setSelections] = useState<Record<string, number>>({}); const [selections, setSelections] = useState<Record<string, number>>({});
const [selectedPlanCapsules, setSelectedPlanCapsules] = useState<60 | 120>(120);
const [form, setForm] = useState({ const [form, setForm] = useState({
firstName: '', firstName: '',
lastName: '', lastName: '',
@ -34,6 +36,10 @@ export default function SummaryPage() {
try { try {
const raw = sessionStorage.getItem('coffeeSelections'); const raw = sessionStorage.getItem('coffeeSelections');
if (raw) setSelections(JSON.parse(raw)); if (raw) setSelections(JSON.parse(raw));
const rawPlan = sessionStorage.getItem('coffeeAboSizeCapsules');
if (rawPlan === '60' || rawPlan === '120') {
setSelectedPlanCapsules(Number(rawPlan) as 60 | 120);
}
} catch {} } catch {}
}, []); }, []);
@ -64,16 +70,20 @@ export default function SummaryPage() {
[selectedEntries] [selectedEntries]
) )
const totalPacks = totalCapsules / 10 const totalPacks = totalCapsules / 10
const requiredPacks = selectedPlanCapsules / 10
const token = useAuthStore.getState().accessToken
console.info('[SummaryPage] token prefix:', token ? `${token.substring(0, 12)}` : null)
// NEW: capture logged-in user id for referral // NEW: capture logged-in user id for referral
const currentUserId = useAuthStore.getState().user?.id const rawUserId = user?.id
const currentUserId = typeof rawUserId === 'number'
? rawUserId
: (typeof rawUserId === 'string' && /^\d+$/.test(rawUserId) ? Number(rawUserId) : undefined)
console.info('[SummaryPage] currentUserId:', currentUserId) console.info('[SummaryPage] currentUserId:', currentUserId)
// Countries list from backend VAT rates (fallback to current country if list empty) // Countries list from backend VAT rates (fallback to current country if list empty)
const countryOptions = useMemo(() => { const countryOptions = useMemo(() => {
const opts = vatRates.length > 0 ? vatRates.map(r => r.code) : [(form.country || 'DE').toUpperCase()] const currentCode = (form.country || 'DE').toUpperCase();
const opts = vatRates.length > 0 ? vatRates.map(r => r.code) : [currentCode]
if (!opts.includes(currentCode)) opts.unshift(currentCode)
console.info('[SummaryPage] countryOptions:', opts) console.info('[SummaryPage] countryOptions:', opts)
return opts return opts
}, [vatRates, form.country]); }, [vatRates, form.country]);
@ -132,18 +142,44 @@ export default function SummaryPage() {
setForm(prev => ({ ...prev, [name]: value })); setForm(prev => ({ ...prev, [name]: value }));
}; };
const fillFromLoggedInData = () => {
if (!user) {
setSubmitError('No logged-in user data found to fill the fields.');
return;
}
const pick = (...values: any[]) => {
for (const value of values) {
if (typeof value === 'string' && value.trim() !== '') return value.trim();
}
return '';
};
setSubmitError(null);
setForm(prev => ({
...prev,
firstName: pick(user.firstName, user.firstname, user.givenName, user.first_name) || prev.firstName,
lastName: pick(user.lastName, user.lastname, user.familyName, user.last_name) || prev.lastName,
email: pick(user.email, user.mail) || prev.email,
street: pick(user.street, user.addressStreet, user.address?.street, user.address_line_1) || prev.street,
postalCode: pick(user.postalCode, user.zipCode, user.zip, user.addressPostalCode, user.address?.postalCode) || prev.postalCode,
city: pick(user.city, user.addressCity, user.town, user.address?.city) || prev.city,
country: (pick(user.country, user.countryCode, user.addressCountry, user.address?.country) || prev.country).toUpperCase(),
}));
};
const canSubmit = const canSubmit =
selectedEntries.length > 0 && selectedEntries.length > 0 &&
totalPacks === 12 && // CHANGED: require exactly 12 packs totalPacks === requiredPacks &&
Object.values(form).every(v => (typeof v === 'string' ? v.trim() !== '' : true)); Object.values(form).every(v => (typeof v === 'string' ? v.trim() !== '' : true));
const backToSelection = () => router.push('/coffee-abonnements'); const backToSelection = () => router.push('/coffee-abonnements');
const submit = async () => { const submit = async () => {
if (!canSubmit || submitLoading) return if (!canSubmit || submitLoading) return
// NEW: guard (defensive) — backend requires exactly 12 packs // NEW: guard (defensive) — backend requires selected package size
if (totalPacks !== 12) { if (totalPacks !== requiredPacks) {
setSubmitError('Order must contain exactly 12 packs (120 capsules).') setSubmitError(`Order must contain exactly ${requiredPacks} packs (${selectedPlanCapsules} capsules).`)
return return
} }
setSubmitError(null) setSubmitError(null)
@ -176,6 +212,7 @@ export default function SummaryPage() {
await subscribeAbo(payload) await subscribeAbo(payload)
setShowThanks(true); setShowThanks(true);
try { sessionStorage.removeItem('coffeeSelections'); } catch {} try { sessionStorage.removeItem('coffeeSelections'); } catch {}
try { sessionStorage.removeItem('coffeeAboSizeCapsules'); } 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 {
@ -250,6 +287,13 @@ export default function SummaryPage() {
<section className="lg:col-span-2"> <section className="lg:col-span-2">
<h2 className="text-xl font-semibold mb-4">1. Your details</h2> <h2 className="text-xl font-semibold mb-4">1. Your details</h2>
<div className="rounded-xl border border-[#1C2B4A]/20 bg-white/80 backdrop-blur-sm p-6 shadow-lg"> <div className="rounded-xl border border-[#1C2B4A]/20 bg-white/80 backdrop-blur-sm p-6 shadow-lg">
<button
type="button"
onClick={fillFromLoggedInData}
className="mb-4 w-full rounded-md border border-[#1C2B4A] px-3 py-2 text-sm font-medium text-[#1C2B4A] hover:bg-[#1C2B4A]/5"
>
Fill fields with logged in data
</button>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
{/* inputs translated */} {/* inputs translated */}
<div> <div>
@ -342,10 +386,10 @@ export default function SummaryPage() {
</div> </div>
{/* Validation summary (refined design) */} {/* Validation summary (refined design) */}
<div className="mt-2 text-xs text-gray-700"> <div className="mt-2 text-xs text-gray-700">
Selected: {totalCapsules} capsules ({totalPacks} packs of 10). Selected: {totalCapsules} capsules ({totalPacks} packs of 10). Target: {selectedPlanCapsules} capsules ({requiredPacks} packs).
{totalPacks !== 12 && ( {totalPacks !== 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"> <span className="ml-2 inline-flex items-center rounded-md bg-red-50 text-red-700 px-2 py-1 border border-red-200">
Exactly 12 packs (120 capsules) are required. Exactly {requiredPacks} packs ({selectedPlanCapsules} capsules) are required.
</span> </span>
)} )}
</div> </div>