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
|
// 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) {
|
||||||
|
|||||||
Reference in new issue
Block a user