feat: abo sub + dependency
This commit is contained in:
parent
49aee7b7ff
commit
004a8f4baa
4013
package-lock.json
generated
4013
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
60
package.json
60
package.json
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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,13 +323,22 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isPreview && (
|
{!isPreview && (
|
||||||
<textarea
|
<div className="space-y-3">
|
||||||
value={htmlCode}
|
{type === 'invoice' && (
|
||||||
onChange={(e) => setHtmlCode(e.target.value)}
|
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-900">
|
||||||
placeholder="Paste your full HTML (or snippet) here…"
|
<p className="font-semibold">Invoice template variables</p>
|
||||||
required
|
<p className="mt-1">Use these placeholders in your HTML: invoiceNumber, customerName, issuedAt, totalNet, totalTax, totalGross, itemsHtml.</p>
|
||||||
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"
|
<p className="mt-1">Important: include <span className="font-semibold">itemsHtml</span> to render invoice line items.</p>
|
||||||
/>
|
</div>
|
||||||
|
)}
|
||||||
|
<textarea
|
||||||
|
value={htmlCode}
|
||||||
|
onChange={(e) => setHtmlCode(e.target.value)}
|
||||||
|
placeholder="Paste your full HTML (or snippet) here…"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isPreview && (
|
{isPreview && (
|
||||||
|
|||||||
@ -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' && (
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user