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
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:
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
|
||||
// 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({
|
||||
|
||||
@@ -24,7 +24,17 @@ export const dynamic = "force-dynamic";
|
||||
export async function generateMetadata({ params }: ProductPageProps): Promise<Metadata> {
|
||||
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 <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) {
|
||||
|
||||
Reference in new issue
Block a user