feat(F-056): completed feature

This commit is contained in:
chattie
2026-08-19 15:10:09 +02:00
parent cc84f24658
commit a967e49851
29 changed files with 946 additions and 43 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 568 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 568 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View File

@@ -0,0 +1,245 @@
import { randomUUID } from 'node:crypto';
import { cookies } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
const SESSION_COOKIE = 'mdv_session';
const COUNTRY_FALLBACK = 'ES';
const addressSchema = {
type: 'object',
required: ['line1', 'city', 'postalCode', 'country'],
properties: {
line1: { type: 'string', minLength: 1, maxLength: 200 },
line2: { type: 'string', maxLength: 200 },
city: { type: 'string', minLength: 1, maxLength: 80 },
postalCode: { type: 'string', minLength: 1, maxLength: 20 },
country: { type: 'string', minLength: 2, maxLength: 80 },
firstName: { type: 'string', maxLength: 80 },
lastName: { type: 'string', maxLength: 80 },
phone: { type: 'string', maxLength: 40 },
email: { type: 'string', maxLength: 160 },
},
} as const;
const itemSchema = {
type: 'object',
required: ['productId', 'variantId', 'quantity'],
properties: {
productId: { type: 'string' },
variantId: { type: 'string' },
quantity: { type: 'number' },
},
} as const;
const bodySchema = {
type: 'object',
required: ['shippingAddress', 'items'],
properties: {
shippingAddress: addressSchema,
billingAddress: addressSchema,
items: { type: 'array', items: itemSchema, minItems: 1 },
promoCode: { type: 'string', maxLength: 64 },
notes: { type: 'string', maxLength: 500 },
idempotencyKey: { type: 'string', maxLength: 120 },
},
} as const;
const UNAUTHORIZED = {
error: {
code: 'UNAUTHORIZED',
message: 'Debes iniciar sesión para finalizar el pedido.',
},
} as const;
interface RawItem {
productId?: unknown;
variantId?: unknown;
quantity?: unknown;
}
interface RawBody {
shippingAddress?: { country?: unknown; postalCode?: unknown; [key: string]: unknown };
items?: RawItem[];
promoCode?: string | null;
idempotencyKey?: string;
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.length > 0;
}
function isUuid(value: unknown): value is string {
return typeof value === 'string' && /^[0-9a-f-]{36}$/i.test(value);
}
function parseQuantity(value: unknown): number {
const number = Number(value);
if (!Number.isFinite(number) || !Number.isInteger(number) || number < 1 || number > 99) {
throw new Error('Cantidad inválida en el carrito.');
}
return number;
}
function parseItems(raw: RawItem[] | undefined): {
productId: string;
variantId: string;
quantity: number;
}[] {
if (!Array.isArray(raw) || raw.length === 0) {
throw new Error('El carrito está vacío.');
}
return raw.map((item, index) => {
if (!isUuid(item.productId)) throw new Error(`productId inválido (item ${index}).`);
if (!isUuid(item.variantId)) throw new Error(`variantId inválido (item ${index}).`);
return {
productId: item.productId,
variantId: item.variantId,
quantity: parseQuantity(item.quantity),
};
});
}
async function syncCart(
cookies: string,
items: { productId: string; variantId: string; quantity: number }[],
): Promise<{ ok: true } | { ok: false; status: number; message: string }> {
let serverItems: { variantId: string; quantity: number }[] = [];
try {
const current = await fetch(`${API}/cart`, {
headers: { Cookie: cookies },
cache: 'no-store',
});
if (current.ok) {
const body = (await current.json()) as { items?: { variantId?: unknown; quantity?: unknown }[] };
serverItems = (body.items ?? [])
.filter(
(entry): entry is { variantId: string; quantity: number } =>
typeof entry.variantId === 'string' && typeof entry.quantity === 'number',
)
.map((entry) => ({ variantId: entry.variantId, quantity: entry.quantity }));
}
} catch {
// Treat the cart as empty if the backend is unreachable; the checkout call
// will surface the real problem.
}
const nextByVariant = new Map(items.map((item) => [item.variantId, item]));
const operations: Promise<Response>[] = [];
for (const item of serverItems) {
if (!nextByVariant.has(item.variantId)) {
operations.push(
fetch(`${API}/cart/items/${item.variantId}`, {
method: 'DELETE',
headers: { Cookie: cookies },
}),
);
}
}
for (const item of items) {
operations.push(
fetch(`${API}/cart/items`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: cookies },
body: JSON.stringify(item),
}),
);
}
const results = await Promise.all(operations);
for (const response of results) {
if (!response.ok && response.status !== 404) {
const message = await response.text();
return { ok: false, status: response.status, message: message || 'Error al sincronizar el carrito' };
}
}
return { ok: true };
}
export async function POST(request: NextRequest) {
const cookieJar = await cookies();
const sessionCookie = cookieJar.get(SESSION_COOKIE)?.value;
if (!sessionCookie) {
return NextResponse.json(UNAUTHORIZED, { status: 401 });
}
const cookieHeader = request.headers.get('cookie') ?? `${SESSION_COOKIE}=${sessionCookie}`;
let body: RawBody;
try {
body = (await request.json()) as RawBody;
} catch {
return NextResponse.json(
{ error: { code: 'INVALID_BODY', message: 'Cuerpo JSON inválido.' } },
{ status: 400 },
);
}
const address = body.shippingAddress ?? {};
const country = isNonEmptyString(address.country) ? address.country : COUNTRY_FALLBACK;
const postalCode = isNonEmptyString(address.postalCode) ? address.postalCode : null;
if (!postalCode) {
return NextResponse.json(
{ error: { code: 'INVALID_ADDRESS', message: 'Falta el código postal.' } },
{ status: 400 },
);
}
let items: { productId: string; variantId: string; quantity: number }[];
try {
items = parseItems(body.items);
} catch (error) {
return NextResponse.json(
{ error: { code: 'INVALID_CART', message: (error as Error).message } },
{ status: 400 },
);
}
const sync = await syncCart(cookieHeader, items);
if (!sync.ok) {
return NextResponse.json(
{ error: { code: 'CART_SYNC_FAILED', message: sync.message } },
{ status: sync.status },
);
}
const idempotencyKey = isNonEmptyString(body.idempotencyKey)
? body.idempotencyKey
: `frontend-${randomUUID()}`;
let backendRes: Response;
try {
backendRes = await fetch(`${API}/checkout`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: cookieHeader },
body: JSON.stringify({
address: { country, postalCode },
promoCode: body.promoCode ?? null,
idempotencyKey,
}),
});
} catch (error) {
return NextResponse.json(
{
error: {
code: 'BACKEND_UNREACHABLE',
message: `No se pudo conectar con el servidor: ${(error as Error).message}`,
},
},
{ status: 502 },
);
}
const text = await backendRes.text();
let payload: unknown = text;
try {
payload = text ? JSON.parse(text) : {};
} catch {
// Keep raw text if backend returned non-JSON.
}
if (backendRes.status === 401) {
return NextResponse.json(UNAUTHORIZED, { status: 401 });
}
return NextResponse.json(payload, { status: backendRes.status });
}

