mirror of
https://git.meshkee.com/Meshkee-Websites/oaktasty.git
synced 2026-08-11 20:50:57 +04:30
Connect menu to Meshkee store-items API for oaktasty.com.
Replace hardcoded products with live tenant catalog data so the menu stays in sync with the backend. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
5fd7d00563
commit
42dd0595e5
@@ -0,0 +1,2 @@
|
||||
NEXT_PUBLIC_API_BASE_URL=https://api.meshkee.com/api/v1
|
||||
NEXT_PUBLIC_DOMAIN=oaktasty.com
|
||||
@@ -5,6 +5,12 @@ const nextConfig: NextConfig = {
|
||||
dangerouslyAllowSVG: true,
|
||||
contentDispositionType: "attachment",
|
||||
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "meshkee-storage.sas.amin.parminstorage.ir",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+4
-2
@@ -1,5 +1,7 @@
|
||||
import { MenuHome } from "@/components/MenuHome";
|
||||
import { getMenuSections } from "@/lib/menu";
|
||||
|
||||
export default function HomePage() {
|
||||
return <MenuHome />;
|
||||
export default async function HomePage() {
|
||||
const sections = await getMenuSections();
|
||||
return <MenuHome sections={sections} />;
|
||||
}
|
||||
|
||||
@@ -4,26 +4,36 @@ import Image from "next/image";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
formatPrice,
|
||||
menuSections,
|
||||
type CategoryId,
|
||||
type MenuSection,
|
||||
type Product,
|
||||
} from "@/data/products";
|
||||
import { ProductModal } from "@/components/ProductModal";
|
||||
import { SiteChrome } from "@/components/SiteChrome";
|
||||
|
||||
export function MenuHome() {
|
||||
type MenuHomeProps = {
|
||||
sections: MenuSection[];
|
||||
};
|
||||
|
||||
export function MenuHome({ sections }: MenuHomeProps) {
|
||||
const [activeCategory, setActiveCategory] = useState<CategoryId>(
|
||||
menuSections[0]?.id ?? "hot-drinks",
|
||||
sections[0]?.id ?? "",
|
||||
);
|
||||
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
|
||||
const tabRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const sections = menuSections
|
||||
if (!sections.some((section) => section.id === activeCategory)) {
|
||||
setActiveCategory(sections[0]?.id ?? "");
|
||||
}
|
||||
}, [activeCategory, sections]);
|
||||
|
||||
useEffect(() => {
|
||||
const observed = sections
|
||||
.map((section) => document.getElementById(`category-${section.id}`))
|
||||
.filter((el): el is HTMLElement => Boolean(el));
|
||||
|
||||
if (sections.length === 0) return;
|
||||
if (observed.length === 0) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
@@ -49,9 +59,9 @@ export function MenuHome() {
|
||||
},
|
||||
);
|
||||
|
||||
sections.forEach((section) => observer.observe(section));
|
||||
observed.forEach((section) => observer.observe(section));
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
}, [sections]);
|
||||
|
||||
useEffect(() => {
|
||||
const activeTab = tabRefs.current[activeCategory];
|
||||
@@ -70,11 +80,21 @@ export function MenuHome() {
|
||||
setActiveCategory(id);
|
||||
};
|
||||
|
||||
if (sections.length === 0) {
|
||||
return (
|
||||
<SiteChrome eyebrow="منوی کافه و قنادی">
|
||||
<main>
|
||||
<p className="empty-state">هنوز آیتمی در منو ثبت نشده است.</p>
|
||||
</main>
|
||||
</SiteChrome>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SiteChrome eyebrow="منوی کافه و قنادی">
|
||||
<nav className="category-nav" aria-label="دستهبندی منو">
|
||||
<div className="category-scroller">
|
||||
{menuSections.map((category) => {
|
||||
{sections.map((category) => {
|
||||
const isActive = category.id === activeCategory;
|
||||
return (
|
||||
<button
|
||||
@@ -95,7 +115,7 @@ export function MenuHome() {
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
{menuSections.map((section) => (
|
||||
{sections.map((section) => (
|
||||
<section
|
||||
key={section.id}
|
||||
id={`category-${section.id}`}
|
||||
@@ -127,14 +147,22 @@ export function MenuHome() {
|
||||
/>
|
||||
</div>
|
||||
<h3 className="product-card__title">{product.name}</h3>
|
||||
{product.nameEn ? (
|
||||
<p className="product-card__title-en" lang="en" dir="ltr">
|
||||
{product.nameEn}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="product-card__price">
|
||||
{product.price != null ? (
|
||||
<>
|
||||
<span className="product-card__price-value">
|
||||
{formatPrice(product.price)}
|
||||
</span>
|
||||
<span className="product-card__price-unit">تومان</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="product-card__price-value">—</span>
|
||||
)}
|
||||
</p>
|
||||
</button>
|
||||
</article>
|
||||
|
||||
@@ -143,14 +143,22 @@ export function ProductModal({ product, onClose }: ProductModalProps) {
|
||||
<h2 id="product-modal-title" className="product-modal__title">
|
||||
{product.name}
|
||||
</h2>
|
||||
{product.nameEn ? (
|
||||
<p className="product-modal__title-en" lang="en" dir="ltr">
|
||||
{product.nameEn}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="product-modal__price">
|
||||
{product.price != null ? (
|
||||
<>
|
||||
<span className="product-modal__price-value">
|
||||
{formatPrice(product.price)}
|
||||
</span>
|
||||
<span className="product-modal__price-unit">تومان</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="product-modal__price-value">—</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="product-modal__desc">{product.description}</p>
|
||||
</div>
|
||||
|
||||
+6
-289
@@ -1,14 +1,4 @@
|
||||
export type CategoryId =
|
||||
| "hot-drinks"
|
||||
| "cold-drinks"
|
||||
| "juices"
|
||||
| "gelato-shake"
|
||||
| "smoothie"
|
||||
| "pastry"
|
||||
| "dessert"
|
||||
| "cake"
|
||||
| "bakery"
|
||||
| "boxed-sweets";
|
||||
export type CategoryId = string;
|
||||
|
||||
export type Category = {
|
||||
id: CategoryId;
|
||||
@@ -16,291 +6,18 @@ export type Category = {
|
||||
};
|
||||
|
||||
export type Product = {
|
||||
id: number;
|
||||
id: string;
|
||||
name: string;
|
||||
nameEn: string;
|
||||
price: number;
|
||||
price: number | null;
|
||||
category: CategoryId;
|
||||
images: string[];
|
||||
description: string;
|
||||
};
|
||||
|
||||
export const categories: Category[] = [
|
||||
{ id: "hot-drinks", label: "نوشیدنی گرم" },
|
||||
{ id: "cold-drinks", label: "نوشیدنی سرد" },
|
||||
{ id: "juices", label: "آبمیوههای طبیعی" },
|
||||
{ id: "gelato-shake", label: "ژلاتو شیک" },
|
||||
{ id: "smoothie", label: "اسموتی" },
|
||||
{ id: "pastry", label: "شیرینی تر" },
|
||||
{ id: "dessert", label: "دسر" },
|
||||
{ id: "cake", label: "کیک تولد" },
|
||||
{ id: "bakery", label: "نان و بیکری" },
|
||||
{ id: "boxed-sweets", label: "شیرینی جعبهای" },
|
||||
];
|
||||
|
||||
const img1 = "/product-placeholder.svg";
|
||||
const img2 = "/product-placeholder-2.svg";
|
||||
|
||||
function product(
|
||||
partial: Omit<Product, "images" | "description"> & {
|
||||
images?: string[];
|
||||
description?: string;
|
||||
},
|
||||
): Product {
|
||||
return {
|
||||
images: [img1],
|
||||
description: "تازهآمادهشده در اک؛ ترکیبی از طعم اصیل و کیفیت روزانه.",
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
export const products: Product[] = [
|
||||
product({
|
||||
id: 21760,
|
||||
name: "اسپرسو",
|
||||
nameEn: "Espresso",
|
||||
price: 120000,
|
||||
category: "hot-drinks",
|
||||
images: [img1, img2],
|
||||
description:
|
||||
"شات غلیظ اسپرسو با عطر قوی و کرمای طلایی؛ انتخاب کلاسیک دوستداران قهوه.",
|
||||
}),
|
||||
product({
|
||||
id: 21761,
|
||||
name: "لاته",
|
||||
nameEn: "Latte",
|
||||
price: 180000,
|
||||
category: "hot-drinks",
|
||||
images: [img1, img2],
|
||||
description: "اسپرسو با شیر مخملی و بافت نرم؛ متعادل، گرم و دلپذیر.",
|
||||
}),
|
||||
product({
|
||||
id: 21762,
|
||||
name: "کاپوچینو",
|
||||
nameEn: "Cappuccino",
|
||||
price: 170000,
|
||||
category: "hot-drinks",
|
||||
description: "ترکیب یکسوم اسپرسو، شیر و فوم؛ کلاسیک ایتالیایی اک.",
|
||||
}),
|
||||
product({
|
||||
id: 21774,
|
||||
name: "نسکافه",
|
||||
nameEn: "Nescafe",
|
||||
price: 260000,
|
||||
category: "hot-drinks",
|
||||
description: "نسکافه خامهای با طعم ملایم و سرو گرم.",
|
||||
}),
|
||||
product({
|
||||
id: 21763,
|
||||
name: "آیس لاته",
|
||||
nameEn: "Iced Latte",
|
||||
price: 190000,
|
||||
category: "cold-drinks",
|
||||
images: [img2, img1],
|
||||
description: "لاته سرد روی یخ؛ سبک، خنک و مناسب روزهای گرم.",
|
||||
}),
|
||||
product({
|
||||
id: 21764,
|
||||
name: "آیس آمریکانو",
|
||||
nameEn: "Iced Americano",
|
||||
price: 160000,
|
||||
category: "cold-drinks",
|
||||
description: "اسپرسو رقیقشده با آب و یخ؛ تلخ و تازه.",
|
||||
}),
|
||||
product({
|
||||
id: 21765,
|
||||
name: "آب پرتقال",
|
||||
nameEn: "Orange Juice",
|
||||
price: 140000,
|
||||
category: "juices",
|
||||
images: [img1, img2],
|
||||
description: "آب پرتقال تازه؛ سرشار از طعم طبیعی و ویتامین.",
|
||||
}),
|
||||
product({
|
||||
id: 21766,
|
||||
name: "آب سیب",
|
||||
nameEn: "Apple Juice",
|
||||
price: 130000,
|
||||
category: "juices",
|
||||
description: "آب سیب طبیعی با شیرینی متعادل.",
|
||||
}),
|
||||
product({
|
||||
id: 21767,
|
||||
name: "آب هویج",
|
||||
nameEn: "Carrot Juice",
|
||||
price: 130000,
|
||||
category: "juices",
|
||||
description: "آب هویج تازه و مغذی.",
|
||||
}),
|
||||
product({
|
||||
id: 21773,
|
||||
name: "موز",
|
||||
nameEn: "Banana",
|
||||
price: 270000,
|
||||
category: "gelato-shake",
|
||||
images: [img2, img1],
|
||||
description: "ژلاتو شیک موزی؛ خامهای، خنک و سیرکننده.",
|
||||
}),
|
||||
product({
|
||||
id: 21772,
|
||||
name: "وانیل",
|
||||
nameEn: "Vanilla",
|
||||
price: 250000,
|
||||
category: "gelato-shake",
|
||||
description: "ژلاتو شیک وانیلی کلاسیک با بافت نرم.",
|
||||
}),
|
||||
product({
|
||||
id: 21771,
|
||||
name: "شکلات",
|
||||
nameEn: "Chocolate",
|
||||
price: 260000,
|
||||
category: "gelato-shake",
|
||||
images: [img1, img2],
|
||||
description: "ژلاتو شیک شکلاتی غنی برای عاشقان کاکائو.",
|
||||
}),
|
||||
product({
|
||||
id: 21770,
|
||||
name: "توت فرنگی",
|
||||
nameEn: "Strawberry",
|
||||
price: 270000,
|
||||
category: "gelato-shake",
|
||||
description: "ژلاتو شیک توتفرنگی با طعم میوهای تازه.",
|
||||
}),
|
||||
product({
|
||||
id: 21769,
|
||||
name: "اورئو",
|
||||
nameEn: "Oreo",
|
||||
price: 300000,
|
||||
category: "gelato-shake",
|
||||
images: [img2, img1],
|
||||
description: "ژلاتو شیک اورئو با تکههای بیسکوییت ترد.",
|
||||
}),
|
||||
product({
|
||||
id: 21784,
|
||||
name: "شیرموز انبه",
|
||||
nameEn: "Mango Banana Milk",
|
||||
price: 250000,
|
||||
category: "smoothie",
|
||||
description: "اسموتی موز و انبه؛ گرمسیری و انرژیبخش.",
|
||||
}),
|
||||
product({
|
||||
id: 21783,
|
||||
name: "شیرموز پسته",
|
||||
nameEn: "Pistachio Banana Milk",
|
||||
price: 230000,
|
||||
category: "smoothie",
|
||||
description: "شیرموز با پسته؛ طعمی ایرانی و خاص.",
|
||||
}),
|
||||
product({
|
||||
id: 21782,
|
||||
name: "شیرموز خرما",
|
||||
nameEn: "Date Banana Milk",
|
||||
price: 190000,
|
||||
category: "smoothie",
|
||||
description: "شیرموز با خرما؛ شیرین طبیعی و مغذی.",
|
||||
}),
|
||||
product({
|
||||
id: 21781,
|
||||
name: "شیرموز",
|
||||
nameEn: "Banana Milk",
|
||||
price: 170000,
|
||||
category: "smoothie",
|
||||
description: "شیرموز ساده و کلاسیک اک.",
|
||||
}),
|
||||
product({
|
||||
id: 21780,
|
||||
name: "معجون میکس",
|
||||
nameEn: "Mixed Elixir",
|
||||
price: 300000,
|
||||
category: "smoothie",
|
||||
images: [img1, img2],
|
||||
description: "معجون میکس با ترکیب میوه و مغز؛ مقوی و کامل.",
|
||||
}),
|
||||
product({
|
||||
id: 21776,
|
||||
name: "شیر انبه",
|
||||
nameEn: "Mango Milk",
|
||||
price: 260000,
|
||||
category: "smoothie",
|
||||
description: "شیر انبه خنک با طعم تابستانی.",
|
||||
}),
|
||||
product({
|
||||
id: 21785,
|
||||
name: "شیرینی تر مخلوط",
|
||||
nameEn: "Assorted Soft Pastry",
|
||||
price: 645000,
|
||||
category: "pastry",
|
||||
images: [img1, img2],
|
||||
description: "باکس مخلوط شیرینی تر روز؛ تازه و متنوع.",
|
||||
}),
|
||||
product({
|
||||
id: 21786,
|
||||
name: "انواع دسر",
|
||||
nameEn: "Assorted Desserts",
|
||||
price: 150000,
|
||||
category: "dessert",
|
||||
description: "انتخابی از دسرهای روز اک.",
|
||||
}),
|
||||
product({
|
||||
id: 21791,
|
||||
name: "کیک شکلاتی",
|
||||
nameEn: "Chocolate Cake",
|
||||
price: 800000,
|
||||
category: "cake",
|
||||
images: [img2, img1],
|
||||
description: "کیک تولد شکلاتی مرطوب با کاور شکلاتی.",
|
||||
}),
|
||||
product({
|
||||
id: 21790,
|
||||
name: "کیک وانیلی",
|
||||
nameEn: "Vanilla Cake",
|
||||
price: 600000,
|
||||
category: "cake",
|
||||
images: [img1, img2],
|
||||
description: "کیک تولد وانیلی نرم با تزئین کلاسیک.",
|
||||
}),
|
||||
product({
|
||||
id: 21792,
|
||||
name: "انواع بیکری",
|
||||
nameEn: "Assorted Bakery",
|
||||
price: 645000,
|
||||
category: "bakery",
|
||||
images: [img1, img2],
|
||||
description: "مجموعهای از نان و بیکری تازه روز.",
|
||||
}),
|
||||
product({
|
||||
id: 21788,
|
||||
name: "استریپس",
|
||||
nameEn: "Strips",
|
||||
price: 150000,
|
||||
category: "bakery",
|
||||
description: "استریپس ترد و طلایی از فر اک.",
|
||||
}),
|
||||
product({
|
||||
id: 21787,
|
||||
name: "اشترودل",
|
||||
nameEn: "Strudel",
|
||||
price: 150000,
|
||||
category: "bakery",
|
||||
description: "اشترودل لایهلایه با مغز میوهای.",
|
||||
}),
|
||||
product({
|
||||
id: 21789,
|
||||
name: "انواع کوکی",
|
||||
nameEn: "Assorted Cookies",
|
||||
price: 150000,
|
||||
category: "boxed-sweets",
|
||||
images: [img2, img1],
|
||||
description: "کوکیهای جعبهای با طعمهای متنوع.",
|
||||
}),
|
||||
];
|
||||
|
||||
export const menuSections = categories
|
||||
.map((category) => ({
|
||||
...category,
|
||||
products: products.filter((item) => item.category === category.id),
|
||||
}))
|
||||
.filter((section) => section.products.length > 0);
|
||||
export type MenuSection = Category & {
|
||||
products: Product[];
|
||||
};
|
||||
|
||||
export function formatPrice(price: number): string {
|
||||
return new Intl.NumberFormat("fa-IR").format(price);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { apiGet } from "./client";
|
||||
import { TENANT_DOMAIN } from "./config";
|
||||
import type { ApiCategory, CategoriesResponse } from "./types";
|
||||
|
||||
export async function getProductCategories(): Promise<ApiCategory[]> {
|
||||
const data = await apiGet<CategoriesResponse>(
|
||||
`/tenants/${TENANT_DOMAIN}/categories?entityType=product`,
|
||||
);
|
||||
|
||||
return [...data.items]
|
||||
.filter((category) => category.isActive && category.parentId === null)
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { API_BASE_URL } from "./config";
|
||||
|
||||
export async function apiGet<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...init?.headers,
|
||||
},
|
||||
next: { revalidate: 60 },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error ${response.status}: ${path}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "https://api.meshkee.com/api/v1";
|
||||
|
||||
export const TENANT_DOMAIN =
|
||||
process.env.NEXT_PUBLIC_DOMAIN ?? "oaktasty.com";
|
||||
@@ -0,0 +1,23 @@
|
||||
import { apiGet } from "./client";
|
||||
import { TENANT_DOMAIN } from "./config";
|
||||
import type { ApiProduct, ProductsResponse } from "./types";
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
export async function getPublishedProducts(): Promise<ApiProduct[]> {
|
||||
const first = await apiGet<ProductsResponse>(
|
||||
`/tenants/${TENANT_DOMAIN}/products?page=1&pageSize=${PAGE_SIZE}`,
|
||||
);
|
||||
|
||||
const items = [...first.items];
|
||||
const totalPages = Math.max(1, Math.ceil(first.total / PAGE_SIZE));
|
||||
|
||||
for (let page = 2; page <= totalPages; page += 1) {
|
||||
const next = await apiGet<ProductsResponse>(
|
||||
`/tenants/${TENANT_DOMAIN}/products?page=${page}&pageSize=${PAGE_SIZE}`,
|
||||
);
|
||||
items.push(...next.items);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { apiGet } from "./client";
|
||||
import { TENANT_DOMAIN } from "./config";
|
||||
import type { ApiStoreItem, StoreItemsResponse } from "./types";
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
export async function getStoreItems(): Promise<ApiStoreItem[]> {
|
||||
const first = await apiGet<StoreItemsResponse>(
|
||||
`/tenants/${TENANT_DOMAIN}/store-items?page=1&pageSize=${PAGE_SIZE}`,
|
||||
);
|
||||
|
||||
const items = [...first.items];
|
||||
const totalPages = Math.max(1, Math.ceil(first.total / PAGE_SIZE));
|
||||
|
||||
for (let page = 2; page <= totalPages; page += 1) {
|
||||
const next = await apiGet<StoreItemsResponse>(
|
||||
`/tenants/${TENANT_DOMAIN}/store-items?page=${page}&pageSize=${PAGE_SIZE}`,
|
||||
);
|
||||
items.push(...next.items);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
export type ApiCategory = {
|
||||
id: string;
|
||||
businessId: string;
|
||||
entityType: string;
|
||||
parentId: string | null;
|
||||
name: string;
|
||||
nameFa: string | null;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
export type CategoriesResponse = {
|
||||
items: ApiCategory[];
|
||||
};
|
||||
|
||||
export type ApiStoreItemSelection = {
|
||||
variationId: string;
|
||||
variationName: string;
|
||||
optionId: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type ApiStoreItem = {
|
||||
id: string;
|
||||
storeItemId: string;
|
||||
productId: string;
|
||||
productTitle: string;
|
||||
productNameFa: string;
|
||||
productImage: string | null;
|
||||
productTotalStock: number;
|
||||
selections: ApiStoreItemSelection[];
|
||||
label: string;
|
||||
price: number | null;
|
||||
discountedPrice: number | null;
|
||||
stockQuantity: number;
|
||||
rewardPoints: number | null;
|
||||
isFestival: boolean;
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
export type StoreItemsResponse = {
|
||||
items: ApiStoreItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
};
|
||||
|
||||
export type ApiProduct = {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
nameFa: string;
|
||||
summary: string | null;
|
||||
descriptionHtml: string | null;
|
||||
categoryId: string | null;
|
||||
categoryName: string | null;
|
||||
categoryNameFa: string | null;
|
||||
thumbnailUrl: string | null;
|
||||
image: string | null;
|
||||
images: string[];
|
||||
};
|
||||
|
||||
export type ProductsResponse = {
|
||||
items: ApiProduct[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
};
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
import { getProductCategories } from "@/lib/api/categories";
|
||||
import { getPublishedProducts } from "@/lib/api/products";
|
||||
import { getStoreItems } from "@/lib/api/store-items";
|
||||
import type { ApiProduct, ApiStoreItem } from "@/lib/api/types";
|
||||
import type { MenuSection, Product } from "@/data/products";
|
||||
|
||||
const PLACEHOLDER = "/product-placeholder.svg";
|
||||
const DEFAULT_DESCRIPTION =
|
||||
"تازهآمادهشده در اک؛ ترکیبی از طعم اصیل و کیفیت روزانه.";
|
||||
|
||||
function pickPreferredVariant(
|
||||
current: ApiStoreItem,
|
||||
candidate: ApiStoreItem,
|
||||
): ApiStoreItem {
|
||||
if (candidate.discountedPrice != null && current.discountedPrice == null) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
if (
|
||||
candidate.discountedPrice != null &&
|
||||
current.discountedPrice != null &&
|
||||
candidate.discountedPrice < current.discountedPrice
|
||||
) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
if (candidate.price != null && current.price == null) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
if (candidate.productImage && !current.productImage) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
if (candidate.isFestival && !current.isFestival) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
function dedupeStoreItemsByProduct(items: ApiStoreItem[]): ApiStoreItem[] {
|
||||
const byProduct = new Map<string, ApiStoreItem>();
|
||||
|
||||
for (const item of items) {
|
||||
const existing = byProduct.get(item.productId);
|
||||
byProduct.set(
|
||||
item.productId,
|
||||
existing ? pickPreferredVariant(existing, item) : item,
|
||||
);
|
||||
}
|
||||
|
||||
return [...byProduct.values()];
|
||||
}
|
||||
|
||||
function stripHtml(html: string | null | undefined): string {
|
||||
if (!html) return "";
|
||||
return html.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function resolvePrice(item: ApiStoreItem): number | null {
|
||||
if (item.discountedPrice != null) return item.discountedPrice;
|
||||
if (item.price != null) return item.price;
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveImages(
|
||||
item: ApiStoreItem,
|
||||
product: ApiProduct | undefined,
|
||||
): string[] {
|
||||
const images: string[] = [];
|
||||
|
||||
if (item.productImage) images.push(item.productImage);
|
||||
if (product?.thumbnailUrl) images.push(product.thumbnailUrl);
|
||||
if (product?.image) images.push(product.image);
|
||||
for (const image of product?.images ?? []) {
|
||||
if (image) images.push(image);
|
||||
}
|
||||
|
||||
const unique = [...new Set(images.filter(Boolean))];
|
||||
return unique.length > 0 ? unique : [PLACEHOLDER];
|
||||
}
|
||||
|
||||
function toProduct(
|
||||
item: ApiStoreItem,
|
||||
product: ApiProduct | undefined,
|
||||
categorySlug: string,
|
||||
): Product {
|
||||
const description =
|
||||
product?.summary?.trim() ||
|
||||
stripHtml(product?.descriptionHtml) ||
|
||||
DEFAULT_DESCRIPTION;
|
||||
|
||||
return {
|
||||
id: item.productId,
|
||||
name: item.productNameFa || item.productTitle,
|
||||
nameEn: item.productTitle || product?.title || "",
|
||||
price: resolvePrice(item),
|
||||
category: categorySlug,
|
||||
images: resolveImages(item, product),
|
||||
description,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getMenuSections(): Promise<MenuSection[]> {
|
||||
const [categories, storeItems, products] = await Promise.all([
|
||||
getProductCategories(),
|
||||
getStoreItems(),
|
||||
getPublishedProducts(),
|
||||
]);
|
||||
|
||||
const productsById = new Map(products.map((product) => [product.id, product]));
|
||||
const categoryById = new Map(categories.map((category) => [category.id, category]));
|
||||
|
||||
const mapped = dedupeStoreItemsByProduct(storeItems)
|
||||
.map((item) => {
|
||||
const product = productsById.get(item.productId);
|
||||
const categoryId = product?.categoryId;
|
||||
const category = categoryId ? categoryById.get(categoryId) : undefined;
|
||||
if (!category) return null;
|
||||
return toProduct(item, product, category.slug);
|
||||
})
|
||||
.filter((product): product is Product => product !== null);
|
||||
|
||||
return categories
|
||||
.map((category) => ({
|
||||
id: category.slug,
|
||||
label: category.nameFa || category.name,
|
||||
products: mapped.filter((product) => product.category === category.slug),
|
||||
}))
|
||||
.filter((section) => section.products.length > 0);
|
||||
}
|
||||
Reference in New Issue
Block a user