feat(F-069): completed feature

This commit is contained in:
chattie
2026-08-19 18:09:23 +02:00
parent ba2ac939c7
commit ddcf2e0c28
14 changed files with 437 additions and 50 deletions

View File

@@ -76,7 +76,12 @@ function MethodRow({ method, onEdit, onDelete }: { method: ShippingMethod; onEdi
const fmt = (cents: number) => `${(cents / 100).toFixed(2)}`;
return (
<tr className="hover:bg-gray-50 transition-colors">
<td className="px-6 py-4 text-sm font-medium text-gray-900">{method.name}</td>
<td className="px-6 py-4">
<p className="text-sm font-medium text-gray-900">{method.name}</p>
{method.description && (
<p className="text-xs text-gray-500 mt-0.5 max-w-xs">{method.description}</p>
)}
</td>
<td className="px-6 py-4 text-sm text-gray-600">{method.zoneName}</td>
<td className="px-6 py-4 text-sm font-semibold text-gray-800">{fmt(method.baseCostCents)}</td>
<td className="px-6 py-4 text-sm text-gray-500">{method.freeShippingThresholdCents ? `Gratis desde ${fmt(method.freeShippingThresholdCents)}` : '—'}</td>
@@ -97,6 +102,7 @@ function MethodForm({ zones, method, onSave, onCancel }: { zones: ShippingZone[]
const [zoneId, setZoneId] = useState(method?.zoneId ?? zones[0]?.id ?? '');
const [cost, setCost] = useState(method ? String(method.baseCostCents / 100) : '');
const [threshold, setThreshold] = useState(method?.freeShippingThresholdCents ? String(method.freeShippingThresholdCents / 100) : '');
const [description, setDescription] = useState(method?.description ?? '');
const [active, setActive] = useState(method?.active ?? true);
const [saving, setSaving] = useState(false);
const [err, setErr] = useState('');
@@ -106,10 +112,24 @@ function MethodForm({ zones, method, onSave, onCancel }: { zones: ShippingZone[]
try {
const baseCostCents = Math.round(parseFloat(cost) * 100);
const freeThreshold = threshold ? Math.round(parseFloat(threshold) * 100) : null;
const descriptionPayload = description.trim() || null;
if (method) {
await shippingApi.updateMethod(method.id, { name, baseCostCents, freeShippingThresholdCents: freeThreshold, active });
await shippingApi.updateMethod(method.id, {
name,
baseCostCents,
freeShippingThresholdCents: freeThreshold,
description: descriptionPayload,
active,
});
} else {
await shippingApi.createMethod({ zoneId, name, baseCostCents, freeShippingThresholdCents: freeThreshold, active });
await shippingApi.createMethod({
zoneId,
name,
baseCostCents,
freeShippingThresholdCents: freeThreshold,
description: descriptionPayload,
active,
});
}
onSave(); onCancel();
} catch (er) { setErr(er instanceof Error ? er.message : 'Error'); } finally { setSaving(false); }
@@ -117,8 +137,12 @@ function MethodForm({ zones, method, onSave, onCancel }: { zones: ShippingZone[]
return (
<tr className="bg-green-50/50 border-b border-green-100">
<td className="px-4 py-3"><input value={name} onChange={e => setName(e.target.value)} required placeholder="Nombre método"
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" /></td>
<td className="px-4 py-3">
<input value={name} onChange={e => setName(e.target.value)} required placeholder="Nombre método"
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
<input value={description} onChange={e => setDescription(e.target.value)} placeholder="Descripción para el checkout"
className="w-full px-3 py-2 mt-1.5 border border-gray-300 rounded-lg text-xs focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
</td>
<td className="px-4 py-3">
<select value={zoneId} onChange={e => setZoneId(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none">

View File

@@ -271,7 +271,7 @@ export interface ShippingZone {
}
export interface ShippingMethod {
id: string; zoneId: string; zoneName: string; name: string;
baseCostCents: number; freeShippingThresholdCents: number | null; active: boolean;
baseCostCents: number; freeShippingThresholdCents: number | null; description: string | null; active: boolean;
}
export const shippingApi = {
listZones: () => api.get<{ items: ShippingZone[] }>('/api/admin/shipping/zones'),
@@ -281,9 +281,9 @@ export const shippingApi = {
api.patch('/api/admin/shipping/zones/' + id, data),
deleteZone: (id: string) => api.delete<void>('/api/admin/shipping/zones/' + id),
listMethods: () => api.get<{ items: ShippingMethod[] }>('/api/admin/shipping/methods'),
createMethod: (data: { zoneId: string; name: string; baseCostCents: number; freeShippingThresholdCents?: number | null; active?: boolean }) =>
createMethod: (data: { zoneId: string; name: string; baseCostCents: number; freeShippingThresholdCents?: number | null; description?: string | null; active?: boolean }) =>
api.post<{ id: string }>('/api/admin/shipping/methods', data),
updateMethod: (id: string, data: Partial<{ name: string; baseCostCents: number; freeShippingThresholdCents?: number | null; active: boolean }>) =>
updateMethod: (id: string, data: Partial<{ name: string; baseCostCents: number; freeShippingThresholdCents?: number | null; description?: string | null; active: boolean }>) =>
api.patch('/api/admin/shipping/methods/' + id, data),
deleteMethod: (id: string) => api.delete<void>('/api/admin/shipping/methods/' + id),
};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,16 @@
import { NextRequest, NextResponse } from 'next/server';
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const qs = searchParams.toString();
const url = `${API}/shipping/methods${qs ? `?${qs}` : ''}`;
try {
const res = await fetch(url, { cache: 'no-store' });
const body = await res.json().catch(() => ({ items: [] }));
return NextResponse.json(body, { status: res.status });
} catch {
return NextResponse.json({ items: [] }, { status: 502 });
}
}

View File

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

View File

@@ -0,0 +1,14 @@
/* eslint-disable camelcase */
export const up = (pgm) => {
pgm.sql(`
ALTER TABLE shipping_methods
ADD COLUMN IF NOT EXISTS description text
`);
};
export const down = (pgm) => {
pgm.sql(`
ALTER TABLE shipping_methods
DROP COLUMN IF EXISTS description
`);
};

View File

@@ -28,6 +28,7 @@ const methodBodySchema = z.object({
name: z.string().min(1).max(120),
baseCostCents: z.number().int().min(0),
freeShippingThresholdCents: z.number().int().min(0).optional().nullable(),
description: z.string().max(500).optional().nullable(),
active: z.boolean().optional(),
});
@@ -74,13 +75,14 @@ export async function registerShippingRoutes(
requireRole(user, 'admin');
const input = parseJson(methodBodySchema, request.body);
const result = await deps.pool.query<{ id: string }>(
`INSERT INTO shipping_methods (zone_id, name, base_cost_cents, free_shipping_threshold_cents, active)
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
`INSERT INTO shipping_methods (zone_id, name, base_cost_cents, free_shipping_threshold_cents, description, active)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
[
input.zoneId,
input.name,
input.baseCostCents,
input.freeShippingThresholdCents ?? null,
input.description ?? null,
input.active ?? true,
],
);
@@ -199,6 +201,7 @@ export async function registerShippingRoutes(
name: string;
base_cost_cents: number;
free_shipping_threshold_cents: number | null;
description: string | null;
active: boolean;
}>(
`SELECT sm.*, sz.name as zone_name FROM shipping_methods sm
@@ -213,6 +216,7 @@ export async function registerShippingRoutes(
name: r.name,
baseCostCents: r.base_cost_cents,
freeShippingThresholdCents: r.free_shipping_threshold_cents,
description: r.description,
active: r.active,
})),
});
@@ -259,6 +263,7 @@ export async function registerShippingRoutes(
name: z.string().min(1).max(120).optional(),
baseCostCents: z.number().int().min(0).optional(),
freeShippingThresholdCents: z.number().int().min(0).optional().nullable(),
description: z.string().max(500).optional().nullable(),
active: z.boolean().optional(),
}),
request.body,
@@ -278,6 +283,10 @@ export async function registerShippingRoutes(
sets.push(`free_shipping_threshold_cents = $${i++}`);
values.push(patch.freeShippingThresholdCents);
}
if (patch.description !== undefined) {
sets.push(`description = $${i++}`);
values.push(patch.description);
}
if (patch.active !== undefined) {
sets.push(`active = $${i++}`);
values.push(patch.active);
@@ -342,6 +351,53 @@ export async function registerShippingRoutes(
throw mapShippingError(error);
}
});
// ── Public list of active shipping methods ───────────────────────────────────────────────────
const publicMethodsSchema: FastifySchema = {
tags: ['Shipping'],
summary: 'List active shipping methods (public)',
querystring: {
type: 'object',
properties: {
country: { type: 'string', description: 'Filter by country (defaults to ES)' },
postalCode: { type: 'string', description: 'Filter by postal code prefix' },
},
},
response: { 200: { type: 'object' } },
};
app.get('/shipping/methods', { schema: publicMethodsSchema }, async (request, reply) => {
const query = request.query as { country?: string; postalCode?: string };
const country = query.country ?? 'ES';
const result = await deps.pool.query<{
id: string;
zone_id: string;
name: string;
base_cost_cents: number;
free_shipping_threshold_cents: number | null;
description: string | null;
}>(
`SELECT sm.id, sm.zone_id, sm.name, sm.base_cost_cents,
sm.free_shipping_threshold_cents, sm.description
FROM shipping_methods sm
JOIN shipping_zones sz ON sz.id = sm.zone_id
WHERE sm.active = true
AND sz.active = true
AND sz.country = $1
AND ($2::text IS NULL OR sz.postal_code_prefix IS NULL OR $2 LIKE sz.postal_code_prefix || '%')
ORDER BY sm.base_cost_cents ASC`,
[country, query.postalCode ?? null],
);
return reply.send({
items: result.rows.map((r) => ({
id: r.id,
zoneId: r.zone_id,
name: r.name,
baseCostCents: r.base_cost_cents,
freeShippingThresholdCents: r.free_shipping_threshold_cents,
description: r.description,
})),
});
});
}
function mapShippingError(error: unknown): Error {