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:
Deploy
2026-08-25 23:41:29 +02:00
parent b6adf681d1
commit b6af852b54
29 changed files with 671 additions and 279 deletions

View File

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

View File

@@ -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 */}

View File

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