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,
|
dangerouslyAllowSVG: true,
|
||||||
contentDispositionType: "attachment",
|
contentDispositionType: "attachment",
|
||||||
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
|
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 { MenuHome } from "@/components/MenuHome";
|
||||||
|
import { getMenuSections } from "@/lib/menu";
|
||||||
|
|
||||||
export default function HomePage() {
|
export default async function HomePage() {
|
||||||
return <MenuHome />;
|
const sections = await getMenuSections();
|
||||||
|
return <MenuHome sections={sections} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,26 +4,36 @@ import Image from "next/image";
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
formatPrice,
|
formatPrice,
|
||||||
menuSections,
|
|
||||||
type CategoryId,
|
type CategoryId,
|
||||||
|
type MenuSection,
|
||||||
type Product,
|
type Product,
|
||||||
} from "@/data/products";
|
} from "@/data/products";
|
||||||
import { ProductModal } from "@/components/ProductModal";
|
import { ProductModal } from "@/components/ProductModal";
|
||||||
import { SiteChrome } from "@/components/SiteChrome";
|
import { SiteChrome } from "@/components/SiteChrome";
|
||||||
|
|
||||||
export function MenuHome() {
|
type MenuHomeProps = {
|
||||||
|
sections: MenuSection[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MenuHome({ sections }: MenuHomeProps) {
|
||||||
const [activeCategory, setActiveCategory] = useState<CategoryId>(
|
const [activeCategory, setActiveCategory] = useState<CategoryId>(
|
||||||
menuSections[0]?.id ?? "hot-drinks",
|
sections[0]?.id ?? "",
|
||||||
);
|
);
|
||||||
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
|
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
|
||||||
const tabRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
const tabRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
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}`))
|
.map((section) => document.getElementById(`category-${section.id}`))
|
||||||
.filter((el): el is HTMLElement => Boolean(el));
|
.filter((el): el is HTMLElement => Boolean(el));
|
||||||
|
|
||||||
if (sections.length === 0) return;
|
if (observed.length === 0) return;
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
@@ -49,9 +59,9 @@ export function MenuHome() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
sections.forEach((section) => observer.observe(section));
|
observed.forEach((section) => observer.observe(section));
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, []);
|
}, [sections]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const activeTab = tabRefs.current[activeCategory];
|
const activeTab = tabRefs.current[activeCategory];
|
||||||
@@ -70,11 +80,21 @@ export function MenuHome() {
|
|||||||
setActiveCategory(id);
|
setActiveCategory(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (sections.length === 0) {
|
||||||
|
return (
|
||||||
|
<SiteChrome eyebrow="منوی کافه و قنادی">
|
||||||
|
<main>
|
||||||
|
<p className="empty-state">هنوز آیتمی در منو ثبت نشده است.</p>
|
||||||
|
</main>
|
||||||
|
</SiteChrome>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SiteChrome eyebrow="منوی کافه و قنادی">
|
<SiteChrome eyebrow="منوی کافه و قنادی">
|
||||||
<nav className="category-nav" aria-label="دستهبندی منو">
|
<nav className="category-nav" aria-label="دستهبندی منو">
|
||||||
<div className="category-scroller">
|
<div className="category-scroller">
|
||||||
{menuSections.map((category) => {
|
{sections.map((category) => {
|
||||||
const isActive = category.id === activeCategory;
|
const isActive = category.id === activeCategory;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -95,7 +115,7 @@ export function MenuHome() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
{menuSections.map((section) => (
|
{sections.map((section) => (
|
||||||
<section
|
<section
|
||||||
key={section.id}
|
key={section.id}
|
||||||
id={`category-${section.id}`}
|
id={`category-${section.id}`}
|
||||||
@@ -127,14 +147,22 @@ export function MenuHome() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="product-card__title">{product.name}</h3>
|
<h3 className="product-card__title">{product.name}</h3>
|
||||||
|
{product.nameEn ? (
|
||||||
<p className="product-card__title-en" lang="en" dir="ltr">
|
<p className="product-card__title-en" lang="en" dir="ltr">
|
||||||
{product.nameEn}
|
{product.nameEn}
|
||||||
</p>
|
</p>
|
||||||
|
) : null}
|
||||||
<p className="product-card__price">
|
<p className="product-card__price">
|
||||||
|
{product.price != null ? (
|
||||||
|
<>
|
||||||
<span className="product-card__price-value">
|
<span className="product-card__price-value">
|
||||||
{formatPrice(product.price)}
|
{formatPrice(product.price)}
|
||||||
</span>
|
</span>
|
||||||
<span className="product-card__price-unit">تومان</span>
|
<span className="product-card__price-unit">تومان</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="product-card__price-value">—</span>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</button>
|
</button>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -143,14 +143,22 @@ export function ProductModal({ product, onClose }: ProductModalProps) {
|
|||||||
<h2 id="product-modal-title" className="product-modal__title">
|
<h2 id="product-modal-title" className="product-modal__title">
|
||||||
{product.name}
|
{product.name}
|
||||||
</h2>
|
</h2>
|
||||||
|
{product.nameEn ? (
|
||||||
<p className="product-modal__title-en" lang="en" dir="ltr">
|
<p className="product-modal__title-en" lang="en" dir="ltr">
|
||||||
{product.nameEn}
|
{product.nameEn}
|
||||||
</p>
|
</p>
|
||||||
|
) : null}
|
||||||
<p className="product-modal__price">
|
<p className="product-modal__price">
|
||||||
|
{product.price != null ? (
|
||||||
|
<>
|
||||||
<span className="product-modal__price-value">
|
<span className="product-modal__price-value">
|
||||||
{formatPrice(product.price)}
|
{formatPrice(product.price)}
|
||||||
</span>
|
</span>
|
||||||
<span className="product-modal__price-unit">تومان</span>
|
<span className="product-modal__price-unit">تومان</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="product-modal__price-value">—</span>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p className="product-modal__desc">{product.description}</p>
|
<p className="product-modal__desc">{product.description}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+5
-288
@@ -1,14 +1,4 @@
|
|||||||
export type CategoryId =
|
export type CategoryId = string;
|
||||||
| "hot-drinks"
|
|
||||||
| "cold-drinks"
|
|
||||||
| "juices"
|
|
||||||
| "gelato-shake"
|
|
||||||
| "smoothie"
|
|
||||||
| "pastry"
|
|
||||||
| "dessert"
|
|
||||||
| "cake"
|
|
||||||
| "bakery"
|
|
||||||
| "boxed-sweets";
|
|
||||||
|
|
||||||
export type Category = {
|
export type Category = {
|
||||||
id: CategoryId;
|
id: CategoryId;
|
||||||
@@ -16,291 +6,18 @@ export type Category = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type Product = {
|
export type Product = {
|
||||||
id: number;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
nameEn: string;
|
nameEn: string;
|
||||||
price: number;
|
price: number | null;
|
||||||
category: CategoryId;
|
category: CategoryId;
|
||||||
images: string[];
|
images: string[];
|
||||||
description: string;
|
description: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const categories: Category[] = [
|
export type MenuSection = Category & {
|
||||||
{ id: "hot-drinks", label: "نوشیدنی گرم" },
|
products: Product[];
|
||||||
{ 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 function formatPrice(price: number): string {
|
export function formatPrice(price: number): string {
|
||||||
return new Intl.NumberFormat("fa-IR").format(price);
|
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