feat(F-069): completed feature
This commit is contained in:
@@ -9,6 +9,15 @@ function formatPrice(cents: number) {
|
||||
return `€${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
interface ShippingMethod {
|
||||
id: string;
|
||||
zoneId?: string;
|
||||
name: string;
|
||||
baseCostCents: number;
|
||||
freeShippingThresholdCents: number | null;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
interface SavedAddress {
|
||||
id: string;
|
||||
label: string | null;
|
||||
@@ -49,6 +58,8 @@ export default function CheckoutClient() {
|
||||
|
||||
const [addresses, setAddresses] = useState<SavedAddress[]>([]);
|
||||
const [selectedAddressId, setSelectedAddressId] = useState<string | null>(null);
|
||||
const [shippingMethods, setShippingMethods] = useState<ShippingMethod[]>([]);
|
||||
const [shippingMethodId, setShippingMethodId] = useState<string | null>(null);
|
||||
|
||||
// Form state
|
||||
const [form, setForm] = useState({
|
||||
@@ -93,6 +104,31 @@ export default function CheckoutClient() {
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
// Fetch shipping methods from the admin-managed catalogue.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/shipping/methods', { cache: 'no-store' });
|
||||
if (!res.ok) return;
|
||||
const data = (await res.json()) as { items?: ShippingMethod[] };
|
||||
if (cancelled) return;
|
||||
const list = data.items ?? [];
|
||||
setShippingMethods(list);
|
||||
const def = list[0];
|
||||
if (def) {
|
||||
setShippingMethodId(def.id);
|
||||
setForm((f) => ({ ...f, shippingMethod: def.id }));
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const selectedAddress = useMemo(
|
||||
() => addresses.find((a) => a.id === selectedAddressId) ?? null,
|
||||
[addresses, selectedAddressId],
|
||||
@@ -125,7 +161,11 @@ export default function CheckoutClient() {
|
||||
);
|
||||
}
|
||||
|
||||
const shippingCost = form.shippingMethod === 'express' ? 899 : 499;
|
||||
const selectedShippingMethod = useMemo(
|
||||
() => shippingMethods.find((m) => m.id === shippingMethodId) ?? null,
|
||||
[shippingMethods, shippingMethodId],
|
||||
);
|
||||
const shippingCost = selectedShippingMethod?.baseCostCents ?? 0;
|
||||
const totalCents = subtotalCents + shippingCost;
|
||||
|
||||
const handlePlaceOrder = async () => {
|
||||
@@ -154,6 +194,7 @@ export default function CheckoutClient() {
|
||||
})),
|
||||
shippingMethod: form.shippingMethod,
|
||||
notes: form.notes,
|
||||
shippingMethodId: selectedShippingMethod?.id ?? null,
|
||||
}),
|
||||
});
|
||||
if (res.status === 401) {
|
||||
@@ -323,24 +364,40 @@ export default function CheckoutClient() {
|
||||
|
||||
<div className="border-t border-gray-200 pt-4 mt-4">
|
||||
<h3 className="font-semibold text-gray-900 mb-3">Método de envío</h3>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ id: 'standard', name: 'Estándar', desc: 'Entrega 3-5 días laborables', price: '€4.99' },
|
||||
{ id: 'express', name: 'Express 24h', desc: 'Entrega al día siguiente', price: '€8.99' },
|
||||
].map(opt => (
|
||||
<label key={opt.id}
|
||||
className={`flex items-center gap-3 p-3 border rounded-lg cursor-pointer transition-colors ${form.shippingMethod === opt.id ? 'border-[#70ad47] bg-[#70ad47]/5' : 'border-gray-200 hover:border-[#70ad47]'}`}>
|
||||
<input type="radio" name="shipping" value={opt.id} checked={form.shippingMethod === opt.id}
|
||||
onChange={e => setForm(f => ({ ...f, shippingMethod: e.target.value }))}
|
||||
className="text-[#70ad47]" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-gray-900">{opt.name}</p>
|
||||
<p className="text-sm text-gray-500">{opt.desc}</p>
|
||||
</div>
|
||||
<span className="font-semibold text-[#70ad47]">{opt.price}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{shippingMethods.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">No hay métodos de envío configurados.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{shippingMethods.map(opt => {
|
||||
const active = form.shippingMethod === opt.id;
|
||||
return (
|
||||
<label
|
||||
key={opt.id}
|
||||
className={`flex items-start gap-3 p-3 border rounded-lg cursor-pointer transition-colors ${active ? 'border-[#70ad47] bg-[#70ad47]/5' : 'border-gray-200 hover:border-[#70ad47]'}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="shipping"
|
||||
value={opt.id}
|
||||
checked={active}
|
||||
onChange={e => setForm(f => ({ ...f, shippingMethod: e.target.value }))}
|
||||
className="mt-1 text-[#70ad47]"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-gray-900">{opt.name}</p>
|
||||
{opt.description && (
|
||||
<p className="text-sm text-gray-500">{opt.description}</p>
|
||||
)}
|
||||
{opt.freeShippingThresholdCents && subtotalCents >= opt.freeShippingThresholdCents && (
|
||||
<p className="text-xs text-green-600 mt-1">Envío gratis aplicado</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="font-semibold text-[#70ad47]">{formatPrice(opt.baseCostCents)}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 pt-4">
|
||||
@@ -387,7 +444,7 @@ export default function CheckoutClient() {
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Envío</span>
|
||||
<span className="text-gray-500">{form.shippingMethod === 'express' ? '€8.99' : '€4.99'}</span>
|
||||
<span className="text-gray-500">{selectedShippingMethod ? formatPrice(shippingCost) : '—'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-gray-600">
|
||||
<span>IVA</span>
|
||||
|
||||
Reference in New Issue
Block a user