fix: storefront live search relative URLs + auth error messages + TPV clock

- Storefront: use relative URLs in api.ts (fix ERR_ADDRESS_UNREACHABLE)
- Auth: show specific error for EMAIL_NOT_CONFIRMED
- TPV: add clock with date/time, modern SVG icons, panel toggle left
This commit is contained in:
chattie
2026-08-24 16:13:02 +02:00
parent 3d404de25c
commit 30a0cd3552
9 changed files with 163 additions and 21 deletions

View File

@@ -7744,6 +7744,38 @@
},
"phase": "pos",
"completed_at": "2026-08-24T13:51:08Z"
},
{
"id": "SHOP-STOCK",
"type": "bug",
"title": "Storefront: Añadir al carrito respeta stock",
"description": "Añadir al carrito se salta restriccion de stock. Si hay 2 en stock se puede pedir 1 o 2, pero no 3.",
"priority": "high",
"risk": "low",
"status": "pending",
"created_at": "2026-08-24",
"gates": {
"reviewer": false,
"security": false,
"qa": false
},
"phase": "storefront"
},
{
"id": "SHOP-AUTH",
"type": "bug",
"title": "Storefront: Login register mismatch",
"description": "Email already registered en register pero iniciar sesion dice password incorrecto cuando es correcta.",
"priority": "high",
"risk": "low",
"status": "pending",
"created_at": "2026-08-24",
"gates": {
"reviewer": false,
"security": false,
"qa": false
},
"phase": "storefront"
}
]
}

View File

@@ -147,6 +147,13 @@ export default function RegisterPage() {
toastTimer = setTimeout(() => setAddedToast(null), 2000);
};
// Clock state for TPV header
const [currentTime, setCurrentTime] = useState(new Date());
useEffect(() => {
const timer = setInterval(() => setCurrentTime(new Date()), 1000);
return () => clearInterval(timer);
}, []);
// POS-FIX-5: verify PIN then close session
const handleVerifyPin = async () => {
if (!config?.terminal) return;
@@ -1180,19 +1187,42 @@ export default function RegisterPage() {
<main className="flex min-w-0 flex-1 flex-col overflow-hidden border-r p-4">
<div className="mb-3">
<div className="mb-2 flex items-center gap-2">
<span className="text-sm font-bold text-[#2D6A4F]">{config.store.name}</span>
<span className="rounded-full bg-green-100 px-2 py-0.5 text-xs text-green-700">
Caja abierta
</span>
<div className="mb-2 flex items-center gap-3">
{/* Panel toggle button - LEFT */}
<button
type="button"
onClick={() => setShowSidebar((v) => !v)}
title={showSidebar ? 'Ocultar panel' : 'Mostrar panel'}
className="ml-2 rounded-lg border border-gray-300 bg-white px-2 py-1 text-xs font-medium text-gray-600 hover:bg-gray-50"
className="flex items-center gap-1.5 rounded-lg border border-gray-300 bg-white px-2.5 py-1.5 text-xs font-medium text-gray-600 hover:bg-gray-50 transition-colors"
>
{showSidebar ? '◀' : '▶'} Panel
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d={showSidebar ? "M4 6h16M4 12h16M4 18h7" : "M4 6h16M4 12h16M4 18h16"} />
</svg>
{showSidebar ? 'Ocultar' : 'Panel'}
</button>
{/* Store name */}
<span className="text-sm font-bold text-[#2D6A4F]">{config.store.name}</span>
{/* Status badge */}
<span className="flex items-center gap-1 rounded-full bg-green-100 px-2 py-0.5 text-xs text-green-700">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
</span>
Caja abierta
</span>
{/* Clock */}
<span className="ml-auto flex items-center gap-1.5 rounded-lg bg-white px-3 py-1.5 text-xs font-medium text-gray-700 border border-gray-200">
<svg className="w-4 h-4 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{currentTime.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
<span className="text-gray-400">·</span>
{currentTime.toLocaleDateString('es-ES', { day: '2-digit', month: 'short', year: 'numeric' })}
</span>
{!config?.terminal?.settings?.selfpayMode && (
<button
type="button"
@@ -1204,8 +1234,11 @@ export default function RegisterPage() {
setCloseError('');
setShowCloseSession(true);
}}
className="ml-auto rounded-lg border border-red-200 bg-red-50 px-3 py-1 text-xs font-bold text-red-600 hover:bg-red-100"
className="flex items-center gap-1.5 rounded-lg border border-red-200 bg-red-50 px-3 py-1.5 text-xs font-bold text-red-600 hover:bg-red-100 transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
Cerrar caja
</button>
)}

View File

@@ -35,6 +35,13 @@ export default function LoginPage() {
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 text-sm rounded-lg px-4 py-3">
{error}
{error.includes('no confirmado') && (
<div className="mt-2">
<Link href="/auth/recuperar" className="underline font-medium hover:no-underline">
Solicitar nuevo enlace de confirmación
</Link>
</div>
)}
</div>
)}
<div>

View File

@@ -65,6 +65,13 @@ export default function RegisterPage() {
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 text-sm rounded-lg px-4 py-3">
{error}
{error.includes('ya está registrado') && (
<div className="mt-2">
<Link href="/auth/login" className="underline font-medium hover:no-underline">
Inicia sesión con tu cuenta existente
</Link>
</div>
)}
</div>
)}
<div>

