+
setMobileOpen(false)}
+ />
+
+
+
+ setMobileOpen(false)}
+ aria-label="بستن منو"
+ className="p-2 text-ink"
+ >
+
+
+
+
+
+
+
+
+
setMobileCategoriesOpen((v) => !v)}
+ >
+ محصولات
+
+
+ {mobileCategoriesOpen && (
+
+ {categoryColumns.map((col) => (
+
+
setMobileOpen(false)}
+ >
+ {col.nameFa}
+
+
+ {col.children.map((sub) => (
+
+ setMobileOpen(false)}
+ >
+ {sub.nameFa}
+
+
+ ))}
+
+
+ ))}
+
+ )}
+
+ {headerNavLinks.map((link) => (
+
+ {link.title}
+
+ ))}
+
+
+ ورود / ثبتنام
+
+
+
+ {socialLinks.map(({ icon: Icon, label }) => (
+
+
+
+ ))}
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/components/HeroSlider.tsx b/src/components/HeroSlider.tsx
new file mode 100644
index 0000000..bd8c4cf
--- /dev/null
+++ b/src/components/HeroSlider.tsx
@@ -0,0 +1,66 @@
+"use client";
+
+import { Swiper, SwiperSlide } from "swiper/react";
+import { Autoplay, Pagination } from "swiper/modules";
+import "swiper/css";
+import "swiper/css/pagination";
+import type { StaticImageSlot } from "@/lib/types";
+
+const fallbackSlides = [
+ "/images/hero/slide-1.svg",
+ "/images/hero/slide-2.svg",
+ "/images/hero/slide-3.svg",
+];
+
+export default function HeroSlider({ slot }: { slot?: StaticImageSlot }) {
+ const slides = slot?.images ?? [];
+ const aspectStyle = slot?.aspectRatio ? { aspectRatio: slot.aspectRatio } : undefined;
+
+ return (
+
+
+ {slides.length > 0
+ ? slides.map((img) => (
+
+ {img.linkUrl ? (
+
+
+
+ ) : (
+
+ )}
+
+ ))
+ : fallbackSlides.map((src) => (
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/Logo.tsx b/src/components/Logo.tsx
new file mode 100644
index 0000000..e80ae7d
--- /dev/null
+++ b/src/components/Logo.tsx
@@ -0,0 +1,16 @@
+import Link from "next/link";
+import { siteInfo } from "@/data/site";
+
+export default function Logo({ dark = false }: { dark?: boolean }) {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/ProductBuyPanel.tsx b/src/components/ProductBuyPanel.tsx
new file mode 100644
index 0000000..9266fc7
--- /dev/null
+++ b/src/components/ProductBuyPanel.tsx
@@ -0,0 +1,193 @@
+"use client";
+
+import { useState } from "react";
+import type { ApiProduct, StoreItem } from "@/lib/types";
+import { BagIcon, CheckIcon, PhoneIcon, RotateIcon, ShieldIcon, TruckIcon } from "./icons";
+
+const trustPoints = [
+ { icon: ShieldIcon, title: "استفاده از بهترین متریال" },
+ { icon: TruckIcon, title: "ارسال سریع به سراسر کشور" },
+ { icon: PhoneIcon, title: "مشاوره و پشتیبانی آنلاین" },
+ { icon: RotateIcon, title: "تا ۷ روز امکان مرجوعی کالا" },
+];
+
+export default function ProductBuyPanel({
+ product,
+ items,
+}: {
+ product: ApiProduct;
+ items: StoreItem[];
+}) {
+ const [selectedId, setSelectedId] = useState(items[0]?.id);
+ const [added, setAdded] = useState(false);
+ const selected = items.find((i) => i.id === selectedId) ?? items[0];
+
+ const title = product.nameFa || product.title;
+ const variationName = selected?.selections[0]?.variationName;
+ const inStock = selected ? selected.stockQuantity > 0 : product.store.inStock;
+ const stockQuantity = selected
+ ? selected.stockQuantity
+ : items.reduce((sum, i) => sum + i.stockQuantity, 0);
+
+ let finalPrice: number | null = product.store.minPrice;
+ let oldPrice: number | null = null;
+ let discountPercent: number | null = null;
+
+ if (selected) {
+ const { price, discountedPrice } = selected;
+ if (discountedPrice != null && price != null && discountedPrice < price) {
+ finalPrice = discountedPrice;
+ oldPrice = price;
+ discountPercent = Math.round(((price - discountedPrice) / price) * 100);
+ } else {
+ finalPrice = discountedPrice ?? price ?? product.store.minPrice;
+ }
+ }
+
+ function handleAddToCart() {
+ setAdded(true);
+ setTimeout(() => setAdded(false), 1800);
+ }
+
+ return (
+
+ {/* Info column */}
+
+
{title}
+ {product.brand &&
برند: {product.brand}
}
+
+ {product.summary && (
+
+
توضیح کوتاه:
+
{product.summary}
+
+ )}
+
+ {items.length > 0 && (
+
+
{variationName || "انتخاب گزینه"}
+
+ {items.map((item) => {
+ const rawValue = item.selections[0]?.value;
+ const colorHex = rawValue?.startsWith("#") ? rawValue : null;
+
+ return (
+ setSelectedId(item.id)}
+ disabled={item.stockQuantity <= 0}
+ title={item.label}
+ className={`flex items-center justify-center transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${
+ colorHex
+ ? `size-9 rounded-full border-2 ${
+ item.id === selectedId ? "border-brand" : "border-line"
+ }`
+ : `rounded-lg border px-4 h-10 text-sm font-bold ${
+ item.id === selectedId
+ ? "border-brand bg-brand text-white"
+ : "border-line text-ink hover:border-brand hover:text-brand"
+ }`
+ }`}
+ >
+ {colorHex ? (
+
+ ) : (
+ item.label
+ )}
+
+ );
+ })}
+
+
+ )}
+
+
+ {items.length > 0 && (
+
+ موجود در انبار:{" "}
+ {stockQuantity.toLocaleString("en-US")} عدد
+
+ )}
+ {product.categoryNameFa && (
+
+ دستهبندی: {product.categoryNameFa}
+
+ )}
+ {product.tags.length > 0 && (
+
تگها: {product.tags.join("، ")}
+ )}
+
+
+
+ {/* Purchase column */}
+
+
+
+
+ {inStock ? "آماده ارسال" : "ناموجود"}
+
+
+
+
+
+
قیمت:
+
+ {discountPercent != null && (
+
+ %{discountPercent}
+
+ )}
+ {oldPrice != null && (
+
+ {oldPrice.toLocaleString("en-US")}
+
+ )}
+
+
+
+
+ تومان
+
+ {finalPrice != null ? finalPrice.toLocaleString("en-US") : "تماس بگیرید"}
+
+
+
+
+ {added ? (
+ <>
+
+ افزوده شد
+ >
+ ) : (
+ <>
+
+ افزودن به سبد خرید
+ >
+ )}
+
+
+
+
+ {trustPoints.map((p) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/components/ProductGallery.tsx b/src/components/ProductGallery.tsx
new file mode 100644
index 0000000..26bc698
--- /dev/null
+++ b/src/components/ProductGallery.tsx
@@ -0,0 +1,62 @@
+"use client";
+
+import { useState } from "react";
+import { ChevronLeftIcon, ChevronRightIcon } from "./icons";
+
+export default function ProductGallery({ images, title }: { images: string[]; title: string }) {
+ const gallery = images.length > 0 ? images : ["/images/products/product-1.svg"];
+ const [active, setActive] = useState(0);
+
+ function prev() {
+ setActive((i) => (i - 1 + gallery.length) % gallery.length);
+ }
+ function next() {
+ setActive((i) => (i + 1) % gallery.length);
+ }
+
+ return (
+
+
+
+
+ {gallery.length > 1 && (
+ <>
+
+
+
+
+
+
+ >
+ )}
+
+
+ {gallery.length > 1 && (
+
+ {gallery.map((src, i) => (
+
setActive(i)}
+ className={`shrink-0 size-16 md:size-20 rounded-lg overflow-hidden border-2 transition-colors ${
+ i === active ? "border-brand" : "border-line"
+ }`}
+ >
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/components/ProductInfoTabs.tsx b/src/components/ProductInfoTabs.tsx
new file mode 100644
index 0000000..8b16a1b
--- /dev/null
+++ b/src/components/ProductInfoTabs.tsx
@@ -0,0 +1,104 @@
+"use client";
+
+import { useState } from "react";
+import type { EntityComment, TechnicalInfoValue } from "@/lib/types";
+
+type Tab = "description" | "technical" | "reviews";
+
+export default function ProductInfoTabs({
+ title,
+ descriptionHtml,
+ technicalValues,
+ comments,
+}: {
+ title: string;
+ descriptionHtml?: string;
+ technicalValues: TechnicalInfoValue[];
+ comments: EntityComment[];
+}) {
+ const [tab, setTab] = useState
("description");
+
+ const tabs: { key: Tab; label: string }[] = [
+ { key: "description", label: "توضیحات" },
+ { key: "technical", label: "مشخصات فنی" },
+ { key: "reviews", label: `دیدگاه کاربران${comments.length > 0 ? ` (${comments.length})` : ""}` },
+ ];
+
+ return (
+
+
+ {tabs.map((t) => (
+ setTab(t.key)}
+ className={`rounded-t-lg px-5 h-11 text-sm font-bold transition-colors border-b-2 -mb-px ${
+ tab === t.key
+ ? "border-brand text-brand"
+ : "border-transparent text-body hover:text-ink"
+ }`}
+ >
+ {t.label}
+
+ ))}
+
+
+
+ {tab === "description" && (
+
+
معرفی {title}
+ {descriptionHtml ? (
+
+ ) : (
+
توضیحی برای این محصول ثبت نشده است.
+ )}
+
+ )}
+
+ {tab === "technical" && (
+
+ {technicalValues.length > 0 ? (
+
+ {technicalValues.map((v, i) => (
+
+
{v.labelFa || v.label || v.key}
+ {v.value}
+
+ ))}
+
+ ) : (
+
مشخصات فنی این محصول ثبت نشده است.
+ )}
+
+ )}
+
+ {tab === "reviews" && (
+
+ {comments.length > 0 ? (
+
+ ) : (
+
+ هنوز دیدگاهی برای این محصول ثبت نشده است.
+
+ )}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/SearchBox.tsx b/src/components/SearchBox.tsx
new file mode 100644
index 0000000..c65fa30
--- /dev/null
+++ b/src/components/SearchBox.tsx
@@ -0,0 +1,120 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import Link from "next/link";
+import { getProducts } from "@/lib/api";
+import type { ApiProduct } from "@/lib/types";
+import { formatToman } from "@/lib/format";
+import { SearchIcon } from "./icons";
+
+const MIN_QUERY_LENGTH = 3;
+const DEBOUNCE_MS = 350;
+
+export default function SearchBox({
+ placeholder = "جستجوی محصولات...",
+ inputHeightClassName = "h-11",
+}: {
+ placeholder?: string;
+ inputHeightClassName?: string;
+}) {
+ const [query, setQuery] = useState("");
+ const [results, setResults] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [open, setOpen] = useState(false);
+ const containerRef = useRef(null);
+
+ function handleQueryChange(value: string) {
+ setQuery(value);
+ if (value.trim().length < MIN_QUERY_LENGTH) {
+ setResults([]);
+ setOpen(false);
+ setLoading(false);
+ } else {
+ setLoading(true);
+ }
+ }
+
+ useEffect(() => {
+ const term = query.trim();
+ if (term.length < MIN_QUERY_LENGTH) return;
+
+ const handle = setTimeout(() => {
+ getProducts({ name: term, pageSize: 6 })
+ .then((res) => {
+ setResults(res.items);
+ setOpen(true);
+ })
+ .catch(() => setResults([]))
+ .finally(() => setLoading(false));
+ }, DEBOUNCE_MS);
+
+ return () => clearTimeout(handle);
+ }, [query]);
+
+ useEffect(() => {
+ function onClickOutside(event: MouseEvent) {
+ if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
+ setOpen(false);
+ }
+ }
+ document.addEventListener("mousedown", onClickOutside);
+ return () => document.removeEventListener("mousedown", onClickOutside);
+ }, []);
+
+ const term = query.trim();
+ const showDropdown = open && term.length >= MIN_QUERY_LENGTH;
+
+ return (
+
+
+
+ handleQueryChange(e.target.value)}
+ onFocus={() => term.length >= MIN_QUERY_LENGTH && setOpen(true)}
+ placeholder={placeholder}
+ className="flex-1 bg-transparent outline-none text-sm placeholder:text-body/70"
+ />
+
+
+ {showDropdown && (
+
+ {loading &&
در حال جستجو...
}
+
+ {!loading && results.length === 0 && (
+
نتیجهای یافت نشد
+ )}
+
+ {!loading &&
+ results.map((product) => (
+
setOpen(false)}
+ className="flex items-center gap-3 p-3 hover:bg-surface transition-colors border-b border-line last:border-0"
+ >
+
+
+
+ {product.nameFa || product.title}
+
+
+ {product.store?.minPrice != null
+ ? formatToman(product.store.minPrice)
+ : "تماس بگیرید"}
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/components/SpecialSaleCarousel.tsx b/src/components/SpecialSaleCarousel.tsx
new file mode 100644
index 0000000..8a64efb
--- /dev/null
+++ b/src/components/SpecialSaleCarousel.tsx
@@ -0,0 +1,83 @@
+"use client";
+
+import Link from "next/link";
+import { Swiper, SwiperSlide } from "swiper/react";
+import { Autoplay, Navigation } from "swiper/modules";
+import "swiper/css";
+import type { ApiProduct } from "@/lib/types";
+import ProductCard from "./ui/ProductCard";
+import { ChevronLeftIcon, ChevronRightIcon } from "./icons";
+
+export default function SpecialSaleCarousel({ products }: { products: ApiProduct[] }) {
+ if (products.length === 0) return null;
+
+ return (
+
+
+
+
+ Special Discount
+
+
+ تخفیفهای ویژه
+
+
+
+
+ مشاهده همه
+
+
+
+
+
+
5}
+ spaceBetween={14}
+ slidesPerView={2.15}
+ breakpoints={{
+ 480: { slidesPerView: 2.4 },
+ 640: { slidesPerView: 3.2 },
+ 1024: { slidesPerView: 5, spaceBetween: 20 },
+ }}
+ >
+
+
+
+ {products.map((product) => (
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/TopSellingCarousel.tsx b/src/components/TopSellingCarousel.tsx
new file mode 100644
index 0000000..8bc3554
--- /dev/null
+++ b/src/components/TopSellingCarousel.tsx
@@ -0,0 +1,60 @@
+"use client";
+
+import { Swiper, SwiperSlide } from "swiper/react";
+import { Navigation } from "swiper/modules";
+import "swiper/css";
+import "swiper/css/navigation";
+import type { ApiProduct } from "@/lib/types";
+import ProductCard from "./ui/ProductCard";
+import SectionHeading from "./ui/SectionHeading";
+import { ChevronLeftIcon, ChevronRightIcon } from "./icons";
+
+export default function TopSellingCarousel({ products }: { products: ApiProduct[] }) {
+ if (products.length === 0) return null;
+
+ return (
+
+
+
+
+ {products.map((product) => (
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/TwoBanners.tsx b/src/components/TwoBanners.tsx
new file mode 100644
index 0000000..db3cde1
--- /dev/null
+++ b/src/components/TwoBanners.tsx
@@ -0,0 +1,42 @@
+import type { StaticImageSlot } from "@/lib/types";
+
+const fallbackBanners = [
+ { image: "/images/banners/banner-1.svg", href: "#" },
+ { image: "/images/banners/banner-2.svg", href: "#" },
+];
+
+export default function TwoBanners({ slot }: { slot?: StaticImageSlot }) {
+ const images = slot?.images ?? [];
+ const aspectStyle = slot?.aspectRatio ? { aspectRatio: slot.aspectRatio } : undefined;
+
+ const banners =
+ images.length > 0
+ ? images.map((img) => ({ image: img.url, href: img.linkUrl || undefined }))
+ : fallbackBanners;
+
+ return (
+
+
+ {banners.map((b, i) => {
+ const Wrapper = b.href ? "a" : "div";
+ return (
+
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/components/icons.tsx b/src/components/icons.tsx
new file mode 100644
index 0000000..0e035b8
--- /dev/null
+++ b/src/components/icons.tsx
@@ -0,0 +1,173 @@
+type IconProps = { className?: string };
+
+const base = {
+ fill: "none",
+ stroke: "currentColor",
+ strokeWidth: 1.2,
+ strokeLinecap: "round" as const,
+ strokeLinejoin: "round" as const,
+};
+
+export function SearchIcon({ className }: IconProps) {
+ return (
+
+
+
+
+ );
+}
+
+export function BagIcon({ className }: IconProps) {
+ return (
+
+
+
+
+ );
+}
+
+export function ChevronDownIcon({ className }: IconProps) {
+ return (
+
+
+
+ );
+}
+
+export function ChevronLeftIcon({ className }: IconProps) {
+ return (
+
+
+
+ );
+}
+
+export function ChevronRightIcon({ className }: IconProps) {
+ return (
+
+
+
+ );
+}
+
+export function MenuIcon({ className }: IconProps) {
+ return (
+
+
+
+ );
+}
+
+export function CloseIcon({ className }: IconProps) {
+ return (
+
+
+
+ );
+}
+
+export function PhoneIcon({ className }: IconProps) {
+ return (
+
+
+
+ );
+}
+
+export function TruckIcon({ className }: IconProps) {
+ return (
+
+
+
+
+
+
+ );
+}
+
+export function ShieldIcon({ className }: IconProps) {
+ return (
+
+
+
+
+ );
+}
+
+export function TagIcon({ className }: IconProps) {
+ return (
+
+
+
+
+ );
+}
+
+export function InstagramIcon({ className }: IconProps) {
+ return (
+
+
+
+
+
+ );
+}
+
+export function TelegramIcon({ className }: IconProps) {
+ return (
+
+
+
+ );
+}
+
+export function WhatsappIcon({ className }: IconProps) {
+ return (
+
+
+
+ );
+}
+
+export function PinIcon({ className }: IconProps) {
+ return (
+
+
+
+
+ );
+}
+
+export function RotateIcon({ className }: IconProps) {
+ return (
+
+
+
+
+ );
+}
+
+export function CheckIcon({ className }: IconProps) {
+ return (
+
+
+
+ );
+}
+
+export function CommentIcon({ className }: IconProps) {
+ return (
+
+
+
+ );
+}
+
+export function MailIcon({ className }: IconProps) {
+ return (
+
+
+
+
+ );
+}
diff --git a/src/components/ui/Pagination.tsx b/src/components/ui/Pagination.tsx
new file mode 100644
index 0000000..c194aaa
--- /dev/null
+++ b/src/components/ui/Pagination.tsx
@@ -0,0 +1,64 @@
+import Link from "next/link";
+import { ChevronLeftIcon, ChevronRightIcon } from "../icons";
+
+export default function Pagination({
+ page,
+ totalPages,
+ buildHref,
+}: {
+ page: number;
+ totalPages: number;
+ buildHref: (page: number) => string;
+}) {
+ if (totalPages <= 1) return null;
+
+ const pages = Array.from({ length: totalPages }, (_, i) => i + 1).filter(
+ (p) => p === 1 || p === totalPages || Math.abs(p - page) <= 1
+ );
+
+ return (
+
+
+
+
+
+ {pages.map((p, i) => {
+ const prev = pages[i - 1];
+ const gap = prev !== undefined && p - prev > 1;
+ return (
+
+ {gap && ... }
+
+ {p}
+
+
+ );
+ })}
+
+
+
+
+
+ );
+}
diff --git a/src/components/ui/ProductCard.tsx b/src/components/ui/ProductCard.tsx
new file mode 100644
index 0000000..504dcc4
--- /dev/null
+++ b/src/components/ui/ProductCard.tsx
@@ -0,0 +1,41 @@
+import Link from "next/link";
+import type { ApiProduct } from "@/lib/types";
+import { formatToman } from "@/lib/format";
+
+export default function ProductCard({ product }: { product: ApiProduct }) {
+ const title = product.nameFa || product.title;
+ const image =
+ product.thumbnailUrl || product.image || product.thumbnail || "/images/products/product-1.svg";
+ const store = product.store;
+
+ return (
+
+
+
+
+ {store && !store.inStock && (
+
+
+ ناموجود
+
+
+ )}
+
+
+
+
{title}
+
+
+ {store?.minPrice != null ? formatToman(store.minPrice) : "تماس بگیرید"}
+
+ {store?.maxPrice != null && store.maxPrice !== store.minPrice && (
+ تا {formatToman(store.maxPrice)}
+ )}
+
+
+
+ );
+}
diff --git a/src/components/ui/SectionHeading.tsx b/src/components/ui/SectionHeading.tsx
new file mode 100644
index 0000000..1ac2296
--- /dev/null
+++ b/src/components/ui/SectionHeading.tsx
@@ -0,0 +1,23 @@
+export default function SectionHeading({
+ kicker,
+ title,
+ align = "center",
+}: {
+ kicker: string;
+ title: string;
+ align?: "center" | "start";
+}) {
+ return (
+
+
+ {kicker}
+
+
{title}
+
+
+ );
+}
diff --git a/src/data/site.ts b/src/data/site.ts
new file mode 100644
index 0000000..2986e06
--- /dev/null
+++ b/src/data/site.ts
@@ -0,0 +1,14 @@
+export const siteInfo = {
+ name: "کودکانه",
+ nameEn: "KIDONE",
+ phone: "021-91234567",
+ address: "تهران، خیابان ولیعصر، مرکز پخش پوشاک",
+ email: "info@kidone.shop",
+};
+
+export const headerNavLinks = [
+ { title: "تخفیفهای ویژه", href: "#special-sale" },
+ { title: "درباره ما", href: "#about" },
+ { title: "سوالات متداول", href: "#faq" },
+ { title: "تماس با ما", href: "#contact" },
+];
diff --git a/src/lib/api.ts b/src/lib/api.ts
new file mode 100644
index 0000000..4de8c1d
--- /dev/null
+++ b/src/lib/api.ts
@@ -0,0 +1,117 @@
+import { API_BASE, SITE_DOMAIN } from "./config";
+import type {
+ ApiBlog,
+ ApiCategory,
+ ApiProduct,
+ EntityComment,
+ PagedResult,
+ ProductVariation,
+ StaticImageSlot,
+ StoreItem,
+ TechnicalInfoValue,
+} from "./types";
+
+type QueryParams = Record;
+
+async function apiGet(path: string, params?: QueryParams): Promise {
+ const url = new URL(`${API_BASE}${path}`);
+ if (params) {
+ for (const [key, value] of Object.entries(params)) {
+ if (value !== undefined && value !== null && value !== "") {
+ url.searchParams.set(key, String(value));
+ }
+ }
+ }
+
+ const res = await fetch(url.toString(), { next: { revalidate: 60 } });
+ if (!res.ok) {
+ throw new Error(`API request failed (${res.status}): ${path}`);
+ }
+ return res.json() as Promise;
+}
+
+// Swallows errors so a backend hiccup degrades a section instead of
+// crashing the whole page.
+export async function safe(promise: Promise, fallback: T): Promise {
+ try {
+ return await promise;
+ } catch {
+ return fallback;
+ }
+}
+
+export function getCategories(
+ entityType: "product" | "blog" | "portfolio" | "video" = "product"
+): Promise {
+ return apiGet<{ items: ApiCategory[] }>(`/tenants/${SITE_DOMAIN}/categories`, {
+ entityType,
+ }).then((r) => r.items);
+}
+
+export function getProducts(
+ params: {
+ page?: number;
+ pageSize?: number;
+ name?: string;
+ categoryId?: string;
+ brandId?: string;
+ tag?: string;
+ inStore?: boolean;
+ } = {}
+): Promise> {
+ return apiGet>(`/tenants/${SITE_DOMAIN}/products`, params);
+}
+
+export function getProduct(
+ slug: string
+): Promise<{ product: ApiProduct; relatedProducts: ApiProduct[] }> {
+ return apiGet(`/tenants/${SITE_DOMAIN}/products/${slug}`);
+}
+
+export function getProductVariations(
+ slug: string
+): Promise<{ variations: ProductVariation[] }> {
+ return apiGet(`/tenants/${SITE_DOMAIN}/products/${slug}/variations`);
+}
+
+export function getStoreItemsByProduct(productId: string): Promise<{ items: StoreItem[] }> {
+ return apiGet(`/tenants/${SITE_DOMAIN}/store-items`, { productId, pageSize: 100 });
+}
+
+export function getProductTechnicalInfo(
+ slug: string
+): Promise<{ form: unknown; values: TechnicalInfoValue[] }> {
+ return apiGet(`/tenants/${SITE_DOMAIN}/products/${slug}/technical-info`);
+}
+
+export function getComments(
+ entityType: "product" | "blog" | "portfolio" | "video",
+ entityId: string
+): Promise<{ items: EntityComment[] }> {
+ return apiGet(`/tenants/${SITE_DOMAIN}/comments`, { entityType, entityId });
+}
+
+export function getBlogs(
+ params: { page?: number; pageSize?: number; categoryId?: string; title?: string } = {}
+): Promise> {
+ return apiGet>(`/tenants/${SITE_DOMAIN}/blogs`, params);
+}
+
+export function getBlog(slug: string): Promise<{ blog: ApiBlog }> {
+ return apiGet(`/tenants/${SITE_DOMAIN}/blogs/${slug}`);
+}
+
+export function getStaticImageSlots(pageKey?: string): Promise {
+ return apiGet<{ items: StaticImageSlot[] }>(`/tenants/${SITE_DOMAIN}/website/static-images`, {
+ pageKey,
+ }).then((r) => r.items);
+}
+
+export function findSlot(
+ slots: StaticImageSlot[],
+ key: string
+): StaticImageSlot | undefined {
+ const slot = slots.find((s) => s.key === key);
+ if (!slot || slot.images.length === 0) return undefined;
+ return { ...slot, images: [...slot.images].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)) };
+}
diff --git a/src/lib/categoryTree.ts b/src/lib/categoryTree.ts
new file mode 100644
index 0000000..0e1bc11
--- /dev/null
+++ b/src/lib/categoryTree.ts
@@ -0,0 +1,30 @@
+import type { ApiCategory, CategoryNode } from "./types";
+
+export function buildCategoryTree(flat: ApiCategory[]): CategoryNode[] {
+ const byId = new Map();
+ flat.forEach((c) => byId.set(c.id, { ...c, children: [] }));
+
+ const roots: CategoryNode[] = [];
+ flat.forEach((c) => {
+ const node = byId.get(c.id)!;
+ const parent = c.parentId ? byId.get(c.parentId) : undefined;
+ if (parent) parent.children.push(node);
+ else roots.push(node);
+ });
+
+ const bySortOrder = (a: CategoryNode, b: CategoryNode) => a.sortOrder - b.sortOrder;
+ const sortRecursive = (nodes: CategoryNode[]) => {
+ nodes.sort(bySortOrder);
+ nodes.forEach((n) => sortRecursive(n.children));
+ };
+ sortRecursive(roots);
+
+ return roots;
+}
+
+// The header mega-menu shows level 2 as columns and level 3 as the links
+// inside each column, skipping the level-1 root category entirely.
+export function getMegaMenuColumns(flat: ApiCategory[]): CategoryNode[] {
+ const tree = buildCategoryTree(flat);
+ return tree.flatMap((root) => root.children);
+}
diff --git a/src/lib/config.ts b/src/lib/config.ts
new file mode 100644
index 0000000..6b23f58
--- /dev/null
+++ b/src/lib/config.ts
@@ -0,0 +1,3 @@
+export const SITE_DOMAIN = process.env.NEXT_PUBLIC_SITE_DOMAIN || "simode.shop";
+export const API_BASE = "https://api.meshkee.com/api/v1";
+export const CUSTOMER_PORTAL_URL = `https://customer.${SITE_DOMAIN}`;
diff --git a/src/lib/format.ts b/src/lib/format.ts
new file mode 100644
index 0000000..ad40b38
--- /dev/null
+++ b/src/lib/format.ts
@@ -0,0 +1,3 @@
+export function formatToman(value: number): string {
+ return `${value.toLocaleString("en-US")} تومان`;
+}
diff --git a/src/lib/types.ts b/src/lib/types.ts
new file mode 100644
index 0000000..351a669
--- /dev/null
+++ b/src/lib/types.ts
@@ -0,0 +1,166 @@
+export interface StaticImageItem {
+ id: string;
+ url: string;
+ titleFa?: string | null;
+ titleEn?: string | null;
+ subtext?: string | null;
+ linkUrl?: string | null;
+ width?: number | null;
+ height?: number | null;
+ sortOrder?: number;
+}
+
+export interface StaticImageSlot {
+ key: string;
+ pageKey: string;
+ kind: "single" | "list";
+ label: string;
+ aspectRatio?: string | number | null;
+ recommendedWidth?: number | null;
+ itemCount?: number | null;
+ images: StaticImageItem[];
+}
+
+export interface ApiCategory {
+ id: string;
+ businessId: string;
+ entityType: string;
+ parentId: string | null;
+ name: string;
+ nameFa: string;
+ slug: string;
+ description: string | null;
+ sortOrder: number;
+ isActive: boolean;
+ variationCount: number;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface CategoryNode extends ApiCategory {
+ children: CategoryNode[];
+}
+
+export interface ProductImage {
+ mediaId: string;
+ url: string;
+}
+
+export interface ProductStoreSummary {
+ variantCount: number;
+ minPrice: number | null;
+ maxPrice: number | null;
+ inStock: boolean;
+}
+
+export interface ApiProduct {
+ id: string;
+ businessId: string;
+ title: string;
+ nameFa: string;
+ summary: string;
+ descriptionHtml: string;
+ slug: string;
+ status: string;
+ categoryId: string | null;
+ categoryName: string | null;
+ categoryNameFa: string | null;
+ brandId: string | null;
+ brand: string | null;
+ tags: string[];
+ thumbnailUrl: string | null;
+ image: string | null;
+ thumbnail: string | null;
+ images: ProductImage[];
+ commentCount: number;
+ variantCount: number;
+ createdAt: string;
+ updatedAt: string;
+ store: ProductStoreSummary;
+}
+
+export interface PagedResult {
+ items: T[];
+ total: number;
+ page: number;
+ pageSize: number;
+}
+
+// Blog object shape isn't documented and this tenant has no posts yet, so
+// every field is optional and consumers must read defensively.
+export interface ApiBlog {
+ id: string;
+ title?: string;
+ nameFa?: string;
+ summary?: string;
+ descriptionHtml?: string;
+ contentHtml?: string;
+ slug: string;
+ thumbnailUrl?: string | null;
+ image?: string | null;
+ images?: ProductImage[];
+ categoryName?: string | null;
+ categoryNameFa?: string | null;
+ createdAt?: string;
+ updatedAt?: string;
+ publishedAt?: string;
+}
+
+export interface VariationOption {
+ id: string;
+ label: string;
+ value: string;
+ colorHex: string | null;
+ sortOrder: number;
+}
+
+export interface ProductVariation {
+ id: string;
+ name: string;
+ type: string;
+ sortOrder: number;
+ options: VariationOption[];
+}
+
+export interface StoreItemSelection {
+ variationId: string;
+ variationName: string;
+ optionId: string;
+ value: string;
+}
+
+export interface StoreItem {
+ id: string;
+ storeItemId: string;
+ productId: string;
+ productTitle: string;
+ productNameFa: string;
+ productImage: string | null;
+ productTotalStock: number;
+ selections: StoreItemSelection[];
+ label: string;
+ price: number | null;
+ discountedPrice: number | null;
+ stockQuantity: number;
+ rewardPoints: number | null;
+ isFestival: boolean;
+ sortOrder: number;
+ createdAt: string;
+ updatedAt: string;
+}
+
+// Technical-info and comment shapes aren't documented and this tenant has
+// no data yet — every field is optional and consumers must read defensively.
+export interface TechnicalInfoValue {
+ key?: string;
+ label?: string;
+ labelFa?: string;
+ value?: string;
+}
+
+export interface EntityComment {
+ id: string;
+ authorName?: string;
+ text?: string;
+ createdAt?: string;
+}