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
+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}"
}
}