dev #21
@ -2,18 +2,14 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
export type CoffeeShippingFeePieceCount = 60 | 120;
|
|
||||||
|
|
||||||
export type CoffeeShippingFee = {
|
export type CoffeeShippingFee = {
|
||||||
pieceCount: CoffeeShippingFeePieceCount;
|
pieceCount: number;
|
||||||
price: number;
|
price: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ShippingFeeMap = Record<CoffeeShippingFeePieceCount, number>;
|
function normalizePieceCount(v: any): number | null {
|
||||||
|
|
||||||
function normalizePieceCount(v: any): CoffeeShippingFeePieceCount | null {
|
|
||||||
const n = typeof v === 'number' ? v : (typeof v === 'string' && /^\d+$/.test(v) ? Number(v) : NaN);
|
const n = typeof v === 'number' ? v : (typeof v === 'string' && /^\d+$/.test(v) ? Number(v) : NaN);
|
||||||
if (n === 60 || n === 120) return n;
|
if (Number.isInteger(n) && n >= 60 && n % 10 === 0) return n;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -24,12 +20,11 @@ export function useShippingFees() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const feeByPieceCount: ShippingFeeMap = useMemo(() => {
|
// Threshold lookup: finds the highest breakpoint <= n (mirrors backend logic).
|
||||||
const map: ShippingFeeMap = { 60: 0, 120: 0 };
|
const resolveShippingFee = useMemo(() => (n: number): number => {
|
||||||
for (const row of fees) {
|
const sorted = [...fees].sort((a, b) => b.pieceCount - a.pieceCount);
|
||||||
map[row.pieceCount] = row.price;
|
const match = sorted.find(f => f.pieceCount <= n);
|
||||||
}
|
return match ? match.price : 0;
|
||||||
return map;
|
|
||||||
}, [fees]);
|
}, [fees]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -83,5 +78,5 @@ export function useShippingFees() {
|
|||||||
};
|
};
|
||||||
}, [base]);
|
}, [base]);
|
||||||
|
|
||||||
return { fees, feeByPieceCount, loading, error };
|
return { fees, resolveShippingFee, loading, error };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,19 +8,41 @@ import { useShippingFees } from './hooks/useShippingFees';
|
|||||||
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 [selectedPlanCapsules, setSelectedPlanCapsules] = useState<number>(60);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// Fetch active coffees from the backend
|
// Fetch active coffees from the backend
|
||||||
const { coffees, loading, error } = useActiveCoffees();
|
const { coffees, loading, error } = useActiveCoffees();
|
||||||
|
|
||||||
// Shipping fees (per piece count)
|
// Shipping fees (threshold-based)
|
||||||
const { feeByPieceCount, loading: shippingLoading, error: shippingError } = useShippingFees();
|
const { resolveShippingFee, loading: shippingLoading, error: shippingError } = useShippingFees();
|
||||||
const shippingFeeFor60 = feeByPieceCount[60] ?? 0;
|
const selectedShippingFee = resolveShippingFee(selectedPlanCapsules);
|
||||||
const shippingFeeFor120 = feeByPieceCount[120] ?? 0;
|
|
||||||
const selectedShippingFee = feeByPieceCount[selectedPlanCapsules] ?? 0;
|
|
||||||
const isFreeShippingSelected = Number(selectedShippingFee) === 0;
|
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(
|
||||||
() =>
|
() =>
|
||||||
Object.entries(selections).map(([id, qty]) => {
|
Object.entries(selections).map(([id, qty]) => {
|
||||||
@ -81,7 +103,7 @@ export default function CoffeeAbonnementPage() {
|
|||||||
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 otherTotal = Object.entries(prev).reduce((sum, [key, qty]) => key === id ? sum : sum + qty, 0);
|
||||||
const maxForCoffee = Math.min(120, selectedPlanCapsules - otherTotal);
|
const maxForCoffee = selectedPlanCapsules - otherTotal;
|
||||||
const next = prev[id] + delta;
|
const next = prev[id] + delta;
|
||||||
if (next < 10 || next > maxForCoffee) return prev;
|
if (next < 10 || next > maxForCoffee) return prev;
|
||||||
const updated = { ...prev, [id]: next };
|
const updated = { ...prev, [id]: next };
|
||||||
@ -115,63 +137,32 @@ export default function CoffeeAbonnementPage() {
|
|||||||
<section>
|
<section>
|
||||||
<h2 className="text-xl font-semibold mb-4">1. Select subscription size</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="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">
|
<div className="flex items-center gap-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSelectedPlanCapsules(60)}
|
onClick={() => changePlanSize(-10)}
|
||||||
className={`rounded-lg border px-4 py-3 text-left transition ${
|
disabled={selectedPlanCapsules <= 60}
|
||||||
selectedPlanCapsules === 60
|
className="h-10 w-10 rounded-full bg-gray-100 hover:bg-gray-200 text-lg font-bold transition disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center"
|
||||||
? 'border-[#1C2B4A] bg-[#1C2B4A]/5 text-[#1C2B4A]'
|
>−</button>
|
||||||
: 'border-gray-300 hover:bg-gray-50'
|
<div className="flex-1 text-center">
|
||||||
}`}
|
<div className="text-2xl font-extrabold text-[#1C2B4A]">{selectedPlanCapsules} pcs</div>
|
||||||
>
|
<div className="text-xs text-gray-500">{selectedPlanCapsules / 10} packs of 10 · min. 60</div>
|
||||||
<div className="flex items-start justify-between gap-3">
|
</div>
|
||||||
<div className="font-semibold">60 piece abo</div>
|
|
||||||
{shippingLoading ? (
|
|
||||||
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold bg-gray-100 text-gray-700 ring-1 ring-inset ring-gray-200">
|
|
||||||
Shipping…
|
|
||||||
</span>
|
|
||||||
) : shippingFeeFor60 === 0 ? (
|
|
||||||
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-200">
|
|
||||||
FREE SHIPPING
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold bg-gray-100 text-gray-700 ring-1 ring-inset ring-gray-200">
|
|
||||||
Shipping €{shippingFeeFor60.toFixed(2)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-600">6 packs of 10 capsules</div>
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSelectedPlanCapsules(120)}
|
onClick={() => changePlanSize(+10)}
|
||||||
className={`rounded-lg border px-4 py-3 text-left transition ${
|
className="h-10 w-10 rounded-full bg-gray-100 hover:bg-gray-200 text-lg font-bold transition flex items-center justify-center"
|
||||||
selectedPlanCapsules === 120
|
>+</button>
|
||||||
? 'border-[#1C2B4A] bg-[#1C2B4A]/5 text-[#1C2B4A]'
|
<div className="ml-4">
|
||||||
: 'border-gray-300 hover:bg-gray-50'
|
{shippingLoading ? (
|
||||||
}`}
|
<span className="inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold bg-gray-100 text-gray-700">Shipping…</span>
|
||||||
>
|
) : isFreeShippingSelected ? (
|
||||||
<div className="flex items-start justify-between gap-3">
|
<span className="inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-200">FREE SHIPPING</span>
|
||||||
<div className="font-semibold">120 piece abo</div>
|
) : (
|
||||||
{shippingLoading ? (
|
<span className="inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold bg-gray-100 text-gray-700 ring-1 ring-inset ring-gray-200">Shipping €{selectedShippingFee.toFixed(2)}</span>
|
||||||
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold bg-gray-100 text-gray-700 ring-1 ring-inset ring-gray-200">
|
)}
|
||||||
Shipping…
|
</div>
|
||||||
</span>
|
|
||||||
) : shippingFeeFor120 === 0 ? (
|
|
||||||
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-200">
|
|
||||||
FREE SHIPPING
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold bg-gray-100 text-gray-700 ring-1 ring-inset ring-gray-200">
|
|
||||||
Shipping €{shippingFeeFor120.toFixed(2)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-600">12 packs of 10 capsules</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{shippingError && (
|
{shippingError && (
|
||||||
<div className="mt-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
|
<div className="mt-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
|
||||||
Shipping fees could not be loaded: {shippingError}
|
Shipping fees could not be loaded: {shippingError}
|
||||||
@ -263,7 +254,7 @@ export default function CoffeeAbonnementPage() {
|
|||||||
{active && (
|
{active && (
|
||||||
<div className="mt-4 flex flex-col gap-3">
|
<div className="mt-4 flex flex-col gap-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-[11px] font-medium text-gray-500">Quantity (10–120)</span>
|
<span className="text-[11px] font-medium text-gray-500">Quantity (10–{maxForCoffee} pcs)</span>
|
||||||
<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'}`}
|
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'}`}
|
||||||
>
|
>
|
||||||
@ -374,8 +365,7 @@ export default function CoffeeAbonnementPage() {
|
|||||||
Selected: {totalCapsules} capsules ({packsSelected} packs of 10). Target: {selectedPlanCapsules} capsules ({requiredPacks} packs).
|
Selected: {totalCapsules} capsules ({packsSelected} packs of 10). Target: {selectedPlanCapsules} capsules ({requiredPacks} packs).
|
||||||
{packsSelected !== requiredPacks && (
|
{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 {selectedPlanCapsules} capsules ({requiredPacks} packs).
|
{packsSelected < requiredPacks ? `${requiredPacks - packsSelected} packs missing.` : `${packsSelected - requiredPacks} packs too many — reduce plan size or remove some coffees.`}
|
||||||
{packsSelected < requiredPacks ? ` ${requiredPacks - packsSelected} packs missing.` : ` ${packsSelected - requiredPacks} packs too many.`}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -407,7 +397,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 {selectedPlanCapsules} capsules ({requiredPacks} packs) are selected.
|
You can continue once exactly {selectedPlanCapsules} capsules ({requiredPacks} packs) are selected. Use the +/− buttons above to adjust the plan size.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -47,7 +47,7 @@ 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 user = useAuthStore(state => state.user)
|
||||||
const { feeByPieceCount, loading: shippingLoading, error: shippingError } = useShippingFees();
|
const { resolveShippingFee, loading: shippingLoading, error: shippingError } = useShippingFees();
|
||||||
const { html: contractHtml, loading: contractLoading, error: contractError } = useAboContractTemplateHtml()
|
const { html: contractHtml, loading: contractLoading, error: contractError } = useAboContractTemplateHtml()
|
||||||
const [isContractPreviewOpen, setIsContractPreviewOpen] = useState(false)
|
const [isContractPreviewOpen, setIsContractPreviewOpen] = useState(false)
|
||||||
const [contractPdfUrl, setContractPdfUrl] = useState<string>('')
|
const [contractPdfUrl, setContractPdfUrl] = useState<string>('')
|
||||||
@ -55,7 +55,7 @@ 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<60 | 120>(120);
|
const [selectedPlanCapsules, setSelectedPlanCapsules] = useState<number>(60);
|
||||||
const [signatureDataUrl, setSignatureDataUrl] = useState('')
|
const [signatureDataUrl, setSignatureDataUrl] = useState('')
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
firstName: '',
|
firstName: '',
|
||||||
@ -349,8 +349,9 @@ export default function SummaryPage() {
|
|||||||
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');
|
const rawPlan = sessionStorage.getItem('coffeeAboSizeCapsules');
|
||||||
if (rawPlan === '60' || rawPlan === '120') {
|
const parsedPlan = rawPlan ? Number(rawPlan) : null;
|
||||||
setSelectedPlanCapsules(Number(rawPlan) as 60 | 120);
|
if (parsedPlan && Number.isInteger(parsedPlan) && parsedPlan >= 60 && parsedPlan % 10 === 0) {
|
||||||
|
setSelectedPlanCapsules(parsedPlan);
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}, []);
|
}, []);
|
||||||
@ -449,9 +450,8 @@ export default function SummaryPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const shippingFee = useMemo(() => {
|
const shippingFee = useMemo(() => {
|
||||||
const v = feeByPieceCount[selectedPlanCapsules];
|
return resolveShippingFee(selectedPlanCapsules);
|
||||||
return Number.isFinite(Number(v)) ? Number(v) : 0;
|
}, [resolveShippingFee, selectedPlanCapsules]);
|
||||||
}, [feeByPieceCount, selectedPlanCapsules]);
|
|
||||||
|
|
||||||
const netWithShipping = useMemo(
|
const netWithShipping = useMemo(
|
||||||
() => totalPrice + shippingFee,
|
() => totalPrice + shippingFee,
|
||||||
|
|||||||
@ -153,8 +153,8 @@ 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 && draftTotalPacks !== 12) {
|
if (draftTotalPacks < 6) {
|
||||||
setContentError('Total packs must be exactly 6 or 12.')
|
setContentError('Total must be at least 6 packs (60 capsules).')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -439,7 +439,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">Edit coffee content</h3>
|
<h3 className="text-sm font-semibold text-gray-900">Edit coffee content</h3>
|
||||||
<p className="text-xs text-gray-600">Selected packs: {draftTotalPacks} (must be 6 or 12)</p>
|
<p className="text-xs text-gray-600">Selected packs: {draftTotalPacks} (minimum 6)</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{coffeesLoading ? (
|
{coffeesLoading ? (
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user