Route categories to /products/category/{id}/{slug}, improve menu hover/layout, and hide stock/category clutter on product cards.
Co-authored-by: Cursor <cursoragent@cursor.com>
298 lines
9.2 KiB
TypeScript
298 lines
9.2 KiB
TypeScript
import type { Product } from "@/lib/data";
|
|
import { meshkeeGet, tenantPath } from "@/lib/meshkee";
|
|
import { detailPath, slugifyForUrl } from "@/lib/seo-slug";
|
|
|
|
export type StoreVariant = {
|
|
id: string;
|
|
storeItemId?: string;
|
|
productId?: string;
|
|
productTitle?: string | null;
|
|
productNameFa?: string | null;
|
|
productImage?: string | null;
|
|
productTotalStock?: number | null;
|
|
label?: string | null;
|
|
price?: number | null;
|
|
discountedPrice?: number | null;
|
|
stockQuantity?: number | null;
|
|
isFestival?: boolean;
|
|
selections?: { variationName?: string; value?: string }[];
|
|
};
|
|
|
|
export type StoreSpecial = {
|
|
id: string;
|
|
key: string;
|
|
title: string;
|
|
items?: StoreSpecialProduct[];
|
|
};
|
|
|
|
export type StoreSpecialProduct = {
|
|
id: string;
|
|
productId?: string;
|
|
productTitle?: string | null;
|
|
productNameFa?: string | null;
|
|
productImage?: string | null;
|
|
isActive?: boolean;
|
|
sortOrder?: number | null;
|
|
variants?: StoreVariant[];
|
|
};
|
|
|
|
export type CatalogProduct = {
|
|
id: string;
|
|
title?: string | null;
|
|
nameFa?: string | null;
|
|
slug?: string;
|
|
summary?: string | null;
|
|
descriptionHtml?: string | null;
|
|
brand?: { name?: string; nameFa?: string } | string | null;
|
|
thumbnailUrl?: string | null;
|
|
image?: string | null;
|
|
thumbnail?: string | null;
|
|
images?: Array<string | { url?: string | null }>;
|
|
categoryNameFa?: string | null;
|
|
categoryName?: string | null;
|
|
commentCount?: number;
|
|
store?: {
|
|
variantCount?: number | null;
|
|
minPrice?: number | null;
|
|
maxPrice?: number | null;
|
|
inStock?: boolean | null;
|
|
} | null;
|
|
};
|
|
|
|
export type Paged<T> = {
|
|
items: T[];
|
|
total: number;
|
|
page: number;
|
|
pageSize: number;
|
|
};
|
|
|
|
const PLACEHOLDER = "/placeholders/product.svg";
|
|
|
|
function asNumber(value: unknown) {
|
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
if (typeof value === "string" && value.trim()) {
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function discountPercent(price?: number | null, discounted?: number | null) {
|
|
if (!price || !discounted || discounted >= price) return undefined;
|
|
return Math.round((1 - discounted / price) * 100);
|
|
}
|
|
|
|
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(productId),
|
|
href: productHref(productId, title),
|
|
title,
|
|
brand: variant.selections?.map((item) => item.value).filter(Boolean).join(" / ") || variant.label || "",
|
|
price: price ?? undefined,
|
|
oldPrice,
|
|
discount: discountPercent(asNumber(variant.price), asNumber(variant.discountedPrice)),
|
|
image: variant.productImage || PLACEHOLDER,
|
|
inStock: inStock(variant.stockQuantity ?? variant.productTotalStock),
|
|
};
|
|
}
|
|
|
|
export function specialItemToProduct(item: StoreSpecialProduct): Product | null {
|
|
if (item.isActive === false) return null;
|
|
|
|
const variant = item.variants?.[0];
|
|
if (variant) {
|
|
return variantToProduct({
|
|
...variant,
|
|
productId: item.productId ?? variant.productId,
|
|
productTitle: item.productTitle ?? variant.productTitle,
|
|
productNameFa: item.productNameFa ?? variant.productNameFa,
|
|
productImage: item.productImage ?? variant.productImage,
|
|
});
|
|
}
|
|
|
|
// Product-source specials often ship with empty variants[].
|
|
const title = item.productNameFa?.trim() || item.productTitle?.trim();
|
|
if (!title && !item.productImage) return null;
|
|
|
|
const id = String(item.productId ?? item.id);
|
|
return {
|
|
id,
|
|
href: productHref(id, title || id),
|
|
title: title || "محصول",
|
|
brand: "",
|
|
image: item.productImage || PLACEHOLDER,
|
|
inStock: true,
|
|
};
|
|
}
|
|
|
|
export async function fetchStoreItems(params: {
|
|
page?: number;
|
|
pageSize?: number;
|
|
categoryId?: string;
|
|
name?: string;
|
|
} = {}) {
|
|
const query = new URLSearchParams();
|
|
query.set("page", String(params.page ?? 1));
|
|
query.set("pageSize", String(params.pageSize ?? 20));
|
|
if (params.categoryId) query.set("categoryId", params.categoryId);
|
|
if (params.name) query.set("name", params.name);
|
|
return meshkeeGet<Paged<StoreVariant>>(tenantPath(`/store-items?${query.toString()}`), {
|
|
items: [],
|
|
total: 0,
|
|
page: 1,
|
|
pageSize: params.pageSize ?? 20,
|
|
});
|
|
}
|
|
|
|
export async function fetchStoreVariant(variantId: string) {
|
|
const data = await meshkeeGet<{ variant?: StoreVariant }>(
|
|
tenantPath(`/store-items/${encodeURIComponent(variantId)}`),
|
|
{},
|
|
);
|
|
return data.variant ?? null;
|
|
}
|
|
|
|
export async function fetchStoreItemByProduct(productId: string) {
|
|
return meshkeeGet<{ storeItem?: { id?: string; productId?: string; variants?: StoreVariant[] } }>(
|
|
tenantPath(`/store-items/by-product/${encodeURIComponent(productId)}`),
|
|
{},
|
|
);
|
|
}
|
|
|
|
export async function fetchStoreSpecials() {
|
|
const data = await meshkeeGet<{ items?: StoreSpecial[]; source?: string }>(tenantPath("/store-specials"), {
|
|
items: [],
|
|
});
|
|
return data.items ?? [];
|
|
}
|
|
|
|
export async function fetchSpecialProducts(key: string) {
|
|
const specials = await fetchStoreSpecials();
|
|
const special = specials.find((item) => item.key === key);
|
|
const rawItems = [...(special?.items ?? [])]
|
|
.filter((item) => item.isActive !== false)
|
|
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
|
|
|
const products = rawItems
|
|
.map((item) => specialItemToProduct(item))
|
|
.filter((item): item is Product => Boolean(item));
|
|
|
|
return { special, products };
|
|
}
|
|
|
|
export function galleryUrls(product?: CatalogProduct | null, fallback?: string | null) {
|
|
const urls: string[] = [];
|
|
for (const image of product?.images ?? []) {
|
|
const url = typeof image === "string" ? image : image?.url;
|
|
if (url) urls.push(url);
|
|
}
|
|
for (const url of [product?.image, product?.thumbnailUrl, product?.thumbnail, fallback]) {
|
|
if (url && !urls.includes(url)) urls.push(url);
|
|
}
|
|
return urls.length ? urls : [PLACEHOLDER];
|
|
}
|
|
|
|
export async function fetchCatalogProducts(params: {
|
|
page?: number;
|
|
pageSize?: number;
|
|
categoryId?: string;
|
|
name?: string;
|
|
} = {}) {
|
|
const query = new URLSearchParams();
|
|
query.set("page", String(params.page ?? 1));
|
|
query.set("pageSize", String(params.pageSize ?? 20));
|
|
if (params.categoryId) query.set("categoryId", params.categoryId);
|
|
if (params.name) query.set("name", params.name);
|
|
return meshkeeGet<Paged<CatalogProduct>>(tenantPath(`/products?${query.toString()}`), {
|
|
items: [],
|
|
total: 0,
|
|
page: 1,
|
|
pageSize: params.pageSize ?? 20,
|
|
});
|
|
}
|
|
|
|
export function catalogProductToCard(product: CatalogProduct): Product {
|
|
const price = asNumber(product.store?.minPrice);
|
|
return {
|
|
id: String(product.id),
|
|
href: productHref(product),
|
|
title: catalogTitle(product),
|
|
brand: brandName(product),
|
|
price: price ?? undefined,
|
|
image: product.thumbnailUrl || product.image || product.thumbnail || PLACEHOLDER,
|
|
inStock: product.store?.inStock !== false,
|
|
};
|
|
}
|
|
|
|
export async function fetchCatalogProductById(productId: string) {
|
|
const data = await meshkeeGet<{ product?: CatalogProduct }>(
|
|
tenantPath(`/products/by-id/${encodeURIComponent(productId)}`),
|
|
{},
|
|
);
|
|
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)}`),
|
|
{},
|
|
);
|
|
return data.product ?? null;
|
|
}
|