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:
co-authored by
Cursor
parent
24b5649abd
commit
0cb9aa57e2
+17
-3
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 || "";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user