From cc7ddddf580e2364ca7e940fd8fa5e3bc2235f0c Mon Sep 17 00:00:00 2001 From: Cristian Felipe Cruz Buitron Date: Sat, 19 Sep 2026 19:38:07 +0000 Subject: [PATCH] fix: usar normalizeTitleForMatch en import de productos + notFound() en generateMetadata El importador de commerce-backend definia normalizeTitleForMatch (colapsa guion largo/comillas tipograficas) pero nunca la usaba en el matching de producto/categoria/coleccion -- seguia comparando con toLowerCase().trim() plano, por eso un re-import de "Toy Story 5 - Woody" con guion largo pegado desde Excel volvio a crear un duplicado en vez de actualizar el existente. En el storefront, cualquier slug de producto inexistente devolvia HTTP 200 en vez de 404 (confirmado con un slug inventado): el Suspense del navbar hace que el layout empiece a stream-ear la respuesta con status 200 antes de que notFound() se ejecute dentro de la pagina. Se mueve la verificacion a generateMetadata, que se resuelve completo antes de iniciar el stream. Co-Authored-By: Claude Sonnet 5 --- .../src/api/admin/product-import/route.ts | 53 ++++++++++++++----- .../ecommerce/app/product/[slug]/page.tsx | 12 ++++- 2 files changed, 51 insertions(+), 14 deletions(-) diff --git a/workloads/commerce-backend/src/api/admin/product-import/route.ts b/workloads/commerce-backend/src/api/admin/product-import/route.ts index b5b6031..8ebd923 100644 --- a/workloads/commerce-backend/src/api/admin/product-import/route.ts +++ b/workloads/commerce-backend/src/api/admin/product-import/route.ts @@ -38,11 +38,22 @@ import { csvRowsToObjects, parseCsv } from "../../../lib/csv"; // generaba un producto duplicado con handle distinto (confirmado en // vivo con "Hello Kitty & Friends": misma fila re-subida para sumar // fotos, dos productos con el mismo titulo en la tienda). El match es -// por titulo exacto (case-insensitive/trim), igual que categoria y -// coleccion; en un match, las fotos nuevas se agregan a las que ya -// tenia (no se reemplazan) y la descripcion se actualiza si la fila -// trae una. No toca precio/cantidad/categoria/coleccion del producto -// existente -- eso se edita a mano en el admin si hace falta. +// por titulo normalizado (case/espacios/guiones-comillas tipograficas, +// ver normalizeTitleForMatch), igual que categoria y coleccion; en un +// match, las fotos nuevas se agregan a las que ya tenia (no se +// reemplazan) y la descripcion se actualiza si la fila trae una. No +// toca precio/cantidad/categoria/coleccion del producto existente -- +// eso se edita a mano en el admin si hace falta. +// +// El match era por titulo exacto (case-insensitive/trim) hasta que +// volvio a duplicar "Toy Story 5 - Woody": la fila reimportada traia +// un guion largo "–" (pegado desde Excel/Sheets, que autocorrige "-" a +// en-dash) en vez del guion normal "-" del producto original, asi que +// el string no calzaba y se creo un producto nuevo en vez de +// actualizar el existente (confirmado en vivo 2026-09-19). Ademas de +// guiones, se normalizan comillas tipograficas por la misma razon -- +// cualquier CSV pegado desde un editor de texto enriquecido puede +// traerlas. // // Las fotos se descargan del origen y se re-alojan en el mismo // MinIO/S3 que ya usa el resto del catalogo (uploadFilesWorkflow -> @@ -88,6 +99,22 @@ type RowResult = { // mismos, ASCII-only, en vez de dejarselo al default de Medusa. El sufijo // corto evita colisiones entre titulos parecidos/repetidos sin necesidad // de revisar unicidad antes de crear. +// Colapsa variantes de guion (-, en-dash, em-dash, minus) y comillas +// tipograficas a su forma ASCII antes de comparar titulos, para que un +// re-import pegado desde Excel/Sheets/Word (que autocorrigen esos +// caracteres) siga matcheando el producto ya existente. Ver nota mas +// arriba sobre "Toy Story 5 - Woody". +function normalizeTitleForMatch(title: string): string { + return title + .normalize("NFKC") + .replace(/[‐-―−]/g, "-") + .replace(/[‘’‛]/g, "'") + .replace(/[“”‟]/g, '"') + .replace(/\s+/g, " ") + .trim() + .toLowerCase(); +} + function slugify(title: string, uniquePart: string): string { const base = title .normalize("NFD") @@ -190,13 +217,13 @@ export async function POST(req: MedusaRequest<{ csv: string }>, res: MedusaRespo } const categoryByName = new Map( - categories.filter((c) => c.name).map((c) => [c.name.toLowerCase().trim(), c.id]), + categories.filter((c) => c.name).map((c) => [normalizeTitleForMatch(c.name), c.id]), ); const collectionByName = new Map( - collections.filter((c) => c.title).map((c) => [c.title.toLowerCase().trim(), c.id]), + collections.filter((c) => c.title).map((c) => [normalizeTitleForMatch(c.title), c.id]), ); const productByTitle = new Map( - existingProducts.filter((p) => p.title).map((p) => [p.title.toLowerCase().trim(), p]), + existingProducts.filter((p) => p.title).map((p) => [normalizeTitleForMatch(p.title), p]), ); const results: RowResult[] = []; @@ -228,7 +255,7 @@ export async function POST(req: MedusaRequest<{ csv: string }>, res: MedusaRespo }, }); - const existingProduct = productByTitle.get(title.toLowerCase()); + const existingProduct = productByTitle.get(normalizeTitleForMatch(title)); if (existingProduct) { await updateProductsWorkflow(req.scope).run({ input: { @@ -262,7 +289,7 @@ export async function POST(req: MedusaRequest<{ csv: string }>, res: MedusaRespo if (!inventoryItemId) throw new Error("No se pudo crear el registro de inventario"); const categoryName = row.categoria?.trim(); - let categoryId = categoryName ? categoryByName.get(categoryName.toLowerCase()) : undefined; + let categoryId = categoryName ? categoryByName.get(normalizeTitleForMatch(categoryName)) : undefined; if (categoryName && !categoryId) { const { result: createdCategories } = await createProductCategoriesWorkflow(req.scope).run({ input: { @@ -272,11 +299,11 @@ export async function POST(req: MedusaRequest<{ csv: string }>, res: MedusaRespo }, }); categoryId = createdCategories[0].id; - categoryByName.set(categoryName.toLowerCase(), categoryId); + categoryByName.set(normalizeTitleForMatch(categoryName), categoryId); } const collectionName = row.coleccion?.trim(); - let collectionId = collectionName ? collectionByName.get(collectionName.toLowerCase()) : undefined; + let collectionId = collectionName ? collectionByName.get(normalizeTitleForMatch(collectionName)) : undefined; if (collectionName && !collectionId) { const { result: createdCollections } = await createCollectionsWorkflow(req.scope).run({ input: { @@ -286,7 +313,7 @@ export async function POST(req: MedusaRequest<{ csv: string }>, res: MedusaRespo }, }); collectionId = createdCollections[0].id; - collectionByName.set(collectionName.toLowerCase(), collectionId); + collectionByName.set(normalizeTitleForMatch(collectionName), collectionId); } await createProductsWorkflow(req.scope).run({ diff --git a/workloads/ecommerce/app/product/[slug]/page.tsx b/workloads/ecommerce/app/product/[slug]/page.tsx index ac48358..2574bb6 100644 --- a/workloads/ecommerce/app/product/[slug]/page.tsx +++ b/workloads/ecommerce/app/product/[slug]/page.tsx @@ -24,7 +24,17 @@ export const dynamic = "force-dynamic"; export async function generateMetadata({ params }: ProductPageProps): Promise { const { slug } = await params; const product = await getProductBySlug(slug); - return product ? { title: product.name, description: product.shortDescription, openGraph: { images: [product.image] } } : { title: "Producto" }; + // notFound() se llama aquí (no solo en el componente de abajo) porque + // generateMetadata se resuelve por completo ANTES de que Next empiece a + // transmitir la respuesta -- el del navbar (categorías, ver + // navbar.tsx) hace que el layout raíz empiece a stream-ear con status 200 + // antes de que un notFound() lanzado dentro del componente de la página + // pueda cambiarlo (confirmado en vivo: cualquier slug inexistente devolvía + // HTTP 200 con el contenido de app/not-found.tsx, rompiendo SEO/crawlers + // que sí miran el status code). Llamarlo aquí evita el problema porque esta + // fase bloquea el stream por diseño. + if (!product) notFound(); + return { title: product.name, description: product.shortDescription, openGraph: { images: [product.image] } }; } export default async function ProductPage({ params }: ProductPageProps) {