Use Meshkee {id}/{fa-slug} detail URLs for products and blogs.

Resolve pages via by-id APIs, slugify Farsi titles for public paths, and redirect legacy slug-only URLs to the canonical form.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-22 23:52:45 +03:30
co-authored by Cursor
parent 24b5649abd
commit 0cb9aa57e2
14 changed files with 426 additions and 307 deletions
+17 -3
View File
@@ -1,4 +1,5 @@
import { meshkeeGet, tenantPath } from "@/lib/meshkee";
import { detailPath, slugifyForUrl } from "@/lib/seo-slug";
export type BlogPost = {
id: string;
@@ -32,7 +33,16 @@ export async function fetchBlogs(params: { page?: number; pageSize?: number } =
});
}
export async function fetchBlog(slug: string) {
export async function fetchBlogById(id: string) {
const data = await meshkeeGet<{ blog?: BlogPost }>(
tenantPath(`/blogs/by-id/${encodeURIComponent(id)}`),
{},
);
return data.blog ?? null;
}
/** @deprecated Prefer fetchBlogById for public detail pages. */
export async function fetchBlogBySlug(slug: string) {
const data = await meshkeeGet<{ blog?: BlogPost }>(tenantPath(`/blogs/${encodeURIComponent(slug)}`), {});
return data.blog ?? null;
}
@@ -41,6 +51,10 @@ export function blogImage(post: BlogPost) {
return post.titleImageUrl || "/placeholders/blog.svg";
}
export function blogHref(post: BlogPost) {
return `/blog/${encodeURIComponent(post.slug)}`;
export function blogHref(post: Pick<BlogPost, "id" | "title">) {
return detailPath("/blog", post.id, post.title);
}
export function blogTitleSlug(title: string) {
return slugifyForUrl(title);
}
+20
View File
@@ -0,0 +1,20 @@
/**
* Build a URL path segment from a title (Persian / Arabic / Latin).
* Keeps letters and digits; spaces → hyphens; strips other punctuation.
*/
export function slugifyForUrl(value: string, fallback = "item"): string {
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 detailPath(base: string, id: string | number, title: string) {
const slug = slugifyForUrl(title);
return `${base}/${encodeURIComponent(String(id))}/${encodeURIComponent(slug)}`;
}
+62 -43
View File
@@ -1,5 +1,6 @@
import type { Product } from "@/lib/data";
import { meshkeeGet, tenantPath } from "@/lib/meshkee";
import { detailPath, slugifyForUrl } from "@/lib/seo-slug";
export type StoreVariant = {
id: string;
@@ -85,17 +86,45 @@ function inStock(quantity?: number | null) {
return quantity == null || quantity > 0;
}
export function brandName(product?: CatalogProduct | null) {
const brand = product?.brand;
if (!brand) return "";
if (typeof brand === "string") return brand;
return brand.nameFa || brand.name || "";
}
export function variantTitle(variant: StoreVariant) {
return variant.productNameFa?.trim() || variant.productTitle?.trim() || variant.label?.trim() || "محصول";
}
export function catalogTitle(product: Pick<CatalogProduct, "nameFa" | "title" | "id">) {
return product.nameFa?.trim() || product.title?.trim() || String(product.id);
}
/** Public product URL: /products/{id}/{nameFaSlug} — never use legacy DB slug. */
export function productHref(
productOrId: string | number | Pick<CatalogProduct, "id" | "nameFa" | "title">,
title?: string | null,
) {
if (typeof productOrId === "object") {
return detailPath("/products", productOrId.id, catalogTitle(productOrId));
}
return detailPath("/products", productOrId, title?.trim() || String(productOrId));
}
export function productNameSlug(title: string) {
return slugifyForUrl(title);
}
export function variantToProduct(variant: StoreVariant): Product {
const price = asNumber(variant.discountedPrice) ?? asNumber(variant.price);
const oldPrice = asNumber(variant.discountedPrice) != null ? asNumber(variant.price) ?? undefined : undefined;
const productId = variant.productId ?? variant.id;
const title = variantTitle(variant);
return {
id: String(variant.id),
href: `/products/${encodeURIComponent(String(variant.id))}`,
title: variantTitle(variant),
id: String(productId),
href: productHref(productId, title),
title,
brand: variant.selections?.map((item) => item.value).filter(Boolean).join(" / ") || variant.label || "",
price: price ?? undefined,
oldPrice,
@@ -105,7 +134,7 @@ export function variantToProduct(variant: StoreVariant): Product {
};
}
export function specialItemToProduct(item: StoreSpecialProduct, slug?: string | null): Product | null {
export function specialItemToProduct(item: StoreSpecialProduct): Product | null {
if (item.isActive === false) return null;
const variant = item.variants?.[0];
@@ -124,13 +153,9 @@ export function specialItemToProduct(item: StoreSpecialProduct, slug?: string |
if (!title && !item.productImage) return null;
const id = String(item.productId ?? item.id);
const href = slug
? `/products/${encodeURIComponent(slug)}`
: `/products?q=${encodeURIComponent(title || id)}`;
return {
id,
href,
href: productHref(id, title || id),
title: title || "محصول",
brand: "",
image: item.productImage || PLACEHOLDER,
@@ -186,19 +211,9 @@ export async function fetchSpecialProducts(key: string) {
.filter((item) => item.isActive !== false)
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
const products = (
await Promise.all(
rawItems.map(async (item) => {
if (item.variants?.[0]) return specialItemToProduct(item);
const catalog = await fetchCatalogProductByTitle(
item.productNameFa || item.productTitle,
item.productId,
);
return specialItemToProduct(item, catalog?.slug);
}),
)
).filter((item): item is Product => Boolean(item));
const products = rawItems
.map((item) => specialItemToProduct(item))
.filter((item): item is Product => Boolean(item));
return { special, products };
}
@@ -238,8 +253,8 @@ export function catalogProductToCard(product: CatalogProduct): Product {
const price = asNumber(product.store?.minPrice);
return {
id: String(product.id),
href: product.slug ? `/products/${encodeURIComponent(product.slug)}` : `/products?q=${encodeURIComponent(product.nameFa || product.title || product.id)}`,
title: product.nameFa?.trim() || product.title?.trim() || "محصول",
href: productHref(product),
title: catalogTitle(product),
brand: brandName(product) || product.categoryNameFa || "",
price: price ?? undefined,
image: product.thumbnailUrl || product.image || product.thumbnail || PLACEHOLDER,
@@ -247,21 +262,32 @@ export function catalogProductToCard(product: CatalogProduct): Product {
};
}
export async function fetchCatalogProductByTitle(title?: string | null, productId?: string | null) {
if (!title?.trim() && !productId) return null;
const query = title?.trim() || String(productId);
const data = await meshkeeGet<Paged<CatalogProduct>>(
tenantPath(`/products?name=${encodeURIComponent(query)}&pageSize=12`),
{ items: [], total: 0, page: 1, pageSize: 12 },
export async function fetchCatalogProductById(productId: string) {
const data = await meshkeeGet<{ product?: CatalogProduct }>(
tenantPath(`/products/by-id/${encodeURIComponent(productId)}`),
{},
);
const match =
data.items.find((item) => String(item.id) === String(productId)) ??
data.items[0] ??
null;
if (!match?.slug) return match;
return fetchCatalogProductBySlug(match.slug);
return data.product ?? null;
}
export async function fetchCatalogProductByTitle(title?: string | null, productId?: string | null) {
if (productId) {
const byId = await fetchCatalogProductById(String(productId));
if (byId) return byId;
}
if (!title?.trim()) return null;
const data = await meshkeeGet<Paged<CatalogProduct>>(
tenantPath(`/products?name=${encodeURIComponent(title.trim())}&pageSize=12`),
{ items: [], total: 0, page: 1, pageSize: 12 },
);
return (
data.items.find((item) => String(item.id) === String(productId)) ??
data.items[0] ??
null
);
}
/** @deprecated Prefer fetchCatalogProductById for public detail pages. */
export async function fetchCatalogProductBySlug(slug: string) {
const data = await meshkeeGet<{ product?: CatalogProduct }>(
tenantPath(`/products/${encodeURIComponent(slug)}`),
@@ -269,10 +295,3 @@ export async function fetchCatalogProductBySlug(slug: string) {
);
return data.product ?? null;
}
export function brandName(product?: CatalogProduct | null) {
const brand = product?.brand;
if (!brand) return "";
if (typeof brand === "string") return brand;
return brand.nameFa || brand.name || "";
}