dev #21

Merged
Seazn merged 35 commits from dev into main 2026-05-21 17:34:44 +00:00
4 changed files with 72 additions and 87 deletions
Showing only changes of commit 7d029efe1b - Show all commits

View File

@ -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 };
} }

View File

@ -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 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>
<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'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="font-semibold">120 piece abo</div>
{shippingLoading ? ( {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"> <span className="inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold bg-gray-100 text-gray-700">Shipping</span>
Shipping ) : isFreeShippingSelected ? (
</span> <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>
) : 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"> <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>
Shipping {shippingFeeFor120.toFixed(2)}
</span>
)} )}
</div> </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 (10120)</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>

View File

@ -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,

View File

@ -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 ? (