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:
Alireza Hassani
2026-08-08 18:42:12 +03:30
co-authored by Cursor
parent 5fd7d00563
commit 42dd0595e5
13 changed files with 361 additions and 314 deletions
+2
View File
@@ -0,0 +1,2 @@
NEXT_PUBLIC_API_BASE_URL=https://api.meshkee.com/api/v1
NEXT_PUBLIC_DOMAIN=oaktasty.com
+6
View File
@@ -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
View File
@@ -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} />;
} }
+37 -9
View File
@@ -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>
+8
View File
@@ -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>
+6 -289
View File
@@ -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);
+13
View File
@@ -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);
}
+18
View File
@@ -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>;
}
+5
View File
@@ -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";
+23
View File
@@ -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;
}
+23
View File
@@ -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;
}
+70
View File
@@ -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
View File
@@ -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);
}