Wire Meshkee catalog: products, blog, mega menu, and product search.

Catalog-only (no cart); search targets store items and products pages.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-21 16:04:31 +03:30
co-authored by Cursor
parent cfa21cbee9
commit 8e14748b48
29 changed files with 1460 additions and 244 deletions
+46
View File
@@ -0,0 +1,46 @@
import { meshkeeGet, tenantPath } from "@/lib/meshkee";
export type BlogPost = {
id: string;
title: string;
slug: string;
abstract?: string | null;
mainTextHtml?: string | null;
titleImageUrl?: string | null;
publishedAt?: string | null;
createdAt?: string | null;
commentCount?: number;
author?: { name?: string } | string | null;
};
export type PagedBlogs = {
items: BlogPost[];
total: number;
page: number;
pageSize: number;
};
export async function fetchBlogs(params: { page?: number; pageSize?: number } = {}) {
const query = new URLSearchParams();
query.set("page", String(params.page ?? 1));
query.set("pageSize", String(params.pageSize ?? 12));
return meshkeeGet<PagedBlogs>(tenantPath(`/blogs?${query.toString()}`), {
items: [],
total: 0,
page: 1,
pageSize: params.pageSize ?? 12,
});
}
export async function fetchBlog(slug: string) {
const data = await meshkeeGet<{ blog?: BlogPost }>(tenantPath(`/blogs/${encodeURIComponent(slug)}`), {});
return data.blog ?? null;
}
export function blogImage(post: BlogPost) {
return post.titleImageUrl || "/placeholders/blog.svg";
}
export function blogHref(post: BlogPost) {
return `/blog/${encodeURIComponent(post.slug)}`;
}
+65
View File
@@ -0,0 +1,65 @@
import { meshkeeGet, tenantPath } from "@/lib/meshkee";
export type ApiCategory = {
id: string;
parentId?: string | null;
name?: string | null;
nameFa?: string | null;
slug?: string | null;
sortOrder?: number | null;
isActive?: boolean;
};
export type NavCategory = {
id: string;
title: string;
href: string;
image?: string;
children: { id: string; title: string; href: string }[];
};
function titleOf(category: ApiCategory) {
return category.nameFa?.trim() || category.name?.trim() || "دسته‌بندی";
}
function hrefOf(category: ApiCategory) {
return `/products?categoryId=${encodeURIComponent(category.id)}`;
}
export async function fetchProductCategories() {
const data = await meshkeeGet<{ items?: ApiCategory[] }>(tenantPath("/categories?entityType=product"), {
items: [],
});
return (data.items ?? []).filter((category) => category.isActive !== false);
}
export function buildCategoryTree(flat: ApiCategory[]): NavCategory[] {
const byId = new Map(flat.map((category) => [String(category.id), category]));
const children = new Map<string, ApiCategory[]>();
for (const category of flat) {
const parentId = category.parentId ? String(category.parentId) : "";
const key = parentId && byId.has(parentId) ? parentId : "root";
const list = children.get(key) ?? [];
list.push(category);
children.set(key, list);
}
const sort = (list: ApiCategory[]) =>
[...list].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0) || titleOf(a).localeCompare(titleOf(b), "fa"));
return sort(children.get("root") ?? []).map((root) => ({
id: String(root.id),
title: titleOf(root),
href: hrefOf(root),
children: sort(children.get(String(root.id)) ?? []).map((child) => ({
id: String(child.id),
title: titleOf(child),
href: hrefOf(child),
})),
}));
}
export async function getNavCategories() {
return buildCategoryTree(await fetchProductCategories());
}
+16
View File
@@ -0,0 +1,16 @@
import { meshkeeGet, tenantPath } from "@/lib/meshkee";
export type CommentItem = {
id?: string;
authorName?: string | null;
text?: string | null;
createdAt?: string | null;
};
export async function fetchComments(entityType: "product" | "blog", entityId: string) {
const data = await meshkeeGet<{ items?: CommentItem[] }>(
tenantPath(`/comments?entityType=${entityType}&entityId=${encodeURIComponent(entityId)}`),
{ items: [] },
);
return data.items ?? [];
}
+26 -10
View File
@@ -1,3 +1,15 @@
export type Product = {
id: string;
title: string;
brand: string;
price?: number;
oldPrice?: number;
discount?: number;
image: string;
href?: string;
inStock?: boolean;
};
export const site = {
name: "فروشگاه برادران برقعی",
nameEn: "Burgheii Brothers Store",
@@ -56,7 +68,7 @@ export const branches = [
export const navLinks = [
{ href: "/", label: "خانه" },
{ href: "/#parts", label: "قطعات" },
{ href: "/products", label: "محصولات" },
{ href: "/blog", label: "مقالات" },
{ href: "/about", label: "درباره ما" },
{ href: "/contact", label: "ارتباط با ما" },
@@ -370,17 +382,21 @@ export const threeBannerRow = [
];
/** Placeholder products with white backgrounds until Meshkee store items are wired. */
export const sampleProducts = [
{ id: "p-vacuum", title: "قطعات جاروبرقی", image: "/images/categories/vacuum.jpg" },
{ id: "p-blender", title: "قطعات مخلوط کن", image: "/images/categories/blender.jpg" },
{ id: "p-grinder", title: "تیغ و شبکه چرخ گوشت", image: "/images/categories/meat-grinder.jpg" },
{ id: "p-microwave", title: "قطعات ماکروویو", image: "/images/categories/microwave.png" },
{ id: "p-kettle", title: "قطعات چای ساز", image: "/images/categories/kettle.jpg" },
{ id: "p-iron", title: "قطعات اتو", image: "/images/categories/iron.jpg" },
{ id: "p-bearing", title: "بلبرینگ", image: "/images/categories/bearing.jpg" },
{ id: "p-collector", title: "کلکتور", image: "/images/categories/collector.jpg" },
export const sampleProducts: Product[] = [
{ id: "p-vacuum", title: "قطعات جاروبرقی", brand: "Vacuum", image: "/images/categories/vacuum.jpg", href: "/products" },
{ id: "p-blender", title: "قطعات مخلوط کن", brand: "Blender", image: "/images/categories/blender.jpg", href: "/products" },
{ id: "p-grinder", title: "تیغ و شبکه چرخ گوشت", brand: "Grinder", image: "/images/categories/meat-grinder.jpg", href: "/products" },
{ id: "p-microwave", title: "قطعات ماکروویو", brand: "Microwave", image: "/images/categories/microwave.png", href: "/products" },
{ id: "p-kettle", title: "قطعات چای ساز", brand: "Kettle", image: "/images/categories/kettle.jpg", href: "/products" },
{ id: "p-iron", title: "قطعات اتو", brand: "Iron", image: "/images/categories/iron.jpg", href: "/products" },
{ id: "p-bearing", title: "بلبرینگ", brand: "Bearing", image: "/images/categories/bearing.jpg", href: "/products" },
{ id: "p-collector", title: "کلکتور", brand: "Collector", image: "/images/categories/collector.jpg", href: "/products" },
];
export const consumablesFallback = sampleProducts.slice(0, 4);
export const refrigeratorPartsFallback = sampleProducts.slice(2, 6);
export const washingMachinePartsFallback = sampleProducts.slice(4, 8);
export const blogPosts = [
{
slug: "meat-grinder-blades",
+14
View File
@@ -0,0 +1,14 @@
export function faDigits(value: string | number) {
return String(value).replace(/\d/g, (digit) => "۰۱۲۳۴۵۶۷۸۹"[Number(digit)]);
}
export function formatPrice(value: number) {
return faDigits(value.toLocaleString("en-US"));
}
export function formatFaDate(value?: string | null) {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleDateString("fa-IR");
}
+28
View File
@@ -0,0 +1,28 @@
import { meshkeeApiBase, websiteDomain } from "@/lib/config";
export async function meshkeeGet<T>(path: string, fallback: T, revalidate = 60): Promise<T> {
try {
const response = await fetch(`${meshkeeApiBase}${path}`, {
next: { revalidate },
});
if (!response.ok) return fallback;
return (await response.json()) as T;
} catch {
return fallback;
}
}
export function tenantPath(suffix: string) {
return `/tenants/${encodeURIComponent(websiteDomain)}${suffix}`;
}
type Tenant = { id: number | string; nameFa?: string; name?: string; domain?: string };
export async function getTenant() {
return meshkeeGet<Tenant | null>(`/tenants/${encodeURIComponent(websiteDomain)}`, null);
}
export async function getBusinessId() {
const tenant = await getTenant();
return tenant?.id != null ? String(tenant.id) : "";
}
+193
View File
@@ -0,0 +1,193 @@
import type { Product } from "@/lib/data";
import { meshkeeGet, tenantPath } from "@/lib/meshkee";
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;
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;
};
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 variantTitle(variant: StoreVariant) {
return variant.productNameFa?.trim() || variant.productTitle?.trim() || variant.label?.trim() || "محصول";
}
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;
return {
id: String(variant.id),
href: `/products/${encodeURIComponent(String(variant.id))}`,
title: variantTitle(variant),
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 {
const variant = item.variants?.[0];
if (!variant) return null;
return variantToProduct({
...variant,
productId: item.productId ?? variant.productId,
productTitle: item.productTitle ?? variant.productTitle,
productNameFa: item.productNameFa ?? variant.productNameFa,
productImage: item.productImage ?? variant.productImage,
});
}
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[] }>(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 products = (special?.items ?? []).map(specialItemToProduct).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 fetchCatalogProductByTitle(title?: string | null, productId?: string | null) {
if (!title?.trim()) return null;
const data = await meshkeeGet<Paged<CatalogProduct>>(
tenantPath(`/products?name=${encodeURIComponent(title.trim())}&pageSize=8`),
{ items: [], total: 0, page: 1, pageSize: 8 },
);
const match =
data.items.find((item) => String(item.id) === String(productId)) ??
data.items[0] ??
null;
if (!match?.slug) return match;
const detail = await meshkeeGet<{ product?: CatalogProduct }>(
tenantPath(`/products/${encodeURIComponent(match.slug)}`),
{},
);
return detail.product ?? match;
}
export function brandName(product?: CatalogProduct | null) {
const brand = product?.brand;
if (!brand) return "";
if (typeof brand === "string") return brand;
return brand.nameFa || brand.name || "";
}