feat(F-067): completed feature

This commit is contained in:
chattie
2026-08-19 17:30:00 +02:00
parent 9f1858f6d7
commit f62bd6a578
16 changed files with 416 additions and 24 deletions

View File

@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from 'next/server';
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
interface RouteParams {
params: Promise<{ id: string }>;
}
export async function GET(_request: NextRequest, { params }: RouteParams) {
const { id } = await params;
const cookie = _request.headers.get('cookie') ?? '';
try {
const res = await fetch(`${API}/users/${id}/addresses`, {
headers: { Cookie: cookie },
cache: 'no-store',
});
const body = await res.json().catch(() => ({ items: [] }));
return NextResponse.json(body, { status: res.status });
} catch {
return NextResponse.json({ items: [] }, { status: 502 });
}
}
export async function POST(request: NextRequest, { params }: RouteParams) {
const { id } = await params;
const cookie = request.headers.get('cookie') ?? '';
const payload = await request.text();
try {
const res = await fetch(`${API}/users/${id}/addresses`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: cookie },
body: payload,
cache: 'no-store',
});
const body = await res.json().catch(() => ({}));
return NextResponse.json(body, { status: res.status });
} catch {
return NextResponse.json({ error: 'Proxy error' }, { status: 502 });
}
}

View File

@@ -79,7 +79,7 @@ export default async function BrandPage({ params }: Props) {
{products.map((product) => (
<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="relative aspect-[5/7] max-h-72 bg-white flex items-center justify-center overflow-hidden">
<div className="relative w-full max-h-72 bg-white flex items-center justify-center overflow-hidden">
{product.images?.[0] ? (
<Image src={product.images[0].url} alt={product.name} fill className="object-contain" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" />
) : (

View File

@@ -87,7 +87,7 @@ export default async function CategoryPage({ params }: Props) {
{products.map((product) => (
<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="relative aspect-[5/7] max-h-72 bg-white flex items-center justify-center overflow-hidden">
<div className="relative w-full max-h-72 bg-white flex items-center justify-center overflow-hidden">
{product.images?.[0] ? (
<Image src={product.images[0].url} alt={product.name} fill className="object-contain" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" />
) : (

View File

@@ -48,11 +48,10 @@ export default async function ProductsPage() {
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">
{/* Image container: bounded box (max 5:7 aspect ratio to match
backend thumbnail sizing). The image preserves its source
aspect ratio via `object-contain`, so non-square images
display without cropping. */}
<div className="relative aspect-[5/7] max-h-72 bg-white flex items-center justify-center overflow-hidden">
{/* Image container fills the card width; the image itself
is centred with `object-contain`. The bounding box is
capped at max-h-72 so very tall images don't dominate. */}
<div className="relative w-full max-h-72 bg-white flex items-center justify-center overflow-hidden">
{product.images?.[0] ? (
<Image
src={product.images[0].url}

View File

@@ -92,7 +92,7 @@ export default async function SearchPage({ searchParams }: Props) {
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="relative aspect-[5/7] max-h-72 bg-white flex items-center justify-center overflow-hidden">
<div className="relative w-full max-h-72 bg-white flex items-center justify-center overflow-hidden">
{product.images?.[0] ? (
<Image src={product.images[0].url} alt={product.name} fill className="object-contain" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" />
) : (

View File

@@ -1,7 +1,7 @@
'use client';
import Link from 'next/link';
import Image from 'next/image';
import { useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useAuth } from '@/contexts/AuthContext';
import { useCart } from '@/contexts/CartContext';
@@ -9,12 +9,47 @@ function formatPrice(cents: number) {
return `${(cents / 100).toFixed(2)}`;
}
interface SavedAddress {
id: string;
label: string | null;
recipientName: string;
street: string;
city: string;
postalCode: string;
country: string;
isDefault: boolean;
}
function splitName(fullName: string): { firstName: string; lastName: string } {
const trimmed = fullName.trim().replace(/\s+/g, ' ');
if (!trimmed) return { firstName: '', lastName: '' };
const parts = trimmed.split(' ');
if (parts.length === 1) return { firstName: parts[0], lastName: '' };
return { firstName: parts.slice(0, -1).join(' '), lastName: parts[parts.length - 1] };
}
function addressToForm(addr: SavedAddress) {
const { firstName, lastName } = splitName(addr.recipientName);
return {
firstName,
lastName,
phone: '',
address: addr.street,
city: addr.city,
postalCode: addr.postalCode,
country: addr.country,
};
}
export default function CheckoutClient() {
const { user, loading: authLoading } = useAuth();
const { items, subtotalCents, itemCount, clearCart } = useCart();
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const [addresses, setAddresses] = useState<SavedAddress[]>([]);
const [selectedAddressId, setSelectedAddressId] = useState<string | null>(null);
// Form state
const [form, setForm] = useState({
firstName: '',
@@ -28,6 +63,47 @@ export default function CheckoutClient() {
shippingMethod: 'standard',
});
// Fetch saved addresses when the customer is logged in.
useEffect(() => {
if (!user || user.role !== 'customer') {
setAddresses([]);
setSelectedAddressId(null);
return;
}
let cancelled = false;
(async () => {
try {
const res = await fetch(`/api/users/${user.id}/addresses`, { credentials: 'include', cache: 'no-store' });
if (!res.ok) return;
const data = (await res.json()) as { items?: SavedAddress[] };
if (cancelled) return;
const list = data.items ?? [];
setAddresses(list);
const def = list.find((a) => a.isDefault) ?? list[0] ?? null;
if (def) {
setSelectedAddressId(def.id);
setForm((f) => ({ ...f, ...addressToForm(def) }));
}
} catch {
/* ignore */
}
})();
return () => {
cancelled = true;
};
}, [user]);
const selectedAddress = useMemo(
() => addresses.find((a) => a.id === selectedAddressId) ?? null,
[addresses, selectedAddressId],
);
const handleSelectAddress = (id: string) => {
setSelectedAddressId(id);
const addr = addresses.find((a) => a.id === id);
if (addr) setForm((f) => ({ ...f, ...addressToForm(addr) }));
};
if (authLoading) {
return (
<div className="max-w-7xl mx-auto px-4 py-16 flex items-center justify-center">
@@ -69,7 +145,7 @@ export default function CheckoutClient() {
line1: form.address,
city: form.city,
postalCode: form.postalCode,
country: 'ES',
country: selectedAddress?.country || form.shippingMethod ? 'ES' : 'ES',
},
items: items.map((i) => ({
productId: i.productId,
@@ -123,6 +199,51 @@ export default function CheckoutClient() {
</div>
)}
{/* Saved addresses selector — only when logged in */}
{user && addresses.length > 0 && (
<div className="bg-white border border-gray-200 rounded-xl p-6 mb-6">
<h2 className="font-bold text-gray-900 mb-3">Direcciones guardadas</h2>
<div className="space-y-2">
{addresses.map((a) => {
const active = selectedAddressId === a.id;
return (
<label
key={a.id}
className={`flex items-start gap-3 p-3 border rounded-lg cursor-pointer transition-colors ${
active ? 'border-[#70ad47] bg-[#70ad47]/5' : 'border-gray-200 hover:border-[#70ad47]'
}`}
>
<input
type="radio"
name="saved-address"
value={a.id}
checked={active}
onChange={() => handleSelectAddress(a.id)}
className="text-[#70ad47] mt-1"
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<p className="font-medium text-gray-900">{a.recipientName}</p>
{a.label && (
<span className="px-2 py-0.5 bg-gray-100 text-gray-600 text-xs rounded-full">{a.label}</span>
)}
{a.isDefault && (
<span className="px-2 py-0.5 bg-green-100 text-green-700 text-xs rounded-full">Predeterminada</span>
)}
</div>
<p className="text-sm text-gray-600">{a.street}</p>
<p className="text-sm text-gray-500">{a.postalCode} {a.city}, {a.country}</p>
</div>
</label>
);
})}
</div>
<p className="text-xs text-gray-400 mt-3">
También puedes editar los campos manualmente debajo; se aplicarán al pedido sin guardar nada en tu cuenta.
</p>
</div>
)}
{/* Shipping form */}
<div className="bg-white border border-gray-200 rounded-xl p-6">
<h2 className="font-bold text-gray-900 mb-4">Datos de envío</h2>
@@ -301,4 +422,4 @@ export default function CheckoutClient() {
</div>
</div>
);
}
}

View File

@@ -34,7 +34,7 @@ export default async function FeaturedProducts() {
{products.map((product) => (
<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-colors">
<div className="relative aspect-[5/7] max-h-72 bg-white flex items-center justify-center overflow-hidden">
<div className="relative w-full max-h-72 bg-white flex items-center justify-center overflow-hidden">
{product.images?.[0] ? (
<Image src={product.images[0].url} alt={product.name} fill className="object-contain" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" />
) : (

View File

@@ -8,7 +8,7 @@ export function ProductCard({ product }: Readonly<{ product: ProductSummaryDto }
href={product.url}
className="group block overflow-hidden rounded-3xl border border-emerald-900/10 bg-white shadow-sm transition hover:-translate-y-0.5 hover:border-emerald-700"
>
<div className="flex aspect-[5/7] max-h-72 items-center justify-center bg-emerald-50 text-sm text-emerald-900 overflow-hidden">
<div className="flex w-full max-h-72 items-center justify-center bg-emerald-50 text-sm text-emerald-900 overflow-hidden">
{mainImage ? (
// Keep plain img for remote/local URL compatibility until image pipeline configuration exists.
// eslint-disable-next-line @next/next/no-img-element

File diff suppressed because one or more lines are too long