feat(ADM-018): completed feature
This commit is contained in:
224
project/frontend/src/app/products/[slug]/page.tsx
Normal file
224
project/frontend/src/app/products/[slug]/page.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import type { Metadata } from 'next';
|
||||
import {
|
||||
fetchProductBySlug,
|
||||
fetchProductVariants,
|
||||
fetchVariantPrice,
|
||||
fetchStockAvailability,
|
||||
fetchCategories,
|
||||
fetchBrands,
|
||||
formatPrice,
|
||||
calcGrossPrice,
|
||||
} from '@/lib/api';
|
||||
import ProductAddToCart from '@/components/cart/ProductAddToCart';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const product = await fetchProductBySlug(slug);
|
||||
if (!product) return { title: 'Producto no encontrado' };
|
||||
return {
|
||||
title: product.seoTitle ?? product.name,
|
||||
description: product.seoDescription ?? product.description,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ProductPage({ params }: Props) {
|
||||
const { slug } = await params;
|
||||
const [product, brands] = await Promise.all([
|
||||
fetchProductBySlug(slug),
|
||||
fetchBrands(),
|
||||
]);
|
||||
|
||||
if (!product) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 py-16 text-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">Producto no encontrado</h1>
|
||||
<p className="text-gray-500 mb-8">El producto que buscas no existe.</p>
|
||||
<Link href="/products" className="text-[#70ad47] font-medium hover:underline">
|
||||
Ver todos los productos →
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const brand = brands.find((b) => b.id === product.brandId);
|
||||
|
||||
// Get primary variant + price + stock
|
||||
const variants = await fetchProductVariants(product.id);
|
||||
const primaryVariant = variants[0];
|
||||
|
||||
let price = null;
|
||||
let stock = null;
|
||||
if (primaryVariant) {
|
||||
[price, stock] = await Promise.all([
|
||||
fetchVariantPrice(primaryVariant.id),
|
||||
fetchStockAvailability(primaryVariant.id),
|
||||
]);
|
||||
}
|
||||
|
||||
// Build category links from all category IDs
|
||||
const tree = await fetchCategories();
|
||||
const flatCats: Array<{ id: string; name: string; slug: string; parentSlug?: string }> = [];
|
||||
function flatten(cats: typeof tree, parentSlug?: string) {
|
||||
for (const cat of cats) {
|
||||
flatCats.push({ id: cat.id, name: cat.name, slug: cat.slug, parentSlug });
|
||||
if (cat.children?.length) flatten(cat.children, cat.slug);
|
||||
}
|
||||
}
|
||||
flatten(tree);
|
||||
const productCats = flatCats.filter((c) => product.categoryIds?.includes(c.id));
|
||||
|
||||
const netCents = price?.netUnitAmountCents ?? 0;
|
||||
const vatRate = price?.vatRate ?? 'general';
|
||||
const grossCents = calcGrossPrice(netCents, vatRate);
|
||||
const vatPercent = vatRate === 'general' ? 21 : 10;
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="mb-6" aria-label="Breadcrumb">
|
||||
<ol className="flex items-center gap-2 text-sm text-gray-500 flex-wrap">
|
||||
<li><Link href="/" className="hover:text-[#70ad47]">Inicio</Link></li>
|
||||
<li><span className="text-gray-300">/</span></li>
|
||||
<li><Link href="/products" className="hover:text-[#70ad47]">Productos</Link></li>
|
||||
{productCats[0] && (
|
||||
<>
|
||||
<li><span className="text-gray-300">/</span></li>
|
||||
<li><Link href={`/categories/${productCats[0].slug}`} className="hover:text-[#70ad47]">{productCats[0].name}</Link></li>
|
||||
</>
|
||||
)}
|
||||
<li><span className="text-gray-300">/</span></li>
|
||||
<li className="text-gray-900 font-medium truncate max-w-xs">{product.name}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||
{/* Image */}
|
||||
<div>
|
||||
<div className="aspect-square bg-gray-50 rounded-2xl border border-gray-100 flex items-center justify-center overflow-hidden">
|
||||
{product.images?.[0] ? (
|
||||
<Image
|
||||
src={product.images[0].url}
|
||||
alt={product.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
sizes="(max-width: 1024px) 100vw, 50vw"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-8xl">🌿</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div>
|
||||
{brand && (
|
||||
<Link href={`/brands/${brand.slug}`} className="text-sm text-[#E76F51] font-medium uppercase tracking-wide hover:underline">
|
||||
{brand.name}
|
||||
</Link>
|
||||
)}
|
||||
<h1 className="text-3xl font-bold text-gray-900 mt-2" style={{ fontFamily: 'var(--font-heading)' }}>
|
||||
{product.name}
|
||||
</h1>
|
||||
|
||||
{/* Price */}
|
||||
{price ? (
|
||||
<div className="mt-6 bg-gray-50 rounded-xl p-6">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span className="text-4xl font-bold text-[#70ad47]">{formatPrice(grossCents)}</span>
|
||||
<span className="text-lg text-gray-500">inc. IVA {vatPercent}%</span>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-500">
|
||||
{formatPrice(netCents)} sin IVA · IVA {vatPercent}%
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 bg-gray-50 rounded-xl p-6">
|
||||
<span className="text-2xl text-gray-400">Precio no disponible</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stock */}
|
||||
{stock && (
|
||||
<div className="mt-4">
|
||||
{stock.available ? (
|
||||
<div className="flex items-center gap-2 text-[#70ad47]">
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium">En stock — {stock.availableQuantity} unidades</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-red-500">
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium">Sin stock</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add to cart */}
|
||||
{stock?.available && primaryVariant ? (
|
||||
<ProductAddToCart
|
||||
variantId={primaryVariant.id}
|
||||
productId={product.id}
|
||||
productName={product.name}
|
||||
priceCents={grossCents}
|
||||
imageUrl={product.images?.[0]?.url}
|
||||
available={true}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
disabled
|
||||
className="mt-6 px-8 py-3.5 bg-gray-200 text-gray-500 font-semibold rounded-xl cursor-not-allowed"
|
||||
>
|
||||
Agotado
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{product.description && (
|
||||
<div className="mt-8">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-2">Descripción</h2>
|
||||
<p className="text-gray-600 leading-relaxed">{product.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Categories */}
|
||||
{productCats.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-2">Categorías</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{productCats.map((cat) => (
|
||||
<Link
|
||||
key={cat.id}
|
||||
href={`/categories/${cat.slug}`}
|
||||
className="px-3 py-1 bg-gray-100 hover:bg-[#70ad47] hover:text-white text-sm rounded-full transition-colors"
|
||||
>
|
||||
{cat.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SKU */}
|
||||
{primaryVariant && (
|
||||
<div className="mt-4 text-xs text-gray-400">
|
||||
SKU: {primaryVariant.sku}
|
||||
{primaryVariant.ean && ` · EAN: ${primaryVariant.ean}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
project/frontend/src/app/products/page.tsx
Normal file
78
project/frontend/src/app/products/page.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import type { Metadata } from 'next';
|
||||
import { fetchProducts, fetchBrands, fetchCategories, formatPrice } from '@/lib/api';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Productos — MercadoDeVida',
|
||||
description: 'Todos los productos naturales y orgánicos.',
|
||||
};
|
||||
|
||||
function findCatName(cats: ReturnType<typeof flattenCats>, id: string): string | undefined {
|
||||
return cats.find((c) => c.id === id)?.name;
|
||||
}
|
||||
|
||||
function flattenCats(cats: Awaited<ReturnType<typeof fetchCategories>>): Array<{ id: string; name: string; slug: string }> {
|
||||
const flat: Array<{ id: string; name: string; slug: string }> = [];
|
||||
function walk(c: typeof cats[number]) {
|
||||
flat.push({ id: c.id, name: c.name, slug: c.slug });
|
||||
if (c.children?.length) c.children.forEach(walk);
|
||||
}
|
||||
cats.forEach(walk);
|
||||
return flat;
|
||||
}
|
||||
|
||||
export default async function ProductsPage() {
|
||||
const [products, brands, categories] = await Promise.all([
|
||||
fetchProducts({ limit: 24 }),
|
||||
fetchBrands(),
|
||||
fetchCategories(),
|
||||
]);
|
||||
const flatCats = flattenCats(categories);
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900" style={{ fontFamily: 'var(--font-heading)' }}>
|
||||
Todos los productos
|
||||
</h1>
|
||||
<p className="mt-2 text-gray-600">{products.length} productos disponibles</p>
|
||||
</div>
|
||||
|
||||
{products.length === 0 ? (
|
||||
<p className="text-gray-500 text-center py-16">No hay productos disponibles.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{products.map((product) => {
|
||||
const brand = brands.find((b) => b.id === product.brandId);
|
||||
return (
|
||||
<Link key={product.id} href={`/products/${product.slug}`} className="group block">
|
||||
<div className="bg-gray-50 rounded-xl overflow-hidden border border-gray-100 hover:border-[#70ad47] transition-all hover:shadow-md">
|
||||
<div className="aspect-square relative bg-white flex items-center justify-center">
|
||||
{product.images?.[0] ? (
|
||||
<Image src={product.images[0].url} alt={product.name} fill className="object-cover" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" />
|
||||
) : (
|
||||
<span className="text-5xl">🌿</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{brand && (
|
||||
<p className="text-xs text-[#E76F51] font-medium uppercase tracking-wide mb-1">{brand.name}</p>
|
||||
)}
|
||||
<h3 className="font-semibold text-gray-900 group-hover:text-[#70ad47] transition-colors line-clamp-2 text-sm">
|
||||
{product.name}
|
||||
</h3>
|
||||
<p className="text-gray-500 text-xs mt-1 line-clamp-2">{product.description}</p>
|
||||
<div className="mt-3 pr-2">
|
||||
<span className="text-lg font-bold text-[#70ad47]">{formatPrice(0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user