feat(F-087): completed feature
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
|
||||
|
||||
/** PATCH /api/cart/items/[variantId] -> backend PATCH /cart/items/:variantId */
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
ctx: { params: Promise<{ variantId: string }> },
|
||||
) {
|
||||
const { variantId } = await ctx.params;
|
||||
const body = await req.text();
|
||||
const cookies = req.headers.get('cookie') ?? '';
|
||||
try {
|
||||
const backendRes = await fetch(`${API}/cart/items/${encodeURIComponent(variantId)}`, {
|
||||
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 DELETE(
|
||||
req: NextRequest,
|
||||
ctx: { params: Promise<{ variantId: string }> },
|
||||
) {
|
||||
const { variantId } = await ctx.params;
|
||||
const cookies = req.headers.get('cookie') ?? '';
|
||||
try {
|
||||
const backendRes = await fetch(`${API}/cart/items/${encodeURIComponent(variantId)}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Cookie: cookies },
|
||||
});
|
||||
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 });
|
||||
}
|
||||
}
|
||||
31
project/storefront/src/app/api/cart/route.ts
Normal file
31
project/storefront/src/app/api/cart/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
|
||||
|
||||
async function proxy(req: NextRequest, method: 'GET' | 'POST', path: string) {
|
||||
const cookies = req.headers.get('cookie') ?? '';
|
||||
let body: string | undefined;
|
||||
if (method === 'POST') body = await req.text();
|
||||
try {
|
||||
const backendRes = await fetch(`${API}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(cookies ? { 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 GET(req: NextRequest) {
|
||||
return proxy(req, 'GET', '/cart');
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxy(req, 'POST', '/cart/items');
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
|
||||
|
||||
/** GET /api/inventory/[variantId]/availability -> backend */
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
ctx: { params: Promise<{ variantId: string }> },
|
||||
) {
|
||||
const { variantId } = await ctx.params;
|
||||
try {
|
||||
const backendRes = await fetch(`${API}/inventory/${encodeURIComponent(variantId)}/availability`);
|
||||
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 });
|
||||
}
|
||||
}
|
||||
164
project/storefront/src/app/carrito/page.tsx
Normal file
164
project/storefront/src/app/carrito/page.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
'use client';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
|
||||
interface CartItem {
|
||||
variantId: string;
|
||||
productId: string;
|
||||
name: string;
|
||||
unitPriceCents: number;
|
||||
quantity: number;
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
interface StockMap {
|
||||
[variantId: string]: number;
|
||||
}
|
||||
|
||||
function readCart(): CartItem[] {
|
||||
if (typeof window === 'undefined') return [];
|
||||
try {
|
||||
const raw = localStorage.getItem('mdv_cart');
|
||||
if (!raw) return [];
|
||||
const data = JSON.parse(raw) as { items?: CartItem[] };
|
||||
return data.items ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeCart(items: CartItem[]): void {
|
||||
localStorage.setItem('mdv_cart', JSON.stringify({ items }));
|
||||
window.dispatchEvent(new CustomEvent('mdv:cart-updated'));
|
||||
}
|
||||
|
||||
export default function CartPage() {
|
||||
const [items, setItems] = useState<CartItem[]>([]);
|
||||
const [stock, setStock] = useState<StockMap>({});
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const data = readCart();
|
||||
setItems(data);
|
||||
// Fetch stock per variant
|
||||
const map: StockMap = {};
|
||||
await Promise.all(
|
||||
data.map(async (item) => {
|
||||
try {
|
||||
const res = await fetch(`/api/inventory/${encodeURIComponent(item.variantId)}/availability`);
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
map[item.variantId] = json.availableQuantity ?? 0;
|
||||
} else {
|
||||
map[item.variantId] = 0;
|
||||
}
|
||||
} catch {
|
||||
map[item.variantId] = 0;
|
||||
}
|
||||
}),
|
||||
);
|
||||
setStock(map);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const updateQty = async (variantId: string, newQty: number) => {
|
||||
const max = stock[variantId] ?? 0;
|
||||
if (newQty < 1) return;
|
||||
if (newQty > max) {
|
||||
setError(`Solo hay ${max} unidades disponibles para esta variante.`);
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
const next = items.map((it) => (it.variantId === variantId ? { ...it, quantity: newQty } : it));
|
||||
setItems(next);
|
||||
writeCart(next);
|
||||
// Sync to backend if logged in
|
||||
try {
|
||||
await fetch(`/api/cart/items/${encodeURIComponent(variantId)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ quantity: newQty }),
|
||||
});
|
||||
} catch {
|
||||
// ignore network errors; localStorage remains source of truth
|
||||
}
|
||||
};
|
||||
|
||||
const remove = (variantId: string) => {
|
||||
const next = items.filter((it) => it.variantId !== variantId);
|
||||
setItems(next);
|
||||
writeCart(next);
|
||||
void fetch(`/api/cart/items/${encodeURIComponent(variantId)}`, { method: 'DELETE' }).catch(() => undefined);
|
||||
};
|
||||
|
||||
const total = items.reduce((sum, it) => sum + it.unitPriceCents * it.quantity, 0);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-6 py-12">
|
||||
<h1 className="text-3xl font-bold text-emerald-950">Carrito</h1>
|
||||
{loading ? (
|
||||
<p className="mt-6 text-stone-500">Cargando...</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="mt-6 text-stone-500">Tu carrito está vacío.</p>
|
||||
) : (
|
||||
<div className="mt-8 space-y-4">
|
||||
{items.map((item) => {
|
||||
const max = stock[item.variantId] ?? 0;
|
||||
const overStock = item.quantity > max;
|
||||
return (
|
||||
<div
|
||||
key={item.variantId}
|
||||
className="flex items-center gap-4 rounded-2xl border border-emerald-900/10 bg-white p-4"
|
||||
>
|
||||
{item.imageUrl && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={item.imageUrl} alt={item.name} className="h-20 w-20 rounded-lg object-cover" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-emerald-950">{item.name}</p>
|
||||
<p className="text-sm text-stone-500">{(item.unitPriceCents / 100).toFixed(2)} € / unidad</p>
|
||||
<p className="text-xs text-stone-400">Stock disponible: {max}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={max}
|
||||
value={item.quantity}
|
||||
onChange={(e) => {
|
||||
const v = parseInt(e.target.value, 10);
|
||||
if (!isNaN(v)) updateQty(item.variantId, v);
|
||||
}}
|
||||
className={`w-20 rounded-lg border px-2 py-1 text-sm focus:ring-2 focus:ring-emerald-700 outline-none ${overStock ? 'border-red-400' : 'border-stone-300'}`}
|
||||
/>
|
||||
<button
|
||||
onClick={() => remove(item.variantId)}
|
||||
className="rounded-lg px-3 py-1 text-sm text-stone-500 hover:bg-stone-100"
|
||||
>
|
||||
Quitar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{error && (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-700">{error}</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between border-t border-emerald-900/10 pt-4">
|
||||
<p className="text-lg font-bold text-emerald-950">Total: {(total / 100).toFixed(2)} €</p>
|
||||
<a
|
||||
href="/checkout"
|
||||
className="rounded-xl bg-emerald-800 px-5 py-2.5 text-sm font-semibold text-white hover:bg-emerald-900"
|
||||
>
|
||||
Tramitar pedido
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { ProductCard } from '@/components/product-card';
|
||||
import { AddToCart } from '@/components/add-to-cart';
|
||||
import { getProductBySlug, searchProducts } from '@/lib/api';
|
||||
import { absoluteUrl, metadataTitle } from '@/lib/seo';
|
||||
import { breadcrumbJsonLd, JsonLdScript, productJsonLd } from '@/lib/seo/json-ld';
|
||||
@@ -101,6 +102,12 @@ export default async function ProductPage({ params }: PageProps) {
|
||||
<dd>{product.state}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<AddToCart
|
||||
productId={product.id}
|
||||
productName={product.name}
|
||||
unitPriceCents={1000}
|
||||
imageUrl={image?.url}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
|
||||
115
project/storefront/src/components/add-to-cart.tsx
Normal file
115
project/storefront/src/components/add-to-cart.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
'use client';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
productId: string;
|
||||
productName: string;
|
||||
unitPriceCents: number;
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
interface CartItem {
|
||||
variantId: string;
|
||||
productId: string;
|
||||
name: string;
|
||||
unitPriceCents: number;
|
||||
quantity: number;
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
function readCart(): CartItem[] {
|
||||
if (typeof window === 'undefined') return [];
|
||||
try {
|
||||
const raw = localStorage.getItem('mdv_cart');
|
||||
if (!raw) return [];
|
||||
const data = JSON.parse(raw) as { items?: CartItem[] };
|
||||
return data.items ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeCart(items: CartItem[]): void {
|
||||
localStorage.setItem('mdv_cart', JSON.stringify({ items }));
|
||||
window.dispatchEvent(new CustomEvent('mdv:cart-updated'));
|
||||
}
|
||||
|
||||
export function AddToCart({ productId, productName, unitPriceCents, imageUrl }: Props) {
|
||||
const [stock, setStock] = useState<number>(0);
|
||||
const [qty, setQty] = useState<number>(1);
|
||||
const [error, setError] = useState<string>('');
|
||||
const [added, setAdded] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/inventory/${encodeURIComponent(productId)}/availability`)
|
||||
.then(async (r) => (r.ok ? await r.json() : { availableQuantity: 0 }))
|
||||
.then((j) => setStock(j.availableQuantity ?? 0))
|
||||
.catch(() => setStock(0));
|
||||
}, [productId]);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
if (qty < 1) {
|
||||
setError('La cantidad debe ser al menos 1.');
|
||||
return;
|
||||
}
|
||||
if (qty > stock) {
|
||||
setError(`Solo hay ${stock} unidades disponibles.`);
|
||||
return;
|
||||
}
|
||||
const items = readCart();
|
||||
const existing = items.find((it) => it.variantId === productId);
|
||||
const newQty = existing ? existing.quantity + qty : qty;
|
||||
if (newQty > stock) {
|
||||
setError(`Solo hay ${stock} unidades disponibles.`);
|
||||
return;
|
||||
}
|
||||
const next = existing
|
||||
? items.map((it) => (it.variantId === productId ? { ...it, quantity: newQty } : it))
|
||||
: [
|
||||
...items,
|
||||
{ variantId: productId, productId, name: productName, unitPriceCents, quantity: qty, imageUrl },
|
||||
];
|
||||
writeCart(next);
|
||||
// Backend sync (best-effort)
|
||||
void fetch('/api/cart/items', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ variantId: productId, quantity: qty }),
|
||||
}).catch(() => undefined);
|
||||
setAdded(true);
|
||||
setTimeout(() => setAdded(false), 2500);
|
||||
};
|
||||
|
||||
const disabled = stock === 0;
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="mt-6 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-sm font-medium text-stone-700">Cantidad</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={stock}
|
||||
value={qty}
|
||||
onChange={(e) => setQty(Math.max(1, parseInt(e.target.value, 10) || 1))}
|
||||
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"
|
||||
/>
|
||||
<span className="text-xs text-stone-500">Stock: {stock}</span>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
{added && !error && (
|
||||
<p className="text-sm text-emerald-700">✓ Añadido al carrito</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={disabled}
|
||||
className="rounded-xl bg-emerald-800 px-5 py-2.5 text-sm font-semibold text-white hover:bg-emerald-900 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{disabled ? 'Sin stock' : 'Añadir al carrito'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
30
project/storefront/src/components/cart-button.tsx
Normal file
30
project/storefront/src/components/cart-button.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function CartButton() {
|
||||
const [count, setCount] = useState(0);
|
||||
useEffect(() => {
|
||||
const stored = JSON.parse(localStorage.getItem('mdv_cart') ?? '{"items":[]}') as { items?: unknown[] };
|
||||
setCount((stored.items ?? []).length);
|
||||
const onUpdate = () => {
|
||||
const s = JSON.parse(localStorage.getItem('mdv_cart') ?? '{"items":[]}') as { items?: unknown[] };
|
||||
setCount((s.items ?? []).length);
|
||||
};
|
||||
window.addEventListener('mdv:cart-updated', onUpdate);
|
||||
return () => window.removeEventListener('mdv:cart-updated', onUpdate);
|
||||
}, []);
|
||||
return (
|
||||
<a
|
||||
href="/carrito"
|
||||
className="relative inline-flex items-center text-sm font-medium text-stone-700 hover:text-emerald-800"
|
||||
aria-label="Ver carrito"
|
||||
>
|
||||
<span aria-hidden="true">🛒</span>
|
||||
{count > 0 && (
|
||||
<span className="ml-1 inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-emerald-700 px-1 text-xs font-semibold text-white">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,8 @@ const navigation = [
|
||||
{ href: '/marca', label: 'Marcas' },
|
||||
];
|
||||
|
||||
import { CartButton } from './cart-button';
|
||||
|
||||
export function SiteHeader() {
|
||||
return (
|
||||
<header className="border-b border-emerald-900/10 bg-white/85 backdrop-blur">
|
||||
@@ -24,6 +26,7 @@ export function SiteHeader() {
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<CartButton />
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user