View File

@@ -100,7 +100,7 @@ export default async function ProductPage({ params }: Props) {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
{/* Image */}
<div>
<div className="aspect-square max-h-[500px] bg-gray-50 rounded-2xl border border-gray-100 flex items-center justify-center overflow-hidden">
<div className="relative aspect-square max-h-[500px] 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}

View File

@@ -0,0 +1,126 @@
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
/**
* Dynamic uploads server.
*
* Next.js caches the `public/` directory listing when the server boots, so
* files uploaded *after* `next start` return 404 (and `next/image` answers
* 502 because its upstream fetch fails). This route handler reads uploads
* from disk on every request, so a freshly uploaded image is available with
* no rebuild and no restart.
*
* It also generates the 40px/200px thumbnails on demand and caches them on
* disk, so `/uploads/40/<file>` and `/uploads/200/<file>` always resolve even
* when the pre-generation script has not been executed yet.
*/
export const dynamic = 'force-dynamic';
const THUMB_SIZES: Readonly<Record<string, number>> = { '40': 40, '200': 200 };
const SAFE_SEGMENT = /^[A-Za-z0-9._-]+$/;
const CONTENT_TYPE_BY_EXTENSION: Readonly<Record<string, string>> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.webp': 'image/webp',
'.avif': 'image/avif',
'.gif': 'image/gif',
};
const LOCAL_UPLOADS = path.join(process.cwd(), 'public', 'uploads');
/** Admin owns the canonical uploads directory; peers mirror it. */
const CANONICAL_UPLOADS = path.join(process.cwd(), '..', 'apps', 'admin', 'public', 'uploads');
const UPLOAD_ROOTS = [LOCAL_UPLOADS, CANONICAL_UPLOADS];
const IMMUTABLE = 'public, max-age=31536000, immutable';
async function readIfFile(filePath: string): Promise<Buffer | null> {
try {
const stats = await stat(filePath);
if (!stats.isFile() || stats.size === 0) return null;
return await readFile(filePath);
} catch {
return null;
}
}
async function findOriginal(filename: string): Promise<{ root: string; buffer: Buffer } | null> {
for (const root of UPLOAD_ROOTS) {
const buffer = await readIfFile(path.join(root, filename));
if (buffer) return { root, buffer };
}
return null;
}
async function buildThumbnail(source: Buffer, width: number): Promise<Buffer | null> {
try {
const { default: sharp } = await import('sharp');
return await sharp(source).resize({ width, withoutEnlargement: true }).toBuffer();
} catch {
return null;
}
}
function imageResponse(body: Buffer, filename: string): Response {
const contentType =
CONTENT_TYPE_BY_EXTENSION[path.extname(filename).toLowerCase()] ?? 'application/octet-stream';
return new Response(new Uint8Array(body), {
status: 200,
headers: {
'Content-Type': contentType,
'Content-Length': String(body.byteLength),
'Cache-Control': IMMUTABLE,
},
});
}
export async function GET(
_request: Request,
context: { params: Promise<{ path?: string[] }> },
): Promise<Response> {
const { path: segments = [] } = await context.params;
if (segments.length === 0 || segments.length > 2) return new Response('Not found', { status: 404 });
if (!segments.every((segment) => SAFE_SEGMENT.test(segment) && segment !== '..')) {
return new Response('Not found', { status: 404 });
}
const sizeSegment = segments.length === 2 ? segments[0] : null;
const filename = segments[segments.length - 1];
if (sizeSegment !== null && !(sizeSegment in THUMB_SIZES)) {
return new Response('Not found', { status: 404 });
}
if (!(path.extname(filename).toLowerCase() in CONTENT_TYPE_BY_EXTENSION)) {
return new Response('Not found', { status: 404 });
}
// Original image requested.
if (sizeSegment === null) {
const original = await findOriginal(filename);
if (!original) return new Response('Not found', { status: 404 });
return imageResponse(original.buffer, filename);
}
// Cached thumbnail already on disk?
for (const root of UPLOAD_ROOTS) {
const cached = await readIfFile(path.join(root, sizeSegment, filename));
if (cached) return imageResponse(cached, filename);
}
// Generate the thumbnail on demand and cache it next to the original.
const original = await findOriginal(filename);
if (!original) return new Response('Not found', { status: 404 });
const thumbnail = await buildThumbnail(original.buffer, THUMB_SIZES[sizeSegment]);
if (!thumbnail) return imageResponse(original.buffer, filename);
try {
const cacheDir = path.join(original.root, sizeSegment);
await mkdir(cacheDir, { recursive: true });
await writeFile(path.join(cacheDir, filename), thumbnail);
} catch {
// Serving the thumbnail matters more than caching it.
}
return imageResponse(thumbnail, filename);
}

View File

@@ -75,8 +75,12 @@ export default function CheckoutClient() {
notes: form.notes,
}),
});
if (res.status === 401) {
setError('Debes iniciar sesión para finalizar el pedido.');
return;
}
if (!res.ok) {
const data = await res.json();
const data = await res.json().catch(() => ({}));
throw new Error(data.error?.message || 'Error al procesar el pedido');
}
const { orderId } = await res.json();