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:
@@ -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"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user