Connect the storefront to Meshkee catalog, blogs, and static image slots.
Wire homepage specials, product/blog pages, instant search, and the customer portal so the live pharmacy site can be managed from the dashboard. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
cd2f9dfc9d
commit
66e604c426
+46
@@ -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)}`;
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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 ?? [];
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export const websiteDomain =
|
||||
process.env.NEXT_PUBLIC_WEBSITE_DOMAIN ??
|
||||
process.env.WEBSITE_DOMAIN ??
|
||||
process.env.NEXT_PUBLIC_MESHKEE_DOMAIN ??
|
||||
"arjmandpharmacy.com";
|
||||
|
||||
export const meshkeeApiBase = (
|
||||
process.env.NEXT_PUBLIC_MESHKEE_API_BASE ??
|
||||
process.env.MESHKEE_API_BASE ??
|
||||
process.env.NEXT_PUBLIC_MESHKEE_API_URL ??
|
||||
"https://api.meshkee.com/api/v1"
|
||||
).replace(/\/$/, "");
|
||||
|
||||
export const customerPortalUrl = (
|
||||
process.env.NEXT_PUBLIC_CUSTOMER_PORTAL_URL ?? `https://customer.${websiteDomain}`
|
||||
).replace(/\/$/, "");
|
||||
+16
-13
@@ -2,10 +2,12 @@ export type Product = {
|
||||
id: string;
|
||||
title: string;
|
||||
brand: string;
|
||||
price: number;
|
||||
price?: number;
|
||||
oldPrice?: number;
|
||||
discount?: number;
|
||||
image: string;
|
||||
href?: string;
|
||||
inStock?: boolean;
|
||||
};
|
||||
|
||||
export type Category = {
|
||||
@@ -30,10 +32,11 @@ export const site = {
|
||||
};
|
||||
|
||||
export const navLinks = [
|
||||
{ title: "فروش ویژه", href: "#special-sale" },
|
||||
{ title: "درباره ما", href: "#about" },
|
||||
{ title: "سوالات متداول", href: "#faq" },
|
||||
{ title: "تماس با ما", href: "#contact" },
|
||||
{ title: "فروش ویژه", href: "/#special-sale" },
|
||||
{ title: "مجله سلامت", href: "/blog" },
|
||||
{ title: "درباره ما", href: "/#about" },
|
||||
{ title: "سوالات متداول", href: "/#faq" },
|
||||
{ title: "تماس با ما", href: "/#contact" },
|
||||
];
|
||||
|
||||
export const categories: Category[] = [
|
||||
@@ -385,15 +388,15 @@ export const faqs = [
|
||||
|
||||
export const footerLinks = {
|
||||
shop: [
|
||||
{ title: "فروش ویژه", href: "#special-sale" },
|
||||
{ title: "پرفروشترینها", href: "#top-selling" },
|
||||
{ title: "محصولات یگانه", href: "#yeganeh" },
|
||||
{ title: "دستهبندیها", href: "#categories" },
|
||||
{ title: "فروش ویژه", href: "/#special-sale" },
|
||||
{ title: "مولتی ویتامین", href: "/#multi-vitamin" },
|
||||
{ title: "مکمل بدنسازی", href: "/#body-building-supplement" },
|
||||
{ title: "همه محصولات", href: "/products" },
|
||||
],
|
||||
pages: [
|
||||
{ title: "درباره ما", href: "#about" },
|
||||
{ title: "سوالات متداول", href: "#faq" },
|
||||
{ title: "تماس با ما", href: "#contact" },
|
||||
{ title: "مجله سلامت", href: "#blog" },
|
||||
{ title: "درباره ما", href: "/#about" },
|
||||
{ title: "سوالات متداول", href: "/#faq" },
|
||||
{ title: "تماس با ما", href: "/#contact" },
|
||||
{ title: "مجله سلامت", href: "/blog" },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -5,3 +5,10 @@ export function faDigits(value: string | number) {
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -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) : "";
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { meshkeeApiBase, websiteDomain } from "@/lib/config";
|
||||
|
||||
export type SlotKind = "single" | "list";
|
||||
|
||||
export type SlotImage = {
|
||||
id: string;
|
||||
url: string;
|
||||
titleFa?: string | null;
|
||||
titleEn?: string | null;
|
||||
subtext?: string | null;
|
||||
linkUrl?: string | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
sortOrder?: number | null;
|
||||
};
|
||||
|
||||
export type StaticImageSlotCatalogItem = {
|
||||
key: string;
|
||||
label: string;
|
||||
kind: SlotKind;
|
||||
pageKey: string;
|
||||
aspectRatio: string;
|
||||
itemCount: number | null;
|
||||
recommendedWidth: number;
|
||||
};
|
||||
|
||||
export type StaticImageSlot = StaticImageSlotCatalogItem & {
|
||||
images: SlotImage[];
|
||||
};
|
||||
|
||||
export type SlotMediaItem = {
|
||||
id: string;
|
||||
image: string;
|
||||
href?: string;
|
||||
title?: string;
|
||||
fallbackSrc?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Homepage slot catalog published at GET /meshkee/static-image-slots.
|
||||
* Ratios match the live homepage layout at max-w-[1280px] (inner 1232px).
|
||||
* If a section’s size or aspect ratio changes, update this catalog — the
|
||||
* Meshkee dashboard reads it as the static-image source of truth.
|
||||
*/
|
||||
export const STATIC_IMAGE_SLOTS: StaticImageSlotCatalogItem[] = [
|
||||
{
|
||||
key: "slider",
|
||||
label: "Homepage slider",
|
||||
kind: "list",
|
||||
pageKey: "home",
|
||||
aspectRatio: "40:13",
|
||||
itemCount: null,
|
||||
recommendedWidth: 1280,
|
||||
},
|
||||
{
|
||||
key: "two-banner-below-slider",
|
||||
label: "Two banners below slider",
|
||||
kind: "list",
|
||||
pageKey: "home",
|
||||
aspectRatio: "61:11",
|
||||
itemCount: 2,
|
||||
recommendedWidth: 610,
|
||||
},
|
||||
{
|
||||
key: "categories",
|
||||
label: "Featured categories",
|
||||
kind: "list",
|
||||
pageKey: "home",
|
||||
aspectRatio: "1:1",
|
||||
itemCount: null,
|
||||
recommendedWidth: 168,
|
||||
},
|
||||
{
|
||||
key: "two-banner-row",
|
||||
label: "Two banner row",
|
||||
kind: "list",
|
||||
pageKey: "home",
|
||||
aspectRatio: "305:59",
|
||||
itemCount: 2,
|
||||
recommendedWidth: 610,
|
||||
},
|
||||
{
|
||||
key: "three-banner-row",
|
||||
label: "Three banner row",
|
||||
kind: "list",
|
||||
pageKey: "home",
|
||||
aspectRatio: "403:118",
|
||||
itemCount: 3,
|
||||
recommendedWidth: 403,
|
||||
},
|
||||
{
|
||||
key: "about-us-image",
|
||||
label: "About us image",
|
||||
kind: "single",
|
||||
pageKey: "home",
|
||||
aspectRatio: "10:11",
|
||||
itemCount: 1,
|
||||
recommendedWidth: 560,
|
||||
},
|
||||
];
|
||||
|
||||
export function catalogSlot(key: string) {
|
||||
return STATIC_IMAGE_SLOTS.find((slot) => slot.key === key);
|
||||
}
|
||||
|
||||
export function parseAspectRatio(ratio?: string | null) {
|
||||
if (!ratio) return undefined;
|
||||
const match = /^(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)$/.exec(ratio.trim());
|
||||
if (!match) return undefined;
|
||||
return `${match[1]} / ${match[2]}`;
|
||||
}
|
||||
|
||||
export function pickSlot(slots: StaticImageSlot[], key: string) {
|
||||
return slots.find((slot) => slot.key === key);
|
||||
}
|
||||
|
||||
function normalizeImages(images: SlotImage[] | undefined) {
|
||||
return [...(images ?? [])]
|
||||
.filter((image) => Boolean(image?.url))
|
||||
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
||||
}
|
||||
|
||||
export function imagesFromSlot(slot?: StaticImageSlot | null): SlotImage[] {
|
||||
if (!slot) return [];
|
||||
const images = normalizeImages(slot.images);
|
||||
if (slot.kind === "single") return images.slice(0, 1);
|
||||
return images;
|
||||
}
|
||||
|
||||
function toMedia(image: SlotImage, fallbackSrc?: string): SlotMediaItem {
|
||||
return {
|
||||
id: String(image.id),
|
||||
image: image.url,
|
||||
href: image.linkUrl?.trim() || undefined,
|
||||
title: image.titleFa?.trim() || image.titleEn?.trim() || undefined,
|
||||
fallbackSrc,
|
||||
};
|
||||
}
|
||||
|
||||
export function overlaySlotImages(
|
||||
slot: StaticImageSlot | undefined,
|
||||
fallbacks: SlotMediaItem[],
|
||||
key: string,
|
||||
): { items: SlotMediaItem[]; aspectRatio?: string } {
|
||||
const catalog = catalogSlot(key);
|
||||
const aspectRatio = slot?.aspectRatio || catalog?.aspectRatio;
|
||||
const kind = slot?.kind ?? catalog?.kind ?? "list";
|
||||
const itemCount = slot?.itemCount ?? catalog?.itemCount ?? null;
|
||||
const apiImages = imagesFromSlot(slot ? { ...slot, kind, images: slot.images ?? [] } : undefined);
|
||||
|
||||
if (kind === "single") {
|
||||
const image = apiImages[0];
|
||||
const fallback = fallbacks[0];
|
||||
return {
|
||||
aspectRatio,
|
||||
items: image ? [toMedia(image, fallback?.image)] : fallbacks.slice(0, 1),
|
||||
};
|
||||
}
|
||||
|
||||
if (apiImages.length === 0) {
|
||||
return {
|
||||
aspectRatio,
|
||||
items: itemCount ? fallbacks.slice(0, itemCount) : fallbacks,
|
||||
};
|
||||
}
|
||||
|
||||
if (itemCount) {
|
||||
return {
|
||||
aspectRatio,
|
||||
items: Array.from({ length: itemCount }, (_, index) => {
|
||||
const image = apiImages[index];
|
||||
const fallback = fallbacks[index] ?? fallbacks[fallbacks.length - 1];
|
||||
return image ? toMedia(image, fallback?.image) : fallback;
|
||||
}).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
aspectRatio,
|
||||
items: apiImages.map((image, index) => toMedia(image, fallbacks[index]?.image)),
|
||||
};
|
||||
}
|
||||
|
||||
function readItems(payload: unknown): StaticImageSlot[] {
|
||||
if (Array.isArray(payload)) return payload as StaticImageSlot[];
|
||||
if (payload && typeof payload === "object") {
|
||||
const record = payload as { items?: StaticImageSlot[]; slots?: StaticImageSlot[] };
|
||||
return record.items ?? record.slots ?? [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function fetchStaticImageSlots(pageKey = "home"): Promise<StaticImageSlot[]> {
|
||||
const url = `${meshkeeApiBase}/tenants/${encodeURIComponent(websiteDomain)}/website/static-images?pageKey=${encodeURIComponent(pageKey)}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
next: { revalidate: 60 },
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
return readItems(await response.json());
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
+193
@@ -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 || "";
|
||||
}
|
||||
Reference in New Issue
Block a user