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:
co-authored by
Cursor
parent
b36c07e28f
commit
5a6ef9e0e0
@@ -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}",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
}
|
||||
],
|
||||
"templates": {
|
||||
"userProduct": "/user-products/{slug}",
|
||||
"userProduct": "/user-products/{id}/{slug}",
|
||||
"blog": "/blog/{slug}"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user