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:
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user