diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c2605fb --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +NEXT_PUBLIC_API_BASE_URL=https://api.meshkee.com/api/v1 +NEXT_PUBLIC_DOMAIN=oaktasty.com diff --git a/next.config.ts b/next.config.ts index fedd784..cfd95ab 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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", + }, + ], }, }; diff --git a/src/app/page.tsx b/src/app/page.tsx index 3c8f89c..bd17b84 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,5 +1,7 @@ import { MenuHome } from "@/components/MenuHome"; +import { getMenuSections } from "@/lib/menu"; -export default function HomePage() { - return ; +export default async function HomePage() { + const sections = await getMenuSections(); + return ; } diff --git a/src/components/MenuHome.tsx b/src/components/MenuHome.tsx index bd233f2..8a07b06 100644 --- a/src/components/MenuHome.tsx +++ b/src/components/MenuHome.tsx @@ -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( - menuSections[0]?.id ?? "hot-drinks", + sections[0]?.id ?? "", ); const [selectedProduct, setSelectedProduct] = useState(null); const tabRefs = useRef>({}); 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 ( + + + هنوز آیتمی در منو ثبت نشده است. + + + ); + } + return ( - {menuSections.map((category) => { + {sections.map((category) => { const isActive = category.id === activeCategory; return ( - {menuSections.map((section) => ( + {sections.map((section) => ( {product.name} - - {product.nameEn} - + {product.nameEn ? ( + + {product.nameEn} + + ) : null} - - {formatPrice(product.price)} - - تومان + {product.price != null ? ( + <> + + {formatPrice(product.price)} + + تومان + > + ) : ( + — + )} diff --git a/src/components/ProductModal.tsx b/src/components/ProductModal.tsx index 020a7dd..53173b9 100644 --- a/src/components/ProductModal.tsx +++ b/src/components/ProductModal.tsx @@ -143,14 +143,22 @@ export function ProductModal({ product, onClose }: ProductModalProps) { {product.name} - - {product.nameEn} - + {product.nameEn ? ( + + {product.nameEn} + + ) : null} - - {formatPrice(product.price)} - - تومان + {product.price != null ? ( + <> + + {formatPrice(product.price)} + + تومان + > + ) : ( + — + )} {product.description} diff --git a/src/data/products.ts b/src/data/products.ts index 9b1ae55..38127d2 100644 --- a/src/data/products.ts +++ b/src/data/products.ts @@ -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 & { - 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); diff --git a/src/lib/api/categories.ts b/src/lib/api/categories.ts new file mode 100644 index 0000000..e315d1c --- /dev/null +++ b/src/lib/api/categories.ts @@ -0,0 +1,13 @@ +import { apiGet } from "./client"; +import { TENANT_DOMAIN } from "./config"; +import type { ApiCategory, CategoriesResponse } from "./types"; + +export async function getProductCategories(): Promise { + const data = await apiGet( + `/tenants/${TENANT_DOMAIN}/categories?entityType=product`, + ); + + return [...data.items] + .filter((category) => category.isActive && category.parentId === null) + .sort((a, b) => a.sortOrder - b.sortOrder); +} diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts new file mode 100644 index 0000000..e6a1b27 --- /dev/null +++ b/src/lib/api/client.ts @@ -0,0 +1,18 @@ +import { API_BASE_URL } from "./config"; + +export async function apiGet(path: string, init?: RequestInit): Promise { + 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; +} diff --git a/src/lib/api/config.ts b/src/lib/api/config.ts new file mode 100644 index 0000000..804eaa6 --- /dev/null +++ b/src/lib/api/config.ts @@ -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"; diff --git a/src/lib/api/products.ts b/src/lib/api/products.ts new file mode 100644 index 0000000..d0a03e6 --- /dev/null +++ b/src/lib/api/products.ts @@ -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 { + const first = await apiGet( + `/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( + `/tenants/${TENANT_DOMAIN}/products?page=${page}&pageSize=${PAGE_SIZE}`, + ); + items.push(...next.items); + } + + return items; +} diff --git a/src/lib/api/store-items.ts b/src/lib/api/store-items.ts new file mode 100644 index 0000000..fbe4a21 --- /dev/null +++ b/src/lib/api/store-items.ts @@ -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 { + const first = await apiGet( + `/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( + `/tenants/${TENANT_DOMAIN}/store-items?page=${page}&pageSize=${PAGE_SIZE}`, + ); + items.push(...next.items); + } + + return items; +} diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts new file mode 100644 index 0000000..65f15ac --- /dev/null +++ b/src/lib/api/types.ts @@ -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; +}; diff --git a/src/lib/menu.ts b/src/lib/menu.ts new file mode 100644 index 0000000..c89039a --- /dev/null +++ b/src/lib/menu.ts @@ -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(); + + 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 { + 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); +}
هنوز آیتمی در منو ثبت نشده است.
- {product.nameEn} -
+ {product.nameEn} +
- - {formatPrice(product.price)} - - تومان + {product.price != null ? ( + <> + + {formatPrice(product.price)} + + تومان + > + ) : ( + — + )}
{product.description}