From 5a6ef9e0e049a693e11791977febcec46c0200b4 Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Tue, 15 Sep 2026 15:41:31 +0330 Subject: [PATCH] Route user products as /user-products/{id}/{slug}. Load details via by-id, build links from pathSlug/title, redirect legacy slug URLs, and update the sitemap template. Co-authored-by: Cursor --- scripts/generate-sitemap-config.mjs | 2 +- src/app/user-products/[id]/[slug]/page.tsx | 193 +++++++++++++++++++++ src/app/user-products/[slug]/page.tsx | 179 +------------------ src/components/Products.tsx | 3 +- src/lib/api/types.ts | 2 + src/lib/api/user-products.ts | 68 +++++++- src/lib/sitemap-config.generated.json | 2 +- 7 files changed, 271 insertions(+), 178 deletions(-) create mode 100644 src/app/user-products/[id]/[slug]/page.tsx diff --git a/scripts/generate-sitemap-config.mjs b/scripts/generate-sitemap-config.mjs index 4536f1a..c74dd0a 100644 --- a/scripts/generate-sitemap-config.mjs +++ b/scripts/generate-sitemap-config.mjs @@ -96,7 +96,7 @@ function main() { staticPages, // Mashinify public paths differ from Meshkee catalog defaults. templates: { - userProduct: "/user-products/{slug}", + userProduct: "/user-products/{id}/{slug}", blog: "/blog/{slug}", }, }; diff --git a/src/app/user-products/[id]/[slug]/page.tsx b/src/app/user-products/[id]/[slug]/page.tsx new file mode 100644 index 0000000..8efc73e --- /dev/null +++ b/src/app/user-products/[id]/[slug]/page.tsx @@ -0,0 +1,193 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; +import { ProductGallery } from "@/components/ProductGallery"; +import { ProductMarketplaceCtas } from "@/components/ProductMarketplaceCtas"; +import { ProductTabs } from "@/components/ProductTabs"; +import { Products } from "@/components/Products"; +import { SiteShell } from "@/components/SiteShell"; +import { getPrimaryPhoneNumber } from "@/lib/api/business"; +import { + formatCondition, + formatPrice, + getUserProductByIdOrNull, + getUserProductTechnicalInfoById, + listUserProducts, + productCategory, + productTitle, + userProductHref, + userProductPathSlug, +} from "@/lib/api/user-products"; +import { resolveProductGallery } from "@/lib/user-product-gallery"; + +type PageProps = { + params: Promise<{ id: string; slug: string }>; +}; + +export async function generateMetadata({ + params, +}: PageProps): Promise { + const { id } = await params; + const product = await getUserProductByIdOrNull(id); + if (!product) return { title: "محصول یافت نشد" }; + const title = productTitle(product); + const description = + product.descriptionFa || product.description || product.descriptionEn || title; + const image = product.imageUrl || product.images?.[0]?.url || undefined; + return { + title, + description, + openGraph: { + title, + description, + ...(image ? { images: [{ url: image, alt: title }] } : {}), + }, + }; +} + +export default async function UserProductDetailPage({ params }: PageProps) { + const { id, slug } = await params; + const product = await getUserProductByIdOrNull(id); + if (!product) notFound(); + + const canonicalSlug = userProductPathSlug(product); + if (slug !== canonicalSlug) { + redirect(userProductHref(product)); + } + + const title = productTitle(product); + const category = productCategory(product); + const condition = formatCondition(product.condition); + const price = formatPrice(product.price, product.priceCurrency); + const gallery = resolveProductGallery(product); + const city = product.cityNameFa || product.cityName || null; + const country = product.countryNameFa || product.countryName || null; + const description = + product.descriptionFa || product.description || product.descriptionEn; + const deliveryNotes = product.deliveryNotes ?? product.deliveryNote ?? null; + const [technicalInfo, similarResult, phone] = await Promise.all([ + getUserProductTechnicalInfoById(id), + product.categoryId + ? listUserProducts({ + categoryId: product.categoryId, + pageSize: 5, + }) + : Promise.resolve({ items: [], total: 0 }), + getPrimaryPhoneNumber(), + ]); + + const similarProducts = similarResult.items + .filter((item) => item.id !== product.id) + .slice(0, 4); + + return ( + +
+
+
+ + محصولات + + / + {title} +
+ +
+ + +
+ {product.titleEn ? ( +

+ {product.titleEn} +

+ ) : null} +

+ {title} +

+ {category ? ( +

{category}

+ ) : null} + +
+ {condition ? ( +
+

+ CONDITION +

+

+ {condition} +

+
+ ) : null} + + {city || country ? ( +
+

+ LOCATION +

+

+ {[city, country].filter(Boolean).join("، ")} +

+
+ ) : null} +
+ + {price ? ( +

+ {price} +

+ ) : ( +

+ قیمت با استعلام +

+ )} + + +
+
+ + + + {similarProducts.length > 0 ? ( + + ) : null} + + +
+
+
+ ); +} diff --git a/src/app/user-products/[slug]/page.tsx b/src/app/user-products/[slug]/page.tsx index bc86a9c..14a0789 100644 --- a/src/app/user-products/[slug]/page.tsx +++ b/src/app/user-products/[slug]/page.tsx @@ -1,186 +1,17 @@ -import type { Metadata } from "next"; -import Link from "next/link"; -import { notFound } from "next/navigation"; -import { ProductGallery } from "@/components/ProductGallery"; -import { ProductMarketplaceCtas } from "@/components/ProductMarketplaceCtas"; -import { ProductTabs } from "@/components/ProductTabs"; -import { Products } from "@/components/Products"; -import { SiteShell } from "@/components/SiteShell"; -import { getPrimaryPhoneNumber } from "@/lib/api/business"; +import { notFound, redirect } from "next/navigation"; import { - formatCondition, - formatPrice, getUserProductBySlugOrNull, - getUserProductTechnicalInfo, - listUserProducts, - productCategory, - productTitle, + userProductHref, } from "@/lib/api/user-products"; -import { resolveProductGallery } from "@/lib/user-product-gallery"; type PageProps = { params: Promise<{ slug: string }>; }; -export async function generateMetadata({ - params, -}: PageProps): Promise { - const { slug } = await params; - const product = await getUserProductBySlugOrNull(slug); - if (!product) return { title: "محصول یافت نشد" }; - const title = productTitle(product); - const description = - product.descriptionFa || product.description || product.descriptionEn || title; - const image = product.imageUrl || product.images?.[0]?.url || undefined; - return { - title, - description, - openGraph: { - title, - description, - ...(image ? { images: [{ url: image, alt: title }] } : {}), - }, - }; -} - -export default async function UserProductDetailPage({ params }: PageProps) { +/** Legacy `/user-products/{db-slug}` → canonical `/user-products/{id}/{pathSlug}`. */ +export default async function LegacyUserProductRedirect({ params }: PageProps) { const { slug } = await params; const product = await getUserProductBySlugOrNull(slug); if (!product) notFound(); - - const title = productTitle(product); - const category = productCategory(product); - const condition = formatCondition(product.condition); - const price = formatPrice(product.price, product.priceCurrency); - const gallery = resolveProductGallery(product); - const city = product.cityNameFa || product.cityName || null; - const country = product.countryNameFa || product.countryName || null; - const description = - product.descriptionFa || product.description || product.descriptionEn; - const deliveryNotes = product.deliveryNotes ?? product.deliveryNote ?? null; - const [technicalInfo, similarResult, phone] = await Promise.all([ - getUserProductTechnicalInfo(slug), - product.categoryId - ? listUserProducts({ - categoryId: product.categoryId, - pageSize: 5, - }) - : Promise.resolve({ items: [], total: 0 }), - getPrimaryPhoneNumber(), - ]); - - const similarProducts = similarResult.items - .filter((item) => item.id !== product.id) - .slice(0, 4); - - return ( - -
-
-
- - محصولات - - / - {title} -
- -
- - -
- {product.titleEn ? ( -

- {product.titleEn} -

- ) : null} -

- {title} -

- {category ? ( -

{category}

- ) : null} - -
- {condition ? ( -
-

- CONDITION -

-

- {condition} -

-
- ) : null} - - {city || country ? ( -
-

- LOCATION -

-

- {[city, country].filter(Boolean).join("، ")} -

-
- ) : null} -
- - {price ? ( -

- {price} -

- ) : ( -

- قیمت با استعلام -

- )} - - -
-
- - - - {similarProducts.length > 0 ? ( - - ) : null} - - -
-
-
- ); + redirect(userProductHref(product)); } diff --git a/src/components/Products.tsx b/src/components/Products.tsx index 023e5f8..886c2c1 100644 --- a/src/components/Products.tsx +++ b/src/components/Products.tsx @@ -5,6 +5,7 @@ import { formatPrice, productCategory, productTitle, + userProductHref, } from "@/lib/api/user-products"; import type { UserProductListItem } from "@/lib/api/types"; @@ -104,7 +105,7 @@ export function Products({ const category = productCategory(product); const condition = formatCondition(product.condition); const price = formatPrice(product.price, product.priceCurrency); - const href = `/user-products/${product.slug}`; + const href = userProductHref(product); return (
(page = 1, pageSize = 12): Paginated => ({ pageSize, }); +/** Match Meshkee `slugifyForUrl` — keep Persian/Latin letters + digits. */ +export function slugifyForUrl(value: string, fallback = "user-product") { + const slug = value + .trim() + .normalize("NFC") + .replace(/\s+/g, "-") + .replace( + /[^\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFFa-zA-Z0-9-]+/g, + "", + ) + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); + + return slug || fallback; +} + +export function userProductPathSlug( + product: Pick, +) { + const fromApi = product.pathSlug?.trim(); + if (fromApi) return fromApi; + return slugifyForUrl( + product.titleFa?.trim() || product.titleEn?.trim() || "", + "user-product", + ); +} + +/** Canonical storefront path: `/user-products/{id}/{pathSlug}`. */ +export function userProductHref( + product: Pick, +) { + return `/user-products/${encodeURIComponent(product.id)}/${encodeURIComponent(userProductPathSlug(product))}`; +} + export async function listUserProducts(params: ListUserProductsParams = {}) { const page = params.page ?? 1; const pageSize = params.pageSize ?? 12; @@ -81,6 +115,24 @@ export async function listUserProducts(params: ListUserProductsParams = {}) { return emptyPage(page, pageSize); } } + +export async function getUserProductById(id: string) { + const data = await apiGet<{ product: UserProduct }>( + `/user-products/by-id/${encodeURIComponent(id)}`, + ); + return data.product; +} + +export async function getUserProductByIdOrNull(id: string) { + try { + return await getUserProductById(id); + } catch (error) { + if (error instanceof ApiError && error.status === 404) return null; + throw error; + } +} + +/** @deprecated Prefer `getUserProductById` — kept for legacy slug redirects. */ export async function getUserProductBySlug(slug: string) { const data = await apiGet<{ product: UserProduct }>( `/user-products/${encodeURIComponent(slug)}`, @@ -97,7 +149,21 @@ export async function getUserProductBySlugOrNull(slug: string) { } } -/** Labels + values for specs table. Join form.fields[].id ↔ values[].fieldId. */ +export async function getUserProductTechnicalInfoById(id: string) { + try { + return await apiGet( + `/user-products/by-id/${encodeURIComponent(id)}/technical-info`, + ); + } catch (error) { + if (error instanceof ApiError && error.status === 404) { + return { form: null, values: [] } satisfies UserProductTechnicalInfo; + } + console.error("getUserProductTechnicalInfoById failed", error); + return { form: null, values: [] } satisfies UserProductTechnicalInfo; + } +} + +/** @deprecated Prefer `getUserProductTechnicalInfoById`. */ export async function getUserProductTechnicalInfo(slug: string) { try { return await apiGet( diff --git a/src/lib/sitemap-config.generated.json b/src/lib/sitemap-config.generated.json index d09a0e4..60a6371 100644 --- a/src/lib/sitemap-config.generated.json +++ b/src/lib/sitemap-config.generated.json @@ -33,7 +33,7 @@ } ], "templates": { - "userProduct": "/user-products/{slug}", + "userProduct": "/user-products/{id}/{slug}", "blog": "/blog/{slug}" } }