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 <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-15 15:41:31 +03:30
co-authored by Cursor
parent b36c07e28f
commit 5a6ef9e0e0
7 changed files with 271 additions and 178 deletions
+1 -1
View File
@@ -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}",
},
};
+193
View File
@@ -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<Metadata> {
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 (
<SiteShell
banner={{
kicker: "Product Details",
title,
description: [category, condition].filter(Boolean).join(" · ") || undefined,
}}
>
<article className="section">
<div className="container-page">
<div className="mb-6 text-sm text-muted">
<Link href="/user-products" className="text-brand hover:underline">
محصولات
</Link>
<span className="mx-2">/</span>
<span>{title}</span>
</div>
<div className="grid gap-8 lg:grid-cols-[1.1fr_0.9fr]">
<ProductGallery images={gallery} alt={title} />
<div>
{product.titleEn ? (
<p className="font-industrial text-sm tracking-[0.14em] text-brand">
{product.titleEn}
</p>
) : null}
<h2 className="mt-2 text-3xl font-extrabold leading-tight text-brand-ink md:text-4xl">
{title}
</h2>
{category ? (
<p className="mt-3 text-sm font-bold text-muted">{category}</p>
) : null}
<div className="mt-6 grid gap-3 sm:grid-cols-2">
{condition ? (
<div className="border border-brand/25 bg-brand/[0.06] px-4 py-3">
<p className="font-industrial text-[0.65rem] tracking-[0.16em] text-brand">
CONDITION
</p>
<p className="mt-1 text-base font-extrabold text-brand-ink">
{condition}
</p>
</div>
) : null}
{city || country ? (
<div className="border border-line bg-[#f7fafc] px-4 py-3">
<p className="font-industrial text-[0.65rem] tracking-[0.16em] text-steel">
LOCATION
</p>
<p className="mt-1 text-base font-extrabold text-brand-ink">
{[city, country].filter(Boolean).join("، ")}
</p>
</div>
) : null}
</div>
{price ? (
<p
dir="ltr"
className="font-industrial mt-6 text-left text-2xl font-extrabold tracking-wide text-brand"
>
{price}
</p>
) : (
<p className="mt-6 text-base font-bold text-muted">
قیمت با استعلام
</p>
)}
<div className="mt-8 flex justify-end">
<a href={`tel:${phone}`} className="btn-primary">
تماس برای استعلام
</a>
</div>
</div>
</div>
<ProductTabs
description={description}
technicalNotes={product.technicalNotes}
deliveryNotes={deliveryNotes}
technicalValues={product.technicalValues}
technicalInfo={technicalInfo}
/>
{similarProducts.length > 0 ? (
<Products
embedded
items={similarProducts}
total={similarResult.total}
kicker="Similar Products"
title="محصولات مشابه"
description="سایر ماشین‌آلات موجود در همین دسته‌بندی که ممکن است برای شما مناسب باشند."
showMoreLink={false}
titleHref={
product.categoryId
? `/user-products?categoryId=${product.categoryId}`
: "/user-products"
}
moreLabel="مشاهده همه در این دسته"
/>
) : null}
<ProductMarketplaceCtas phone={phone} />
</div>
</article>
</SiteShell>
);
}
+5 -174
View File
@@ -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<Metadata> {
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 (
<SiteShell
banner={{
kicker: "Product Details",
title,
description: [category, condition].filter(Boolean).join(" · ") || undefined,
}}
>
<article className="section">
<div className="container-page">
<div className="mb-6 text-sm text-muted">
<Link href="/user-products" className="text-brand hover:underline">
محصولات
</Link>
<span className="mx-2">/</span>
<span>{title}</span>
</div>
<div className="grid gap-8 lg:grid-cols-[1.1fr_0.9fr]">
<ProductGallery images={gallery} alt={title} />
<div>
{product.titleEn ? (
<p className="font-industrial text-sm tracking-[0.14em] text-brand">
{product.titleEn}
</p>
) : null}
<h2 className="mt-2 text-3xl font-extrabold leading-tight text-brand-ink md:text-4xl">
{title}
</h2>
{category ? (
<p className="mt-3 text-sm font-bold text-muted">{category}</p>
) : null}
<div className="mt-6 grid gap-3 sm:grid-cols-2">
{condition ? (
<div className="border border-brand/25 bg-brand/[0.06] px-4 py-3">
<p className="font-industrial text-[0.65rem] tracking-[0.16em] text-brand">
CONDITION
</p>
<p className="mt-1 text-base font-extrabold text-brand-ink">
{condition}
</p>
</div>
) : null}
{city || country ? (
<div className="border border-line bg-[#f7fafc] px-4 py-3">
<p className="font-industrial text-[0.65rem] tracking-[0.16em] text-steel">
LOCATION
</p>
<p className="mt-1 text-base font-extrabold text-brand-ink">
{[city, country].filter(Boolean).join("، ")}
</p>
</div>
) : null}
</div>
{price ? (
<p
dir="ltr"
className="font-industrial mt-6 text-left text-2xl font-extrabold tracking-wide text-brand"
>
{price}
</p>
) : (
<p className="mt-6 text-base font-bold text-muted">
قیمت با استعلام
</p>
)}
<div className="mt-8 flex justify-end">
<a href={`tel:${phone}`} className="btn-primary">
تماس برای استعلام
</a>
</div>
</div>
</div>
<ProductTabs
description={description}
technicalNotes={product.technicalNotes}
deliveryNotes={deliveryNotes}
technicalValues={product.technicalValues}
technicalInfo={technicalInfo}
/>
{similarProducts.length > 0 ? (
<Products
embedded
items={similarProducts}
total={similarResult.total}
kicker="Similar Products"
title="محصولات مشابه"
description="سایر ماشین‌آلات موجود در همین دسته‌بندی که ممکن است برای شما مناسب باشند."
showMoreLink={false}
titleHref={
product.categoryId
? `/user-products?categoryId=${product.categoryId}`
: "/user-products"
}
moreLabel="مشاهده همه در این دسته"
/>
) : null}
<ProductMarketplaceCtas phone={phone} />
</div>
</article>
</SiteShell>
);
redirect(userProductHref(product));
}
+2 -1
View File
@@ -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 (
<article
+2
View File
@@ -44,6 +44,8 @@ export type UserProductCondition =
export type UserProductListItem = {
id: string;
slug: string;
/** SEO path segment for `/user-products/{id}/{pathSlug}`. */
pathSlug?: string | null;
titleFa: string | null;
titleEn: string | null;
price: number | string | null;
+67 -1
View File
@@ -61,6 +61,40 @@ const emptyPage = <T,>(page = 1, pageSize = 12): Paginated<T> => ({
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<UserProductListItem, "pathSlug" | "titleFa" | "titleEn">,
) {
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<UserProductListItem, "id" | "pathSlug" | "titleFa" | "titleEn">,
) {
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<UserProductListItem>(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<UserProductTechnicalInfo>(
`/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<UserProductTechnicalInfo>(
+1 -1
View File
@@ -33,7 +33,7 @@
}
],
"templates": {
"userProduct": "/user-products/{slug}",
"userProduct": "/user-products/{id}/{slug}",
"blog": "/blog/{slug}"
}
}