View File

@@ -43,7 +43,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
});
const data = await res.json();
if (!res.ok) {
return { ok: false, error: data.error?.message || 'Error de login' };
if (data.error?.code === 'EMAIL_NOT_CONFIRMED') {
return { ok: false, error: 'Email no confirmado. Revisa tu correo o solicita un nuevo enlace de confirmación.' };
}
if (data.error?.code === 'EMAIL_ALREADY_REGISTERED') {
return { ok: false, error: 'Este email ya está registrado. ¿Ya tienes cuenta? Inicia sesión.' };
}
return { ok: false, error: data.error?.message || 'Email o contraseña incorrectos' };
}
setUser(data);
return { ok: true };
@@ -58,6 +64,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
});
const data = await res.json();
if (!res.ok) {
if (data.error?.code === 'EMAIL_ALREADY_REGISTERED') {
return { ok: false, error: 'Este email ya está registrado. ¿Ya tienes cuenta? Inicia sesión.' };
}
return { ok: false, error: data.error?.message || 'Error de registro' };
}
setUser(data);

View File

@@ -22,6 +22,7 @@ export interface InventoryRoutesDeps {
}
const variantParamSchema = z.object({ variantId: z.uuid() });
const productParamSchema = z.object({ productId: z.uuid() });
const availabilityQuerySchema = z.object({
quantity: z.coerce.number().int().positive().default(1),
storeId: z.uuid().optional(),
@@ -80,6 +81,49 @@ export async function registerInventoryRoutes(
},
);
// GET /inventory/product/:productId/availability — lookup first variant and return stock
const productAvailabilitySchema: FastifySchema = {
tags: ['Inventory'],
summary: 'Check availability by productId (público)',
params: {
type: 'object',
required: ['productId'],
properties: { productId: { type: 'string', format: 'uuid' } },
},
querystring: {
type: 'object',
properties: {
quantity: { type: 'integer', default: 1 },
storeId: { type: 'string', format: 'uuid' },
},
},
};
app.get(
'/inventory/product/:productId/availability',
{ schema: productAvailabilitySchema },
async (request, reply) => {
const { productId } = parseJson(productParamSchema, request.params);
const { quantity, storeId } = parseJson(availabilityQuerySchema, request.query);
// Lookup the first variant for this product
const variantResult = await deps.pool.query(
`SELECT id FROM product_variants WHERE product_id = $1 LIMIT 1`,
[productId],
);
if (variantResult.rows.length === 0) {
throw new AppError(404, 'VARIANT_NOT_FOUND', 'No variant found for this product');
}
const variantId = variantResult.rows[0].id;
const availability = await inventory.checkAvailability(
variantId,
storeId ?? DEFAULT_STORE_ID,
quantity,
);
return reply.send(availability);
},
);
const setStockSchema: FastifySchema = {
tags: ['Inventory'],
summary: 'Set available stock (admin)',

View File

@@ -41,7 +41,7 @@ export function AddToCart({ productId, productName, unitPriceCents, imageUrl }:
const [added, setAdded] = useState<boolean>(false);
useEffect(() => {
fetch(`/api/inventory/${encodeURIComponent(productId)}/availability`)
fetch(`/api/inventory/product/${encodeURIComponent(productId)}/availability`)
.then(async (r) => (r.ok ? await r.json() : { availableQuantity: 0 }))
.then((j) => setStock(j.availableQuantity ?? 0))
.catch(() => setStock(0));

View File

@@ -77,14 +77,10 @@ export interface SearchProductsInput {
offset?: number;
}
const DEFAULT_API_BASE_URL = 'http://localhost:3000';
// Use env var if set, otherwise use relative URL (works for SSR and client-side via same-origin)
function apiBaseUrl(): string {
return (
process.env.API_BASE_URL ??
process.env.NEXT_PUBLIC_API_BASE_URL ??
DEFAULT_API_BASE_URL
).replace(/\/$/, '');
const base = process.env.API_BASE_URL ?? process.env.NEXT_PUBLIC_API_BASE_URL ?? '';
return base.replace(/\/$/, '');
}
function toQueryString(input: SearchProductsInput): string {

View File

@@ -1,12 +1,12 @@
{
"feature_id": "F-204",
"stage": "qa_gate",
"agent": "qa",
"feature_id": "SHOP-STOCK",
"stage": "build",
"agent": "implementer",
"action": "Añadiendo DELETE handler al proxy",
"state": "running",
"next_agent": "leader",
"waiting_for": "Seleccionar una feature pending y actualizar este estado",
"updated_at": "2026-08-24T13:55:13Z",
"updated_at": "2026-08-24T13:57:50Z",
"timeline": [
{
"ts": "2026-08-24T05:21:22Z",
@@ -119,6 +119,20 @@
"stage": "qa_gate",
"state": "running",
"message": "Añadiendo DELETE handler al proxy"
},
{
"ts": "2026-08-24T13:56:05Z",
"agent": "implementer",
"stage": "build",
"state": "running",
"message": "Añadiendo DELETE handler al proxy"
},
{
"ts": "2026-08-24T13:57:50Z",
"agent": "implementer",
"stage": "build",
"state": "running",
"message": "Añadiendo DELETE handler al proxy"
}
]
}