fix(release): 0.2.8 harden admin proxy, cart stock caps, selfpay and refund timeline
- admin proxy: 25s timeout, body guards, structured failure logs - cart: addItem enforces stock cap (409 INSUFFICIENT_STOCK), UI clamps qty - tpv selfpay: hide sidebar/discounts/save-pending, rename button, receipt-settings 400 fix - pos admin: quick products slot count aligned to 8 - returns: human-readable history message + metadata jsonb (migrations 064-065) + admin fallback formatter - storefront: product card white background - product page: remove duplicate stock label under add-to-cart button
This commit is contained in:
@@ -1 +1 @@
|
||||
0.2.4
|
||||
0.2.8
|
||||
|
||||
4
project/apps/admin/package-lock.json
generated
4
project/apps/admin/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@mercadodevida/admin",
|
||||
"version": "0.2.4",
|
||||
"version": "0.2.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@mercadodevida/admin",
|
||||
"version": "0.2.4",
|
||||
"version": "0.2.8",
|
||||
"dependencies": {
|
||||
"@lexical/history": "^0.49.0",
|
||||
"@lexical/html": "^0.49.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mercadodevida/admin",
|
||||
"version": "0.2.4",
|
||||
"version": "0.2.8",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --port 3001",
|
||||
|
||||
@@ -78,6 +78,35 @@ function formatPrice(cents: number) {
|
||||
return `€${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* F-138: legacy return events were stored as raw JSON. If the migration
|
||||
* hasn't caught up (or didn't run on a particular row) we still want the UI
|
||||
* to show something readable instead of dumping the payload. Falls back to
|
||||
* the original string when the value isn't JSON.
|
||||
*/
|
||||
function formatRefundMessage(raw: string): string {
|
||||
const trimmed = raw?.trim() ?? '';
|
||||
if (!trimmed.startsWith('{')) return raw;
|
||||
try {
|
||||
const payload = JSON.parse(trimmed) as {
|
||||
refundedCents?: number;
|
||||
status?: 'refund' | 'partial_refund';
|
||||
lines?: Array<{ returnedQuantity?: number }>;
|
||||
reason?: string;
|
||||
};
|
||||
const euros = ((payload.refundedCents ?? 0) / 100).toFixed(2);
|
||||
const action = payload.status === 'refund' ? 'Devolución total' : 'Devolución parcial';
|
||||
const lineCount = Array.isArray(payload.lines)
|
||||
? payload.lines.reduce((sum, line) => sum + (line.returnedQuantity ?? 0), 0)
|
||||
: 0;
|
||||
const reason = payload.reason?.trim();
|
||||
const detail = lineCount > 0 ? ` (${lineCount} uds.)` : '';
|
||||
return `${action} de ${euros} €${detail}${reason ? ` — ${reason}` : ''}`;
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
export default function OrderDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [order, setOrder] = useState<Order | null>(null);
|
||||
@@ -710,6 +739,9 @@ export default function OrderDetailPage() {
|
||||
// ORDERS-FIX: detectar refunds para mostrar de forma más legible
|
||||
const isRefund = /refund|reembolso|devolu/i.test(event.message);
|
||||
const isRefundEvent = event.eventType === 'REFUND' || isRefund;
|
||||
const formattedMessage = isRefundEvent
|
||||
? formatRefundMessage(event.message)
|
||||
: event.message;
|
||||
return (
|
||||
<div key={event.id} className="flex gap-3">
|
||||
<div className={`w-2 h-2 rounded-full mt-1.5 flex-shrink-0 ${
|
||||
@@ -718,7 +750,7 @@ export default function OrderDetailPage() {
|
||||
}`} />
|
||||
<div className="min-w-0">
|
||||
<p className={`text-sm break-words ${isRefundEvent ? 'text-pink-700 font-medium' : 'text-gray-800'}`}>
|
||||
{isRefundEvent && <span className="mr-1">💸</span>}{event.message}
|
||||
{isRefundEvent && <span className="mr-1">💸</span>}{formattedMessage}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{new Date(event.createdAt).toLocaleString('es-ES')}
|
||||
|
||||
@@ -199,7 +199,7 @@ export default function PosAdminPage() {
|
||||
setTouchEnabled(terminal.settings?.touchNavigationEnabled !== false);
|
||||
setDiscountsEnabled(terminal.settings?.lineDiscountsEnabled !== false);
|
||||
const configured = terminal.settings?.quickProductVariantIds ?? [];
|
||||
setQuickSlots(Array.from({ length: 10 }, (_, slot) => configured[slot] ?? null));
|
||||
setQuickSlots(Array.from({ length: 8 }, (_, slot) => configured[slot] ?? null));
|
||||
setTouchMessage('');
|
||||
setSelfpayMode(terminal.settings?.selfpayMode ?? false);
|
||||
setClosePinRequired(terminal.settings?.closeSessionRequiresPin ?? false);
|
||||
|
||||
@@ -2,126 +2,207 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
|
||||
|
||||
/**
|
||||
* Catch-all proxy: forwards ALL requests to the backend API.
|
||||
* This avoids CORS preflight issues since requests stay within the
|
||||
* same origin (localhost:3004 -> localhost:3004 proxy -> 127.0.0.1:3000 backend).
|
||||
*
|
||||
* More specific routes (e.g. /api/auth/login) take precedence in Next.js,
|
||||
* so they are NOT served by this handler.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const path = req.nextUrl.pathname.replace('/api/', '');
|
||||
const search = req.nextUrl.search;
|
||||
const cookies = req.headers.get('cookie') ?? '';
|
||||
try {
|
||||
const backendRes = await fetch(`${API}/${path}${search}`, {
|
||||
headers: { Cookie: cookies },
|
||||
});
|
||||
if (path === 'admin/logs/stream') {
|
||||
// Pipe SSE directly: Next.js App Router supports ReadableStream passthrough.
|
||||
// X-Accel-Buffering: no tells any intermediate proxy (nginx) not to buffer.
|
||||
const headers = new Headers();
|
||||
headers.set('Content-Type', backendRes.headers.get('content-type') ?? 'text/event-stream');
|
||||
headers.set('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
headers.set('X-Accel-Buffering', 'no');
|
||||
// Do NOT set Connection: keep-alive — it is HTTP/1.1 default for persistent
|
||||
// connections and can confuse proxies that do not expect streaming.
|
||||
return new Response(backendRes.body, {
|
||||
status: backendRes.status,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
const data = await backendRes.json().catch(() => null);
|
||||
const resp = NextResponse.json(data ?? { error: 'Bad response' }, { status: backendRes.status });
|
||||
return resp;
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Proxy error' }, { status: 502 });
|
||||
}
|
||||
const PROXY_TIMEOUT_MS = 25_000;
|
||||
const REQUEST_ID_HEADER = 'x-request-id';
|
||||
|
||||
interface ProxyInit {
|
||||
method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
|
||||
/** Raw body, or `null` when there is no body to forward. */
|
||||
body: string | null;
|
||||
headers: Record<string, string>;
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const path = req.nextUrl.pathname.replace('/api/', '');
|
||||
const search = req.nextUrl.search;
|
||||
const cookies = req.headers.get('cookie') ?? '';
|
||||
const body = await req.text();
|
||||
interface ProxyResult {
|
||||
status: number;
|
||||
headers: Headers;
|
||||
body: ReadableStream | null;
|
||||
}
|
||||
|
||||
async function proxyToBackend(
|
||||
request: NextRequest,
|
||||
init: ProxyInit,
|
||||
): Promise<ProxyResult> {
|
||||
const path = request.nextUrl.pathname.replace('/api/', '');
|
||||
const search = request.nextUrl.search;
|
||||
const url = `${API}/${path}${search}`;
|
||||
|
||||
// Forward only the cookies the proxy knows are safe to relay. We deliberately
|
||||
// do NOT pass `content-length` (Node fetch sets it from `body`), and we keep
|
||||
// `content-type` only when we actually have a body to send — otherwise some
|
||||
// upstreams reject the request or return 502 on bodyless PATCH/POST calls.
|
||||
const headers: Record<string, string> = {
|
||||
Cookie: init.headers.Cookie ?? '',
|
||||
[REQUEST_ID_HEADER]: init.requestId,
|
||||
};
|
||||
if (init.body !== null) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), PROXY_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = { Cookie: cookies };
|
||||
if (body) headers['Content-Type'] = 'application/json';
|
||||
const backendRes = await fetch(`${API}/${path}${search}`, {
|
||||
method: 'POST',
|
||||
const backendRes = await fetch(url, {
|
||||
method: init.method,
|
||||
headers,
|
||||
...(body ? { body } : {}),
|
||||
body: init.body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const responseHeaders = new Headers();
|
||||
const contentType = backendRes.headers.get('content-type');
|
||||
if (contentType) responseHeaders.set('Content-Type', contentType);
|
||||
const setCookie = backendRes.headers.get('set-cookie');
|
||||
const data = await backendRes.json().catch(() => null);
|
||||
const resp = NextResponse.json(data ?? { error: 'Bad response' }, { status: backendRes.status });
|
||||
if (setCookie) {
|
||||
resp.headers.set(
|
||||
responseHeaders.set(
|
||||
'Set-Cookie',
|
||||
setCookie.replace(/;\s*Secure/gi, '').replace(/;\s*SameSite=Lax/gi, '').trim(),
|
||||
);
|
||||
}
|
||||
return resp;
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Proxy error' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const path = req.nextUrl.pathname.replace('/api/', '');
|
||||
const search = req.nextUrl.search;
|
||||
const cookies = req.headers.get('cookie') ?? '';
|
||||
const body = await req.text();
|
||||
try {
|
||||
const backendRes = await fetch(`${API}/${path}${search}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', Cookie: cookies },
|
||||
body,
|
||||
});
|
||||
const data = await backendRes.json().catch(() => null);
|
||||
return NextResponse.json(data ?? { error: 'Bad response' }, { status: backendRes.status });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Proxy error' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
const path = req.nextUrl.pathname.replace('/api/', '');
|
||||
const search = req.nextUrl.search;
|
||||
const cookies = req.headers.get('cookie') ?? '';
|
||||
const body = await req.text();
|
||||
try {
|
||||
const backendRes = await fetch(`${API}/${path}${search}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', Cookie: cookies },
|
||||
body,
|
||||
});
|
||||
const data = await backendRes.json().catch(() => null);
|
||||
return NextResponse.json(data ?? { error: 'Bad response' }, { status: backendRes.status });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Proxy error' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
const path = req.nextUrl.pathname.replace('/api/', '');
|
||||
const search = req.nextUrl.search;
|
||||
const cookies = req.headers.get('cookie') ?? '';
|
||||
try {
|
||||
const backendRes = await fetch(`${API}/${path}${search}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Cookie: cookies },
|
||||
});
|
||||
// Reenviar la respuesta del backend tal cual: el backend puede devolver
|
||||
// 204 No Content (sin body) en borrados exitosos, y forzar un JSON con
|
||||
// status 204 es HTTP inválido (F-127).
|
||||
return new Response(backendRes.body, {
|
||||
return {
|
||||
status: backendRes.status,
|
||||
statusText: backendRes.statusText,
|
||||
headers: { 'Content-Length': backendRes.headers.get('content-length') ?? '0' },
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Proxy error' }, { status: 502 });
|
||||
headers: responseHeaders,
|
||||
body: backendRes.body,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the incoming request body safely. Returns `null` for bodyless methods
|
||||
* (GET/DELETE) and an empty string when the body is empty.
|
||||
*/
|
||||
async function safeReadBody(request: NextRequest): Promise<string | null> {
|
||||
if (request.method === 'GET' || request.method === 'DELETE' || request.method === 'HEAD') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return await request.text();
|
||||
} catch (error) {
|
||||
console.warn('[proxy] failed to read body', { message: (error as Error)?.message });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getRequestId(request: NextRequest): string {
|
||||
return (
|
||||
request.headers.get(REQUEST_ID_HEADER) ??
|
||||
request.headers.get('x-vercel-id') ??
|
||||
`adm-${Math.random().toString(36).slice(2, 10)}`
|
||||
);
|
||||
}
|
||||
|
||||
function logProxyFailure(stage: string, request: NextRequest, requestId: string, error: unknown) {
|
||||
console.warn('[proxy] failure', {
|
||||
stage,
|
||||
requestId,
|
||||
method: request.method,
|
||||
target: request.nextUrl.pathname,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
name: error instanceof Error ? error.name : 'unknown',
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const requestId = getRequestId(request);
|
||||
try {
|
||||
const result = await proxyToBackend(request, {
|
||||
method: 'GET',
|
||||
body: null,
|
||||
headers: { Cookie: request.headers.get('cookie') ?? '' },
|
||||
requestId,
|
||||
});
|
||||
if (request.nextUrl.pathname === 'admin/logs/stream') {
|
||||
result.headers.set('Content-Type', result.headers.get('content-type') ?? 'text/event-stream');
|
||||
result.headers.set('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
result.headers.set('X-Accel-Buffering', 'no');
|
||||
}
|
||||
return new Response(result.body, { status: result.status, headers: result.headers });
|
||||
} catch (error) {
|
||||
logProxyFailure('get', request, requestId, error);
|
||||
return NextResponse.json(
|
||||
{ error: { code: 'PROXY_ERROR', message: 'Proxy error', requestId } },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = getRequestId(request);
|
||||
try {
|
||||
const body = await safeReadBody(request);
|
||||
const result = await proxyToBackend(request, {
|
||||
method: 'POST',
|
||||
body,
|
||||
headers: { Cookie: request.headers.get('cookie') ?? '' },
|
||||
requestId,
|
||||
});
|
||||
return new Response(result.body, { status: result.status, headers: result.headers });
|
||||
} catch (error) {
|
||||
logProxyFailure('post', request, requestId, error);
|
||||
return NextResponse.json(
|
||||
{ error: { code: 'PROXY_ERROR', message: 'Proxy error', requestId } },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
const requestId = getRequestId(request);
|
||||
try {
|
||||
const body = await safeReadBody(request);
|
||||
const result = await proxyToBackend(request, {
|
||||
method: 'PATCH',
|
||||
body,
|
||||
headers: { Cookie: request.headers.get('cookie') ?? '' },
|
||||
requestId,
|
||||
});
|
||||
return new Response(result.body, { status: result.status, headers: result.headers });
|
||||
} catch (error) {
|
||||
logProxyFailure('patch', request, requestId, error);
|
||||
return NextResponse.json(
|
||||
{ error: { code: 'PROXY_ERROR', message: 'Proxy error', requestId } },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
const requestId = getRequestId(request);
|
||||
try {
|
||||
const body = await safeReadBody(request);
|
||||
const result = await proxyToBackend(request, {
|
||||
method: 'PUT',
|
||||
body,
|
||||
headers: { Cookie: request.headers.get('cookie') ?? '' },
|
||||
requestId,
|
||||
});
|
||||
return new Response(result.body, { status: result.status, headers: result.headers });
|
||||
} catch (error) {
|
||||
logProxyFailure('put', request, requestId, error);
|
||||
return NextResponse.json(
|
||||
{ error: { code: 'PROXY_ERROR', message: 'Proxy error', requestId } },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const requestId = getRequestId(request);
|
||||
try {
|
||||
const result = await proxyToBackend(request, {
|
||||
method: 'DELETE',
|
||||
body: null,
|
||||
headers: { Cookie: request.headers.get('cookie') ?? '' },
|
||||
requestId,
|
||||
});
|
||||
return new Response(result.body, { status: result.status, headers: result.headers });
|
||||
} catch (error) {
|
||||
logProxyFailure('delete', request, requestId, error);
|
||||
return NextResponse.json(
|
||||
{ error: { code: 'PROXY_ERROR', message: 'Proxy error', requestId } },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
4
project/apps/pos/package-lock.json
generated
4
project/apps/pos/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mercadodevida-pos",
|
||||
"version": "0.2.4",
|
||||
"version": "0.2.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mercadodevida-pos",
|
||||
"version": "0.2.4",
|
||||
"version": "0.2.8",
|
||||
"dependencies": {
|
||||
"next": "^16.3.1",
|
||||
"react": "^19.2.8",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mercadodevida-pos",
|
||||
"version": "0.2.4",
|
||||
"version": "0.2.8",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --port 3002",
|
||||
|
||||
@@ -1659,14 +1659,16 @@ export default function RegisterPage() {
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void parkSale()}
|
||||
disabled={processing || cart.length === 0}
|
||||
className="min-h-16 rounded-xl bg-amber-500 text-lg font-bold text-white disabled:opacity-40"
|
||||
>
|
||||
{processing ? 'Guardando…' : 'Guardar pendiente'}
|
||||
</button>
|
||||
{!isSelfpayMode && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void parkSale()}
|
||||
disabled={processing || cart.length === 0}
|
||||
className="min-h-16 rounded-xl bg-amber-500 text-lg font-bold text-white disabled:opacity-40"
|
||||
>
|
||||
{processing ? 'Guardando…' : 'Guardar pendiente'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void confirmSale()}
|
||||
@@ -1677,7 +1679,9 @@ export default function RegisterPage() {
|
||||
paidCents <= 0 ||
|
||||
paidCents > totals.total
|
||||
}
|
||||
className="min-h-16 rounded-xl bg-[#1B4332] text-lg font-bold text-white disabled:opacity-40"
|
||||
className={`min-h-16 rounded-xl bg-[#1B4332] text-lg font-bold text-white disabled:opacity-40 ${
|
||||
isSelfpayMode ? 'col-span-2' : ''
|
||||
}`}
|
||||
>
|
||||
{processing ? 'Confirmando…' : 'Cobrar e imprimir'}
|
||||
</button>
|
||||
@@ -1688,7 +1692,7 @@ export default function RegisterPage() {
|
||||
disabled={processing || cart.length === 0}
|
||||
className="mt-2 w-full rounded-xl border border-gray-300 bg-white py-2 text-sm font-bold text-red-600 disabled:opacity-40"
|
||||
>
|
||||
Vaciar caja
|
||||
{isSelfpayMode ? '🧹 Limpiar Ticket' : 'Vaciar caja'}
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
|
||||
4
project/frontend/package-lock.json
generated
4
project/frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.2.4",
|
||||
"version": "0.2.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "0.2.4",
|
||||
"version": "0.2.8",
|
||||
"dependencies": {
|
||||
"next": "16.3.1",
|
||||
"react": "19.2.8",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.2.4",
|
||||
"version": "0.2.8",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
@@ -201,6 +201,7 @@ export default async function ProductPage({ params }: Props) {
|
||||
priceCents={grossCents}
|
||||
imageUrl={product.images?.[0]?.url}
|
||||
available={true}
|
||||
availableQuantity={stock.availableQuantity}
|
||||
minPurchaseQty={product.minPurchaseQty ?? 1}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCart } from '@/contexts/CartContext';
|
||||
import { fetchStockAvailability } from '@/lib/api';
|
||||
|
||||
interface Props {
|
||||
variantId: string;
|
||||
@@ -10,24 +11,48 @@ interface Props {
|
||||
imageUrl?: string;
|
||||
available?: boolean;
|
||||
minPurchaseQty?: number;
|
||||
/** F-138: max units available right now. Used to cap the add-to-cart action. */
|
||||
availableQuantity?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function AddToCartButton({
|
||||
variantId, productId, productName, priceCents, imageUrl, available = true, minPurchaseQty = 1, className = '',
|
||||
variantId, productId, productName, priceCents, imageUrl, available = true, minPurchaseQty = 1, availableQuantity, className = '',
|
||||
}: Props) {
|
||||
const { addItem, itemCount } = useCart();
|
||||
const { addItem, items } = useCart();
|
||||
const [added, setAdded] = useState(false);
|
||||
const [liveStock, setLiveStock] = useState<number | null>(availableQuantity ?? null);
|
||||
const qty = Math.max(1, minPurchaseQty);
|
||||
|
||||
// Refresh stock so the button reflects reality (especially on stale tabs).
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchStockAvailability(variantId)
|
||||
.then((s) => {
|
||||
if (!cancelled) setLiveStock(s.availableQuantity);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setLiveStock(null);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [variantId]);
|
||||
|
||||
// Account for whatever the user already has in the cart for this variant.
|
||||
const inCart = items.find((it) => it.variantId === variantId)?.quantity ?? 0;
|
||||
const stockCap = liveStock ?? null;
|
||||
const remaining = stockCap === null ? null : Math.max(0, stockCap - inCart);
|
||||
const disabled = !available || (remaining !== null && remaining < qty);
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!available) return;
|
||||
if (disabled) return;
|
||||
addItem({ variantId, productId, productName, quantity: qty, priceCents, imageUrl, minPurchaseQty: qty });
|
||||
setAdded(true);
|
||||
setTimeout(() => setAdded(false), 2000);
|
||||
};
|
||||
|
||||
if (!available) {
|
||||
if (!available || remaining === 0) {
|
||||
return (
|
||||
<button disabled className={`px-8 py-3.5 bg-gray-200 text-gray-500 font-semibold rounded-xl cursor-not-allowed ${className}`}>
|
||||
Agotado
|
||||
@@ -47,9 +72,10 @@ export default function AddToCartButton({
|
||||
<div>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
className={`px-8 py-3.5 bg-[#70ad47] hover:bg-[#5a9040] text-white font-semibold rounded-xl transition-colors shadow-lg ${className}`}
|
||||
disabled={disabled}
|
||||
className={`px-8 py-3.5 bg-[#70ad47] hover:bg-[#5a9040] text-white font-semibold rounded-xl transition-colors shadow-lg disabled:bg-gray-300 disabled:cursor-not-allowed ${className}`}
|
||||
>
|
||||
Añadir al carrito{qty > 1 ? ` (${qty} uds.)` : ''}
|
||||
{disabled && remaining !== null ? `Solo ${remaining} uds.` : `Añadir al carrito${qty > 1 ? ` (${qty} uds.)` : ''}`}
|
||||
</button>
|
||||
{qty > 1 && (
|
||||
<p className="mt-2 text-xs text-gray-500">Compra mínima: {qty} unidades.</p>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
'use client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useCart, type CartItem } from '@/contexts/CartContext';
|
||||
import { fetchStockAvailability } from '@/lib/api';
|
||||
|
||||
function formatPrice(cents: number) {
|
||||
return `€${(cents / 100).toFixed(2)}`;
|
||||
@@ -9,6 +11,33 @@ function formatPrice(cents: number) {
|
||||
|
||||
function CartItemRow({ item }: { item: CartItem }) {
|
||||
const { removeItem, changeQuantity } = useCart();
|
||||
const [stockMax, setStockMax] = useState<number | null>(null);
|
||||
|
||||
// F-138: cap the + button to actual stock so the buyer can never load more
|
||||
// units than the SKU allows.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchStockAvailability(item.variantId)
|
||||
.then((s) => {
|
||||
if (!cancelled) setStockMax(s.availableQuantity);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setStockMax(null);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [item.variantId]);
|
||||
|
||||
const atStockMax = stockMax !== null && item.quantity >= stockMax;
|
||||
const stockLabel =
|
||||
stockMax === null
|
||||
? null
|
||||
: stockMax === 0
|
||||
? 'Sin stock'
|
||||
: stockMax <= 3
|
||||
? `Quedan ${stockMax} uds.`
|
||||
: `Stock: ${stockMax} uds.`;
|
||||
|
||||
return (
|
||||
<div className="flex gap-4 py-4 border-b border-gray-100 last:border-0">
|
||||
@@ -49,8 +78,18 @@ function CartItemRow({ item }: { item: CartItem }) {
|
||||
</button>
|
||||
<span className="w-8 text-center text-sm font-medium">{item.quantity}</span>
|
||||
<button
|
||||
onClick={() => changeQuantity(item.variantId, item.quantity + 1)}
|
||||
className="w-8 h-8 flex items-center justify-center text-gray-600 hover:text-[#70ad47] transition-colors"
|
||||
onClick={() => {
|
||||
if (stockMax === null) {
|
||||
changeQuantity(item.variantId, item.quantity + 1);
|
||||
return;
|
||||
}
|
||||
// Hard cap: never go above stockMax and never below 1.
|
||||
const next = Math.min(stockMax, item.quantity + 1);
|
||||
if (next > item.quantity) changeQuantity(item.variantId, next);
|
||||
}}
|
||||
disabled={atStockMax}
|
||||
title={atStockMax ? `Solo hay ${stockMax} uds. disponibles` : undefined}
|
||||
className="w-8 h-8 flex items-center justify-center text-gray-600 hover:text-[#70ad47] transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
@@ -62,6 +101,11 @@ function CartItemRow({ item }: { item: CartItem }) {
|
||||
Eliminar
|
||||
</button>
|
||||
</div>
|
||||
{stockLabel && (
|
||||
<p className={`mt-1 text-xs ${atStockMax ? 'text-amber-600' : 'text-gray-500'}`}>
|
||||
{stockLabel}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Subtotal */}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import AddToCartButton from './AddToCartButton';
|
||||
|
||||
interface Props {
|
||||
@@ -9,10 +8,12 @@ interface Props {
|
||||
priceCents: number;
|
||||
imageUrl?: string;
|
||||
available: boolean;
|
||||
/** F-138: available stock for the variant. */
|
||||
availableQuantity?: number;
|
||||
minPurchaseQty?: number;
|
||||
}
|
||||
|
||||
export default function ProductAddToCart({ variantId, productId, productName, priceCents, imageUrl, available, minPurchaseQty }: Props) {
|
||||
export default function ProductAddToCart({ variantId, productId, productName, priceCents, imageUrl, available, availableQuantity, minPurchaseQty }: Props) {
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<AddToCartButton
|
||||
@@ -22,6 +23,7 @@ export default function ProductAddToCart({ variantId, productId, productName, pr
|
||||
priceCents={priceCents}
|
||||
imageUrl={imageUrl}
|
||||
available={available}
|
||||
availableQuantity={availableQuantity}
|
||||
minPurchaseQty={minPurchaseQty}
|
||||
className="w-full sm:w-auto"
|
||||
/>
|
||||
|
||||
20
project/migrations/064_orders_history_metadata.js
Normal file
20
project/migrations/064_orders_history_metadata.js
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* F-138: orders_order_history.metadata holds the structured payload for
|
||||
* machine-readable events (returns, refunds, etc.) so we can keep the
|
||||
* human-visible `message` column short and friendly while preserving all the
|
||||
* detail that internal tooling needs.
|
||||
* @param {import('node-pg-migrate').MigrationBuilder} pgm
|
||||
*/
|
||||
export const up = (pgm) => {
|
||||
pgm.sql(`
|
||||
ALTER TABLE orders_order_history
|
||||
ADD COLUMN IF NOT EXISTS metadata jsonb NULL
|
||||
`);
|
||||
};
|
||||
|
||||
export const down = (pgm) => {
|
||||
pgm.sql(`
|
||||
ALTER TABLE orders_order_history
|
||||
DROP COLUMN IF EXISTS metadata
|
||||
`);
|
||||
};
|
||||
65
project/migrations/065_backfill_return_history_messages.js
Normal file
65
project/migrations/065_backfill_return_history_messages.js
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* F-138 backfill: convert any legacy `orders_order_history` refund entries that
|
||||
* were stored as raw JSON `message` strings into the new human-readable format
|
||||
* introduced by `apply-pos-return.ts`. We also move the original payload into
|
||||
* the new `metadata` column so internal tooling still has the structured data.
|
||||
* @param {import('node-pg-migrate').MigrationBuilder} pgm
|
||||
*/
|
||||
export const up = (pgm) => {
|
||||
pgm.sql(`
|
||||
WITH parsed AS (
|
||||
SELECT id,
|
||||
(message::jsonb) AS payload,
|
||||
(
|
||||
CASE (message::jsonb->>'status')
|
||||
WHEN 'refund' THEN 'Devolución total'
|
||||
ELSE 'Devolución parcial'
|
||||
END
|
||||
) AS action
|
||||
FROM orders_order_history
|
||||
WHERE event_type = 'RETURNED'
|
||||
AND message ~ '^\\s*\\{'
|
||||
),
|
||||
formatted AS (
|
||||
SELECT id,
|
||||
action || ' de ' ||
|
||||
(round(((payload->>'refundedCents')::numeric) / 100)::numeric(12,2)::text) ||
|
||||
' €' ||
|
||||
COALESCE(
|
||||
(SELECT ' — ' || string_agg(
|
||||
((l->>'returnedQuantity')::int)::text || ' × ' ||
|
||||
COALESCE(oi.name, 'Artículo'),
|
||||
', '
|
||||
ORDER BY ord)
|
||||
FROM jsonb_array_elements(payload->'lines') WITH ORDINALITY AS x(l, ord)
|
||||
LEFT JOIN orders_items oi ON oi.id = (l->>'orderItemId')::uuid
|
||||
WHERE (l->>'returnedQuantity')::int > 0
|
||||
),
|
||||
''
|
||||
) ||
|
||||
CASE
|
||||
WHEN NULLIF(payload->>'reason', '') IS NOT NULL
|
||||
THEN ' (motivo: ' || (payload->>'reason') || ')'
|
||||
ELSE ''
|
||||
END AS readable
|
||||
FROM parsed
|
||||
)
|
||||
UPDATE orders_order_history h
|
||||
SET message = f.readable,
|
||||
metadata = h.message::jsonb
|
||||
FROM formatted f
|
||||
WHERE h.id = f.id
|
||||
`);
|
||||
};
|
||||
|
||||
export const down = (pgm) => {
|
||||
// Best-effort: restore the JSON message from metadata. We can't know whether
|
||||
// a row was originally a JSON message or already human-readable, so the
|
||||
// down migration just clears the column.
|
||||
pgm.sql(`
|
||||
UPDATE orders_order_history
|
||||
SET message = metadata::text,
|
||||
metadata = NULL
|
||||
WHERE event_type = 'RETURNED' AND metadata IS NOT NULL
|
||||
`);
|
||||
};
|
||||
4
project/package-lock.json
generated
4
project/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mercadodevida-backend",
|
||||
"version": "0.2.4",
|
||||
"version": "0.2.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mercadodevida-backend",
|
||||
"version": "0.2.4",
|
||||
"version": "0.2.8",
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.1.2",
|
||||
"@fastify/cors": "^11.3.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mercadodevida-backend",
|
||||
"version": "0.2.4",
|
||||
"version": "0.2.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "mercadodevida vNext backend - modular monolith skeleton",
|
||||
|
||||
@@ -105,28 +105,52 @@ describe.skipIf(!hasDb)('cart flows (real PostgreSQL)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('flags cart item unavailable when variant is out of stock (AC2)', async () => {
|
||||
it('rejects add when stock is zero (AC2 — F-138)', async () => {
|
||||
const productId = randomUUID();
|
||||
const variantId = randomUUID();
|
||||
await setPrice(variantId, 500);
|
||||
await setStock(variantId, 0);
|
||||
await app.inject({
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/cart/items',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
||||
payload: { productId, variantId, quantity: 1 },
|
||||
});
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json()).toMatchObject({ error: { code: 'INSUFFICIENT_STOCK' } });
|
||||
|
||||
const cart = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/cart',
|
||||
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
||||
});
|
||||
const item = (cart.json().items as Array<{ variantId: string; available: boolean }>).find(
|
||||
(entry) => entry.variantId === variantId,
|
||||
);
|
||||
expect(item).toMatchObject({ available: false });
|
||||
const items = cart.json().items as Array<{ variantId: string }>;
|
||||
expect(items.find((entry) => entry.variantId === variantId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects add when total quantity exceeds stock (AC2b — F-138)', async () => {
|
||||
const productId = randomUUID();
|
||||
const variantId = randomUUID();
|
||||
await setPrice(variantId, 500);
|
||||
await setStock(variantId, 3);
|
||||
const first = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/cart/items',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
||||
payload: { productId, variantId, quantity: 2 },
|
||||
});
|
||||
expect(first.statusCode).toBe(201);
|
||||
const oversell = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/cart/items',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
||||
payload: { productId, variantId, quantity: 2 },
|
||||
});
|
||||
expect(oversell.statusCode).toBe(409);
|
||||
expect(oversell.json()).toMatchObject({ error: { code: 'INSUFFICIENT_STOCK' } });
|
||||
});
|
||||
|
||||
it('ignores client-supplied price fields in cart payloads (AC3)', async () => {
|
||||
|
||||
@@ -19,8 +19,16 @@ export class CartService {
|
||||
|
||||
async addItem(userId: string, input: CartItemInput): Promise<CartView> {
|
||||
ensurePositiveQuantity(input.quantity);
|
||||
// Keep unavailable lines visible in the cart so the customer can remove or
|
||||
// replace them; checkout remains the authoritative stock gate.
|
||||
// F-138: stock cap is enforced on every cart mutation. The previous
|
||||
// behaviour ("keep unavailable lines visible so the buyer can remove
|
||||
// them") allowed overselling in the online storefront: customers could
|
||||
// load 50 units of a 3-unit SKU into the cart and only failed at checkout.
|
||||
// Cap the resulting quantity to whatever is actually in stock and surface
|
||||
// the available amount to the caller via the standard error.
|
||||
const cart = await this.carts.getOrCreate(userId);
|
||||
const existing = cart.items.find((item) => item.variantId === input.variantId);
|
||||
const requested = (existing?.quantity ?? 0) + input.quantity;
|
||||
await this.assertStockAvailable(input.variantId, requested);
|
||||
return this.toView(await this.carts.addItem(userId, input));
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,31 @@ async function assertReceiptAccess(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves which store the admin POS settings page should target.
|
||||
*
|
||||
* Accepts the explicit `storeId` query value when present and well-formed;
|
||||
* otherwise falls back to the first active store. This keeps the admin
|
||||
* dashboard recoverable when the user lands on the page before the store
|
||||
* dropdown is hydrated or the dropdown value is somehow invalid (F-139).
|
||||
*/
|
||||
async function resolveStoreIdForReceipt(
|
||||
pool: pg.Pool,
|
||||
rawStoreId: string | undefined,
|
||||
): Promise<string> {
|
||||
if (rawStoreId && storeIdSchema.safeParse(rawStoreId).success) {
|
||||
return rawStoreId;
|
||||
}
|
||||
const fallback = await pool.query<{ id: string }>(
|
||||
`SELECT id FROM pos_stores WHERE active = true ORDER BY created_at ASC LIMIT 1`,
|
||||
);
|
||||
const id = fallback.rows[0]?.id;
|
||||
if (!id) {
|
||||
throw new AppError(404, 'NO_ACTIVE_STORE', 'No hay tiendas activas configuradas');
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps) {
|
||||
const { pool, authenticate } = deps;
|
||||
|
||||
@@ -1208,8 +1233,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
|
||||
summary: 'Get company and receipt numbering settings',
|
||||
querystring: {
|
||||
type: 'object',
|
||||
required: ['storeId'],
|
||||
properties: { storeId: { type: 'string', format: 'uuid' } },
|
||||
properties: { storeId: { type: 'string' } },
|
||||
},
|
||||
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||
} as FastifySchema,
|
||||
@@ -1217,7 +1241,11 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
|
||||
async (request, reply) => {
|
||||
const user = await authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { storeId } = parseJson(z.object({ storeId: storeIdSchema }), request.query);
|
||||
const rawStoreId = (request.query as { storeId?: string }).storeId;
|
||||
// F-139: the admin POS page can land before the store dropdown has a
|
||||
// selection. Fall back to the first active store so the UI never sees a
|
||||
// 400 caused by an empty querystring.
|
||||
const storeId = await resolveStoreIdForReceipt(pool, rawStoreId);
|
||||
await pool.query(
|
||||
`INSERT INTO pos_receipt_settings (store_id) VALUES ($1)
|
||||
ON CONFLICT (store_id) DO NOTHING`,
|
||||
|
||||
@@ -15,6 +15,7 @@ interface OrderRow {
|
||||
|
||||
interface OrderItemRow {
|
||||
id: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
returned_quantity: number;
|
||||
is_free_item: boolean;
|
||||
@@ -131,7 +132,7 @@ export class ApplyPosReturnUseCase {
|
||||
}
|
||||
|
||||
const itemRows = await client.query<OrderItemRow>(
|
||||
`SELECT id, quantity, returned_quantity, is_free_item, variant_id,
|
||||
`SELECT id, name, quantity, returned_quantity, is_free_item, variant_id,
|
||||
unit_price_cents, discount_cents, tax_cents
|
||||
FROM orders_items
|
||||
WHERE order_id = $1
|
||||
@@ -315,10 +316,18 @@ export class ApplyPosReturnUseCase {
|
||||
],
|
||||
);
|
||||
await client.query(
|
||||
`INSERT INTO orders_order_history (order_id, event_type, message, actor_email)
|
||||
VALUES ($1, 'RETURNED', $2, (SELECT email FROM backoffice_users WHERE id = $3))`,
|
||||
`INSERT INTO orders_order_history (order_id, event_type, message, actor_email, metadata)
|
||||
VALUES ($1, 'RETURNED', $2, (SELECT email FROM backoffice_users WHERE id = $3), $4)`,
|
||||
[
|
||||
input.orderId,
|
||||
formatReturnMessage({
|
||||
refundedCents: totalRefundCents,
|
||||
status: reportingStatus,
|
||||
lines: input.lines,
|
||||
reason: input.reason,
|
||||
items: itemsById,
|
||||
}),
|
||||
input.userId,
|
||||
JSON.stringify({
|
||||
returnId,
|
||||
refundedCents: totalRefundCents,
|
||||
@@ -326,7 +335,6 @@ export class ApplyPosReturnUseCase {
|
||||
lines: input.lines,
|
||||
reason: input.reason,
|
||||
}),
|
||||
input.userId,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -366,3 +374,38 @@ export class ApplyPosReturnUseCase {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface FormatReturnMessageInput {
|
||||
refundedCents: number;
|
||||
status: 'refund' | 'partial_refund';
|
||||
lines: ReadonlyArray<{ orderItemId: string; returnedQuantity: number }>;
|
||||
reason: string;
|
||||
items: ReadonlyMap<string, OrderItemRow>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a refund event as a short, human-readable line for the order
|
||||
* timeline. We deliberately do NOT dump the raw payload here: customers and
|
||||
* back-office staff see `event.message` and the JSON was unreadable.
|
||||
* Structured fields are still stored on `orders_order_history.metadata` so
|
||||
* support tooling can read them when needed.
|
||||
*/
|
||||
function formatReturnMessage(input: FormatReturnMessageInput): string {
|
||||
const euros = (input.refundedCents / 100).toFixed(2);
|
||||
const action = input.status === 'refund' ? 'Devolución total' : 'Devolución parcial';
|
||||
const lineDescriptions = input.lines
|
||||
.filter((line) => line.returnedQuantity > 0)
|
||||
.map((line) => {
|
||||
const item = input.items.get(line.orderItemId);
|
||||
const name = item?.name ?? 'Artículo';
|
||||
return `${line.returnedQuantity} × ${name}`;
|
||||
});
|
||||
const linesSummary =
|
||||
lineDescriptions.length === 0
|
||||
? ''
|
||||
: ` — ${lineDescriptions.slice(0, 3).join(', ')}${
|
||||
lineDescriptions.length > 3 ? ` y ${lineDescriptions.length - 3} más` : ''
|
||||
}`;
|
||||
const reason = input.reason?.trim();
|
||||
return `${action} de ${euros} €${linesSummary}${reason ? ` (motivo: ${reason})` : ''}`;
|
||||
}
|
||||
|
||||
4
project/storefront/package-lock.json
generated
4
project/storefront/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mercadodevida-storefront",
|
||||
"version": "0.2.4",
|
||||
"version": "0.2.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mercadodevida-storefront",
|
||||
"version": "0.2.4",
|
||||
"version": "0.2.8",
|
||||
"dependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
"next": "^16.0.5",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mercadodevida-storefront",
|
||||
"version": "0.2.4",
|
||||
"version": "0.2.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "mercadodevida customer storefront shell",
|
||||
|
||||
@@ -62,7 +62,7 @@ export function AddToCart({ productId, productName, unitPriceCents, imageUrl }:
|
||||
const existing = items.find((it) => it.variantId === productId);
|
||||
const newQty = existing ? existing.quantity + qty : qty;
|
||||
if (newQty > stock) {
|
||||
setError(`Solo hay ${stock} unidades disponibles.`);
|
||||
setError(`Solo hay ${stock} unidades disponibles en total (ya tienes ${existing?.quantity ?? 0} en el carrito).`);
|
||||
return;
|
||||
}
|
||||
const next = existing
|
||||
@@ -93,7 +93,16 @@ export function AddToCart({ productId, productName, unitPriceCents, imageUrl }:
|
||||
min={1}
|
||||
max={stock}
|
||||
value={qty}
|
||||
onChange={(e) => setQty(Math.max(1, parseInt(e.target.value, 10) || 1))}
|
||||
onChange={(e) => {
|
||||
const parsed = parseInt(e.target.value, 10);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
setQty(1);
|
||||
return;
|
||||
}
|
||||
// F-138: never let the buyer enter a quantity outside [1, stock].
|
||||
const clamped = Math.max(1, Math.min(stock, parsed));
|
||||
setQty(clamped);
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="w-24 rounded-lg border border-stone-300 px-3 py-2 text-sm focus:ring-2 focus:ring-emerald-700 outline-none disabled:opacity-50"
|
||||
/>
|
||||
|
||||
@@ -8,7 +8,7 @@ export function ProductCard({ product }: Readonly<{ product: ProductSummaryDto }
|
||||
href={product.url}
|
||||
className="group block overflow-hidden rounded-3xl border border-emerald-900/10 bg-white shadow-sm transition hover:-translate-y-0.5 hover:border-emerald-700"
|
||||
>
|
||||
<div className="flex w-full max-h-72 items-center justify-center bg-emerald-50 text-sm text-emerald-900 overflow-hidden">
|
||||
<div className="flex w-full max-h-72 items-center justify-center bg-white text-sm text-emerald-900 overflow-hidden">
|
||||
{mainImage ? (
|
||||
// Keep plain img for remote/local URL compatibility until image pipeline configuration exists.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
# Estado Actual
|
||||
|
||||
## Ninguna feature activa
|
||||
## Sesión anterior completada: 2026-08-25
|
||||
|
||||
**Última feature cerrada:** `FRONTEND-UI-FIXES2` (ya estaba `done` en backlog)
|
||||
### Features cerradas
|
||||
- TPV-FIXES (3/3 bugs) ✅
|
||||
- TICKET-LOGO ✅
|
||||
- ORDERS-FIX ✅
|
||||
|
||||
Para iniciar una nueva feature:
|
||||
1. Seleccionar una feature `pending` de `backlog/features.json`
|
||||
2. Actualizar estado con `python3 scripts/agent_status.py set --feature-id <id> --stage intake --agent leader`
|
||||
3. Continuar con `harness/workflow.stages.yml`
|
||||
### Pendientes
|
||||
- **INVENTORY-OPT**: Inventory optimization (pagination, queries, filters for 10k+ products)
|
||||
- **SHIPPING-ZONES**: Restrict Balearic and Canary islands, add continental...
|
||||
|
||||
---
|
||||
### Issue pendiente de resolver
|
||||
- **TPV stock**: Tema de stock con items convertidos a free items necesita revisión más profunda
|
||||
|
||||
## Resumen backlog
|
||||
- Total features: 344
|
||||
- Features pendientes de iniciar: verificar con `rg '"status": "pending"' backlog/features.json | wc -l`
|
||||
## Para iniciar próxima sesión
|
||||
```bash
|
||||
./scripts/pi_orquestra.sh
|
||||
```
|
||||
Seleccionar INVENTORY-OPT o SHIPPING-ZONES del backlog.
|
||||
|
||||
@@ -1,152 +1,152 @@
|
||||
{
|
||||
"feature_id": "TPV-FIXES",
|
||||
"stage": "review_gate",
|
||||
"agent": "reviewer",
|
||||
"action": "Fixing favicon 404, cashier label, and pos/sales 400 error",
|
||||
"state": "done",
|
||||
"feature_id": "POS-RECEIPT-QUICK-FIXES",
|
||||
"stage": "build",
|
||||
"agent": "implementer",
|
||||
"action": "Fix receipt-settings 400 and quick products save",
|
||||
"state": "running",
|
||||
"next_agent": null,
|
||||
"waiting_for": "Seleccionar una feature pending y actualizar este estado",
|
||||
"updated_at": "2026-08-25T04:47:13Z",
|
||||
"updated_at": "2026-08-25T21:03:29Z",
|
||||
"timeline": [
|
||||
{
|
||||
"ts": "2026-08-25T04:27:58Z",
|
||||
"agent": "implementer",
|
||||
"stage": "build",
|
||||
"state": "idle",
|
||||
"message": "Estado actualizado"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-25T04:28:09Z",
|
||||
"agent": "implementer",
|
||||
"stage": "build",
|
||||
"state": "idle",
|
||||
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-25T04:28:15Z",
|
||||
"ts": "2026-08-25T19:46:39Z",
|
||||
"agent": "implementer",
|
||||
"stage": "build",
|
||||
"state": "running",
|
||||
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
|
||||
"message": "Rebuild dist for awaiting payment notification fix"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-25T04:30:08Z",
|
||||
"ts": "2026-08-25T19:52:55Z",
|
||||
"agent": "implementer",
|
||||
"stage": "build",
|
||||
"state": "done",
|
||||
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
|
||||
"message": "Redeploy local prod stack after awaiting-payment fix"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-25T04:30:24Z",
|
||||
"agent": "reviewer",
|
||||
"stage": "review_gate",
|
||||
"state": "done",
|
||||
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-25T04:30:35Z",
|
||||
"agent": "security",
|
||||
"stage": "security_gate",
|
||||
"state": "done",
|
||||
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-25T04:30:44Z",
|
||||
"agent": "qa",
|
||||
"stage": "qa_gate",
|
||||
"state": "done",
|
||||
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-25T04:31:04Z",
|
||||
"agent": "leader",
|
||||
"stage": "intake",
|
||||
"state": "done",
|
||||
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-25T04:31:58Z",
|
||||
"ts": "2026-08-25T19:53:50Z",
|
||||
"agent": "implementer",
|
||||
"stage": "build",
|
||||
"state": "running",
|
||||
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
|
||||
"message": "Fix monolith restart to stop old deployments before starting latest"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-25T04:35:08Z",
|
||||
"ts": "2026-08-25T19:59:53Z",
|
||||
"agent": "implementer",
|
||||
"stage": "build",
|
||||
"state": "done",
|
||||
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
|
||||
"message": "Fix monolith restart to remove old deployments and run latest version"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-25T04:35:09Z",
|
||||
"agent": "reviewer",
|
||||
"stage": "review_gate",
|
||||
"state": "done",
|
||||
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-25T04:35:18Z",
|
||||
"agent": "security",
|
||||
"stage": "security_gate",
|
||||
"state": "done",
|
||||
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-25T04:35:26Z",
|
||||
"agent": "qa",
|
||||
"stage": "qa_gate",
|
||||
"state": "done",
|
||||
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-25T04:35:43Z",
|
||||
"agent": "leader",
|
||||
"stage": "intake",
|
||||
"state": "done",
|
||||
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-25T04:37:19Z",
|
||||
"ts": "2026-08-25T20:00:40Z",
|
||||
"agent": "implementer",
|
||||
"stage": "build",
|
||||
"state": "running",
|
||||
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
|
||||
"message": "Add semantic versioning and conventional commit enforcement to Orquestra"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-25T04:37:57Z",
|
||||
"ts": "2026-08-25T20:04:01Z",
|
||||
"agent": "implementer",
|
||||
"stage": "build",
|
||||
"state": "done",
|
||||
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
|
||||
"message": "Add semantic versioning and conventional commit enforcement to Orquestra"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-25T04:37:57Z",
|
||||
"agent": "reviewer",
|
||||
"stage": "review_gate",
|
||||
"state": "done",
|
||||
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-25T04:45:38Z",
|
||||
"ts": "2026-08-25T20:04:58Z",
|
||||
"agent": "implementer",
|
||||
"stage": "build",
|
||||
"state": "running",
|
||||
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
|
||||
"message": "Add settings about section with service versions and server info"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-25T04:47:13Z",
|
||||
"ts": "2026-08-25T20:08:32Z",
|
||||
"agent": "implementer",
|
||||
"stage": "build",
|
||||
"state": "done",
|
||||
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
|
||||
"message": "Add settings about section with service versions and server info"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-25T04:47:13Z",
|
||||
"agent": "reviewer",
|
||||
"stage": "review_gate",
|
||||
"ts": "2026-08-25T20:12:55Z",
|
||||
"agent": "implementer",
|
||||
"stage": "build",
|
||||
"state": "running",
|
||||
"message": "Fix admin 500 errors for awaiting-payment and POS cash-close reports"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-25T20:16:24Z",
|
||||
"agent": "implementer",
|
||||
"stage": "build",
|
||||
"state": "done",
|
||||
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
|
||||
"message": "Fix admin 500 errors for awaiting-payment and POS cash-close reports"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-25T20:20:00Z",
|
||||
"agent": "implementer",
|
||||
"stage": "build",
|
||||
"state": "running",
|
||||
"message": "Fix reporting trend chart height and closures report display"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-25T20:24:31Z",
|
||||
"agent": "implementer",
|
||||
"stage": "build",
|
||||
"state": "done",
|
||||
"message": "Fix reporting trend chart height and closures report display"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-25T20:26:16Z",
|
||||
"agent": "implementer",
|
||||
"stage": "build",
|
||||
"state": "running",
|
||||
"message": "Hide TPV self-checkout menu/discounts and fix trend text distortion"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-25T20:30:04Z",
|
||||
"agent": "implementer",
|
||||
"stage": "build",
|
||||
"state": "done",
|
||||
"message": "Hide TPV self-checkout menu/discounts and fix trend text distortion"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-25T20:36:53Z",
|
||||
"agent": "implementer",
|
||||
"stage": "build",
|
||||
"state": "running",
|
||||
"message": "Remove duplicate ticket print header and force white print background"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-25T20:39:16Z",
|
||||
"agent": "implementer",
|
||||
"stage": "build",
|
||||
"state": "done",
|
||||
"message": "Remove duplicate ticket print header and force white print background"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-25T20:39:16Z",
|
||||
"agent": "implementer",
|
||||
"stage": "build",
|
||||
"state": "running",
|
||||
"message": "Diagnose PATCH /api/products/[id] 502 Bad Gateway"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-25T20:50:29Z",
|
||||
"agent": "implementer",
|
||||
"stage": "build",
|
||||
"state": "running",
|
||||
"message": "Fix storefront card bg, stock cap, return human readable, PATCH 502"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-25T20:54:10Z",
|
||||
"agent": "implementer",
|
||||
"stage": "build",
|
||||
"state": "running",
|
||||
"message": "Fix storefront card bg, stock cap, return human readable, PATCH 502, selfpay button rename"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-25T21:03:29Z",
|
||||
"agent": "implementer",
|
||||
"stage": "build",
|
||||
"state": "running",
|
||||
"message": "Fix receipt-settings 400 and quick products save"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user