fix: usar normalizeTitleForMatch en import de productos + notFound() en generateMetadata
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 33s
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 23s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 20m5s
Build and Push Medusa / Construir y publicar Medusa (push) Successful in 27m10s

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 <[email protected]>
This commit is contained in:
devopsandClaude Sonnet 5 committed 2026-09-19 19:38:07 +00:00
1 parent 7dd2c139cb
commit cc7ddddf58
2 files changed
+51 -14

No files matched your search

@@ -38,11 +38,22 @@ import { csvRowsToObjects, parseCsv } from "../../../lib/csv";
// generaba un producto duplicado con handle distinto (confirmado en // generaba un producto duplicado con handle distinto (confirmado en
// vivo con "Hello Kitty & Friends": misma fila re-subida para sumar // vivo con "Hello Kitty & Friends": misma fila re-subida para sumar
// fotos, dos productos con el mismo titulo en la tienda). El match es // fotos, dos productos con el mismo titulo en la tienda). El match es
// por titulo exacto (case-insensitive/trim), igual que categoria y // por titulo normalizado (case/espacios/guiones-comillas tipograficas,
// coleccion; en un match, las fotos nuevas se agregan a las que ya // ver normalizeTitleForMatch), igual que categoria y coleccion; en un
// tenia (no se reemplazan) y la descripcion se actualiza si la fila // match, las fotos nuevas se agregan a las que ya tenia (no se
// trae una. No toca precio/cantidad/categoria/coleccion del producto // reemplazan) y la descripcion se actualiza si la fila trae una. No
// existente -- eso se edita a mano en el admin si hace falta. // 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 // Las fotos se descargan del origen y se re-alojan en el mismo
// MinIO/S3 que ya usa el resto del catalogo (uploadFilesWorkflow -> // 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 // mismos, ASCII-only, en vez de dejarselo al default de Medusa. El sufijo
// corto evita colisiones entre titulos parecidos/repetidos sin necesidad // corto evita colisiones entre titulos parecidos/repetidos sin necesidad
// de revisar unicidad antes de crear. // 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 { function slugify(title: string, uniquePart: string): string {
const base = title const base = title
.normalize("NFD") .normalize("NFD")
@@ -190,13 +217,13 @@ export async function POST(req: MedusaRequest<{ csv: string }>, res: MedusaRespo
} }
const categoryByName = new Map( 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( 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( 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[] = []; 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) { if (existingProduct) {
await updateProductsWorkflow(req.scope).run({ await updateProductsWorkflow(req.scope).run({
input: { 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"); if (!inventoryItemId) throw new Error("No se pudo crear el registro de inventario");
const categoryName = row.categoria?.trim(); const categoryName = row.categoria?.trim();
let categoryId = categoryName ? categoryByName.get(categoryName.toLowerCase()) : undefined; let categoryId = categoryName ? categoryByName.get(normalizeTitleForMatch(categoryName)) : undefined;
if (categoryName && !categoryId) { if (categoryName && !categoryId) {
const { result: createdCategories } = await createProductCategoriesWorkflow(req.scope).run({ const { result: createdCategories } = await createProductCategoriesWorkflow(req.scope).run({
input: { input: {
@@ -272,11 +299,11 @@ export async function POST(req: MedusaRequest<{ csv: string }>, res: MedusaRespo
}, },
}); });
categoryId = createdCategories[0].id; categoryId = createdCategories[0].id;
categoryByName.set(categoryName.toLowerCase(), categoryId); categoryByName.set(normalizeTitleForMatch(categoryName), categoryId);
} }
const collectionName = row.coleccion?.trim(); const collectionName = row.coleccion?.trim();
let collectionId = collectionName ? collectionByName.get(collectionName.toLowerCase()) : undefined; let collectionId = collectionName ? collectionByName.get(normalizeTitleForMatch(collectionName)) : undefined;
if (collectionName && !collectionId) { if (collectionName && !collectionId) {
const { result: createdCollections } = await createCollectionsWorkflow(req.scope).run({ const { result: createdCollections } = await createCollectionsWorkflow(req.scope).run({
input: { input: {
@@ -286,7 +313,7 @@ export async function POST(req: MedusaRequest<{ csv: string }>, res: MedusaRespo
}, },
}); });
collectionId = createdCollections[0].id; collectionId = createdCollections[0].id;
collectionByName.set(collectionName.toLowerCase(), collectionId); collectionByName.set(normalizeTitleForMatch(collectionName), collectionId);
} }
await createProductsWorkflow(req.scope).run({ await createProductsWorkflow(req.scope).run({
@@ -24,7 +24,17 @@ export const dynamic = "force-dynamic";
export async function generateMetadata({ params }: ProductPageProps): Promise<Metadata> { export async function generateMetadata({ params }: ProductPageProps): Promise<Metadata> {
const { slug } = await params; const { slug } = await params;
const product = await getProductBySlug(slug); 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 <Suspense> 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) { export default async function ProductPage({ params }: ProductPageProps) {