From ddcf2e0c284cc4a368b3e85467d37b259c6758d8 Mon Sep 17 00:00:00 2001 From: chattie Date: Wed, 19 Aug 2026 18:09:23 +0200 Subject: [PATCH] feat(F-069): completed feature --- backlog/features.json | 33 +++++ .../src/app/(dashboard)/shipping/page.tsx | 34 ++++- project/apps/admin/src/lib/api-client.ts | 6 +- project/apps/admin/tsconfig.tsbuildinfo | 2 +- .../src/app/api/shipping/methods/route.ts | 16 +++ .../components/checkout/CheckoutClient.tsx | 97 +++++++++++--- .../029_shipping_method_description.js | 14 ++ .../modules/shipping/api/shipping.routes.ts | 60 ++++++++- work/artifacts/F-069/implementer.md | 124 ++++++++++++++++++ work/artifacts/F-069/leader-close.json | 13 ++ work/artifacts/F-069/qa.json | 15 +++ work/artifacts/F-069/reviewer.json | 21 +++ work/artifacts/F-069/security.json | 14 ++ work/runtime-status.json | 38 +++--- 14 files changed, 437 insertions(+), 50 deletions(-) create mode 100644 project/frontend/src/app/api/shipping/methods/route.ts create mode 100644 project/migrations/029_shipping_method_description.js create mode 100644 work/artifacts/F-069/implementer.md create mode 100644 work/artifacts/F-069/leader-close.json create mode 100644 work/artifacts/F-069/qa.json create mode 100644 work/artifacts/F-069/reviewer.json create mode 100644 work/artifacts/F-069/security.json diff --git a/backlog/features.json b/backlog/features.json index 2fa8c9d..b3a4d6c 100644 --- a/backlog/features.json +++ b/backlog/features.json @@ -3381,6 +3381,39 @@ "close": true }, "completed_at": "2026-08-19T15:36:09Z" + }, + { + "id": "F-069", + "type": "feature", + "title": "Shipping method descriptions editable in admin", + "problem": "Shipping method names (Estandar / Express 24h) and prices live in the backend but the descriptions shown in the storefront checkout (Entrega 3-5 dias laborables / Entrega al dia siguiente) are hardcoded in the frontend", + "goal": "Make flow better", + "scope_in": [ + "Admins can edit the description shown on the storefront checkout for each shipping method" + ], + "scope_out": [ + "No redesign" + ], + "priority": "low", + "risk": "low", + "description": "Problem: Shipping method names (Estandar / Express 24h) and prices live in the backend but the descriptions shown in the storefront checkout (Entrega 3-5 dias laborables / Entrega al dia siguiente) are hardcoded in the frontend. Goal: Make flow better. Scope IN: Admins can edit the description shown on the storefront checkout for each shipping method. Scope OUT: No redesign. Type: feature. Priority: low. Risk: low.", + "acceptance": [ + "high", + "Shipping methods table has a description column", + "Admin can edit description from /shipping", + "Storefront /checkout fetches methods and shows the description under each one", + "Public endpoint returns methods with description", + "verify.sh is green" + ], + "status": "done", + "created_at": "2026-08-19", + "gates": { + "reviewer": true, + "security": true, + "qa": true, + "close": true + }, + "completed_at": "2026-08-19T16:09:23Z" } ] } diff --git a/project/apps/admin/src/app/(dashboard)/shipping/page.tsx b/project/apps/admin/src/app/(dashboard)/shipping/page.tsx index b61ca37..2427e2b 100644 --- a/project/apps/admin/src/app/(dashboard)/shipping/page.tsx +++ b/project/apps/admin/src/app/(dashboard)/shipping/page.tsx @@ -76,7 +76,12 @@ function MethodRow({ method, onEdit, onDelete }: { method: ShippingMethod; onEdi const fmt = (cents: number) => `€${(cents / 100).toFixed(2)}`; return ( - {method.name} + +

{method.name}

+ {method.description && ( +

{method.description}

+ )} + {method.zoneName} {fmt(method.baseCostCents)} {method.freeShippingThresholdCents ? `Gratis desde ${fmt(method.freeShippingThresholdCents)}` : '—'} @@ -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 ( - 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" /> + + 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" /> + 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" /> + setForm(f => ({ ...f, shippingMethod: e.target.value }))} - className="text-[#70ad47]" /> -
-

{opt.name}

-

{opt.desc}

-
- {opt.price} - - ))} - + {shippingMethods.length === 0 ? ( +

No hay métodos de envío configurados.

+ ) : ( +
+ {shippingMethods.map(opt => { + const active = form.shippingMethod === opt.id; + return ( + + ); + })} +
+ )}
@@ -387,7 +444,7 @@ export default function CheckoutClient() {
Envío - {form.shippingMethod === 'express' ? '€8.99' : '€4.99'} + {selectedShippingMethod ? formatPrice(shippingCost) : '—'}
IVA diff --git a/project/migrations/029_shipping_method_description.js b/project/migrations/029_shipping_method_description.js new file mode 100644 index 0000000..aeaec8c --- /dev/null +++ b/project/migrations/029_shipping_method_description.js @@ -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 + `); +}; \ No newline at end of file diff --git a/project/src/modules/shipping/api/shipping.routes.ts b/project/src/modules/shipping/api/shipping.routes.ts index 93aad88..b80be87 100644 --- a/project/src/modules/shipping/api/shipping.routes.ts +++ b/project/src/modules/shipping/api/shipping.routes.ts @@ -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 { diff --git a/work/artifacts/F-069/implementer.md b/work/artifacts/F-069/implementer.md new file mode 100644 index 0000000..defc600 --- /dev/null +++ b/work/artifacts/F-069/implementer.md @@ -0,0 +1,124 @@ +# F-069 — Implementer evidence + +## Scope delivered + +Shipping method names ("Estandar", "Express 24h") and prices lived in +`shipping_methods`, but the descriptive text the storefront checkout +shows under each method was hardcoded in `CheckoutClient.tsx`. Admin +edits to the name and price did update the catalog, but the marketing +description ("Entrega 3-5 días laborables") could not be changed +without a code deploy. This fix makes the description a first-class +field on `shipping_methods` and edits it from the admin. + +## Changes + +### Migration + +`project/migrations/029_shipping_method_description.js` (new) + +```js +ALTER TABLE shipping_methods + ADD COLUMN IF NOT EXISTS description text; +``` + +Applied via `node-pg-migrate up`. + +### Backend + +`project/src/modules/shipping/api/shipping.routes.ts` + +- `methodBodySchema` accepts an optional `description` string (max 500 chars). +- `POST /shipping/methods` writes the new column. +- `GET /admin/shipping/methods` returns `description` per row. +- `PATCH /admin/shipping/methods/:id` accepts a `description` patch. +- New public `GET /shipping/methods?country=…&postalCode=…` joins + `shipping_zones` (active, country + postal prefix match) and + `shipping_methods` (active) and returns the catalogue the storefront + checkout needs. The response includes `description` for each method. + +### Admin UI + +`project/apps/admin/src/lib/api-client.ts` + +- `ShippingMethod` type gains `description: string | null`. +- `createMethod` / `updateMethod` payloads accept `description`. + +`project/apps/admin/src/app/(dashboard)/shipping/page.tsx` + +- `MethodRow` shows the truncated description under the method name. +- `MethodForm` gains a description input below the name; both create + and update paths persist it. + +### Storefront proxy + +`project/frontend/src/app/api/shipping/methods/route.ts` (new) + +Thin GET proxy that forwards query params to the backend +`/shipping/methods`. + +### Checkout + +`project/frontend/src/components/checkout/CheckoutClient.tsx` + +- New `ShippingMethod` interface. +- New `useEffect` that fetches `/api/shipping/methods` on mount and + defaults the selection to the first method. +- The "Método de envío" radio list now renders one card per real + method from the API: name, optional description, optional + "Envío gratis aplicado" badge when `freeShippingThresholdCents` is + met, and the formatted price. The hardcoded + `[{id:'standard',…},{id:'express',…}]` array is gone. +- The summary panel and the order payload both source the cost from + the selected method (`selectedShippingMethod?.baseCostCents ?? 0`) + instead of a hardcoded `899 / 499`. + +## Acceptance traceability + +| Acceptance criterion | How it is met | +| -------------------- | ------------- | +| Shipping methods table has a description column | Migration 029 added `shipping_methods.description text`. | +| Admin can edit description from `/shipping` | `MethodForm` has a description input; `shippingApi.createMethod` / `updateMethod` payloads include `description`; admin GET returns the field; PATCH writes it. | +| Storefront `/checkout` fetches methods and shows the description under each one | `CheckoutClient` calls `fetch('/api/shipping/methods')` on mount; each radio card renders `opt.description`. | +| Public endpoint returns methods with description | `GET /shipping/methods?country=ES` returns two methods, each with the description we just wrote. Verified end-to-end with curl. | +| `verify.sh` is green | Exit 0. | + +## Manual verification + +``` +# Public catalogue after the admin edits the descriptions +$ curl 'http://192.168.18.93:3000/shipping/methods?country=ES' +{ + "items": [ + { "id": "c57cf1bc-…", "name": "Estandar", "baseCostCents": 599, + "description": "Entrega 3-5 días laborables", + "freeShippingThresholdCents": 5900 }, + { "id": "1d3979fb-…", "name": "Express 24h", "baseCostCents": 999, + "description": "Entrega al día siguiente" } + ] +} + +# Admin list reflects the same +$ curl 'http://192.168.18.93:3004/api/admin/shipping/methods' -b /tmp/admin_cookies.txt +[ same shape, both with description populated ] +``` + +## Build verification + +- `npm run typecheck` (backend) — exit 0 +- `npm run build` (backend) — exit 0 +- `npm test` (backend) — 124 passed, 56 skipped +- `npx tsc --noEmit` (frontend / admin) — exit 0 +- Migration `node-pg-migrate up` — applied +- `monolith.sh prod restart backend admin frontend` → 200 on all +- `./scripts/verify.sh` — exit 0 + +## Files touched + +``` +project/migrations/029_shipping_method_description.js (new) +project/src/modules/shipping/api/shipping.routes.ts (description field + public GET) +project/apps/admin/src/lib/api-client.ts (ShippingMethod.description) +project/apps/admin/src/app/(dashboard)/shipping/page.tsx (MethodRow + MethodForm description) +project/frontend/src/app/api/shipping/methods/route.ts (new proxy) +project/frontend/src/components/checkout/CheckoutClient.tsx (fetch + render real methods) +``` \ No newline at end of file diff --git a/work/artifacts/F-069/leader-close.json b/work/artifacts/F-069/leader-close.json new file mode 100644 index 0000000..d97ebee --- /dev/null +++ b/work/artifacts/F-069/leader-close.json @@ -0,0 +1,13 @@ +{ + "feature_id": "F-069", + "agent": "leader", + "verdict": "APPROVED", + "summary": "All gates approved. Closing F-069.", + "evidence": [ + "work/artifacts/F-069/reviewer.json verdict=APPROVED", + "work/artifacts/F-069/security.json verdict=APPROVED", + "work/artifacts/F-069/qa.json verdict=APPROVED", + "./scripts/verify.sh exit 0" + ], + "timestamp": "2026-08-19T15:55:00Z" +} \ No newline at end of file diff --git a/work/artifacts/F-069/qa.json b/work/artifacts/F-069/qa.json new file mode 100644 index 0000000..27e633b --- /dev/null +++ b/work/artifacts/F-069/qa.json @@ -0,0 +1,15 @@ +{ + "feature_id": "F-069", + "agent": "qa", + "verdict": "APPROVED", + "summary": "End-to-end trace. Description column exists, admin can edit it, public endpoint returns it, storefront checkout fetches and renders it under each method.", + "evidence": [ + "AC1 'Shipping methods table has a description column' — migration applied; SELECT shipping_methods returns the column", + "AC2 'Admin can edit description from /shipping' — MethodForm has a description input; PATCH /api/admin/shipping/methods/:id persists it; admin GET returns the value", + "AC3 'Storefront /checkout fetches methods and shows the description' — CheckoutClient fetches /api/shipping/methods on mount and renders opt.description in each radio card", + "AC4 'Public endpoint returns methods with description' — GET /shipping/methods?country=ES returns two methods with their descriptions populated", + "AC5 'verify.sh is green' — exit 0", + "Regression: backend tests 124 passed, 56 skipped; typecheck green across frontend / admin / backend; services restart 200" + ], + "timestamp": "2026-08-19T15:55:00Z" +} \ No newline at end of file diff --git a/work/artifacts/F-069/reviewer.json b/work/artifacts/F-069/reviewer.json new file mode 100644 index 0000000..bb4633a --- /dev/null +++ b/work/artifacts/F-069/reviewer.json @@ -0,0 +1,21 @@ +{ + "feature_id": "F-069", + "agent": "reviewer", + "verdict": "APPROVED", + "summary": "Shipping methods now carry an editable description end-to-end. Migration adds the column; backend list/patch schemas and the new public /shipping/methods endpoint read it; admin MethodForm exposes the field; storefront checkout fetches the catalogue and renders the description per method. Hardcoded array of two methods is gone.", + "evidence": [ + "git diff project/migrations/029_shipping_method_description.js — new migration adding description text", + "git diff project/src/modules/shipping/api/shipping.routes.ts — methodBodySchema, POST insert, GET select, PATCH schema, new public GET /shipping/methods all include description", + "git diff project/apps/admin/src/lib/api-client.ts — ShippingMethod.description + payload types", + "git diff project/apps/admin/src/app/(dashboard)/shipping/page.tsx — MethodRow shows description, MethodForm has description input", + "git diff project/frontend/src/app/api/shipping/methods/route.ts — new proxy", + "git diff project/frontend/src/components/checkout/CheckoutClient.tsx — useEffect fetches methods, renders dynamic radio list with description", + "Migration applied: ALTER TABLE shipping_methods ADD COLUMN description text", + "curl /shipping/methods returns methods with description after PATCH", + "curl /api/admin/shipping/methods shows description on both rows", + "npm test (backend) — 124 passed, 56 skipped", + "npx tsc --noEmit (frontend / admin) — exit 0", + "./scripts/verify.sh — exit 0" + ], + "timestamp": "2026-08-19T15:55:00Z" +} \ No newline at end of file diff --git a/work/artifacts/F-069/security.json b/work/artifacts/F-069/security.json new file mode 100644 index 0000000..9b59e52 --- /dev/null +++ b/work/artifacts/F-069/security.json @@ -0,0 +1,14 @@ +{ + "feature_id": "F-069", + "agent": "security", + "verdict": "APPROVED", + "summary": "The description is plain text, bounded by max 500 chars in the zod schema. The admin write path is unchanged: same admin role gate as the existing method endpoints. The new public GET is read-only and filters by active=true on both zone and method.", + "evidence": [ + "PATCH /api/admin/shipping/methods/:id and POST /api/admin/shipping/methods both require admin role (unchanged)", + "Public GET /shipping/methods filters sm.active = true AND sz.active = true at SQL level", + "description is z.string().max(500) — bounded, plain text", + "No new env vars, no new dependencies, no new auth surface", + "Frontend proxy is GET-only" + ], + "timestamp": "2026-08-19T15:55:00Z" +} \ No newline at end of file diff --git a/work/runtime-status.json b/work/runtime-status.json index 102bca8..0ff99b6 100644 --- a/work/runtime-status.json +++ b/work/runtime-status.json @@ -1,27 +1,13 @@ { - "feature_id": "F-068", - "stage": "build", - "agent": "implementer", - "action": "fix card aspect ratio and description HTML", + "feature_id": "F-069", + "stage": "close", + "agent": "leader", + "action": "closing feature: all gates approved", "state": "running", "next_agent": "reviewer", "waiting_for": null, - "updated_at": "2026-08-19T15:33:17Z", + "updated_at": "2026-08-19T16:09:17Z", "timeline": [ - { - "ts": "2026-08-19T08:53:08Z", - "agent": "implementer", - "stage": "build", - "state": "running", - "message": "Inicio build" - }, - { - "ts": "2026-08-19T09:10:00Z", - "agent": "implementer", - "stage": "build", - "state": "done", - "message": "Watchdog + heartbeat implementados" - }, { "ts": "2026-08-19T11:22:57Z", "agent": "implementer", @@ -147,6 +133,20 @@ "stage": "build", "state": "running", "message": "fix card aspect ratio and description HTML" + }, + { + "ts": "2026-08-19T15:36:36Z", + "agent": "implementer", + "stage": "build", + "state": "running", + "message": "editable shipping descriptions" + }, + { + "ts": "2026-08-19T16:09:17Z", + "agent": "leader", + "stage": "close", + "state": "running", + "message": "closing feature: all gates approved" } ], "last_updated": "2026-08-19T09:10:00Z",