+
- ارسال به سراسر ایران و کشورهای همسایه
+ {titleFa}
-
- Shipping Across Iran and Neighboring Countries
-
+ {titleEn ? (
+
+ {titleEn}
+
+ ) : null}
+ {subtext ? (
+
+ {subtext}
+
+ ) : null}
-
goTo(active - 1)}
- className="absolute top-1/2 right-4 z-10 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/20 text-white backdrop-blur-sm transition-colors hover:bg-white/40 sm:right-8"
- >
-
-
-
-
-
goTo(active + 1)}
- className="absolute top-1/2 left-4 z-10 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/20 text-white backdrop-blur-sm transition-colors hover:bg-white/40 sm:left-8"
- >
-
-
-
-
-
-
- {slides.map((slide, i) => (
+ {count > 1 ? (
+ <>
goTo(i)}
- className={`h-2.5 rounded-full transition-all ${
- i === active ? "w-7 bg-white" : "w-2.5 bg-white/50"
- }`}
- />
- ))}
-
+ aria-label="اسلاید قبلی"
+ onClick={() => goTo(active - 1)}
+ className="absolute top-1/2 right-4 z-10 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/20 text-white backdrop-blur-sm transition-colors hover:bg-white/40 sm:right-8"
+ >
+
+
+
+
+
goTo(active + 1)}
+ className="absolute top-1/2 left-4 z-10 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/20 text-white backdrop-blur-sm transition-colors hover:bg-white/40 sm:left-8"
+ >
+
+
+
+
+
+
+ {safeSlides.map((slide, i) => (
+ goTo(i)}
+ className={`h-2.5 rounded-full transition-all ${
+ i === active ? "w-7 bg-white" : "w-2.5 bg-white/50"
+ }`}
+ />
+ ))}
+
+ >
+ ) : null}
);
}
diff --git a/src/components/LatestProducts.tsx b/src/components/LatestProducts.tsx
deleted file mode 100644
index 13f05e6..0000000
--- a/src/components/LatestProducts.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import Link from "next/link";
-import Carousel from "./Carousel";
-import ProductCard from "./ProductCard";
-import RemoteProductCard from "./RemoteProductCard";
-import { meshkee } from "@/lib/meshkee";
-
-const fallbackProducts = [
- { image: "/images/product-1.jpg", title: "ساعت ایستاده CL001", price: "29,700,000 تومان" },
- { image: "/images/product-2.jpg", title: "میز کنسول سه درب نیم گرد نقاشی برنزخور B084", price: "49,300,000 تومان" },
- { image: "/images/product-3.jpg", title: "میز کنسول دودرب نقاشی برنزخور B003", price: "47,500,000 تومان" },
- { image: "/images/product-4.jpg", title: "میز کنسول سه درب نقاشی برنزخور B096", price: "49,500,000 تومان" },
- { image: "/images/product-5.jpg", title: "آینه کنسول تک درب نقاشی برنزخور B020", price: "43,500,000 تومان" },
- { image: "/images/product-6.jpg", title: "میز کنسول تک درب نقاشی برنزخور B075", price: "35,500,000 تومان" },
- { image: "/images/product-7.jpg", title: "میز کنسول ویترین نقاشی برنزخور B083", price: "46,900,000 تومان" },
- { image: "/images/product-8.jpg", title: "میز کنسول خمره ای وسط کشو نقاشی برنزخور B087", price: "49,700,000 تومان" },
-];
-
-export default async function LatestProducts() {
- const { items } = await meshkee.getProducts({ pageSize: 8 }).catch(() => ({ items: [] }));
-
- return (
-
-
-
-
- NEW ARRIVALS
-
-
جدیدترین پیشنهادهای دکوریا
-
-
- مشاهده همه محصولات ←
-
-
-
-
- {items.length > 0
- ? items.map((p) => )
- : fallbackProducts.map((p) => )}
-
-
- );
-}
diff --git a/src/components/ProductCard.tsx b/src/components/ProductCard.tsx
index c357b6f..f9f82e4 100644
--- a/src/components/ProductCard.tsx
+++ b/src/components/ProductCard.tsx
@@ -1,40 +1,78 @@
import Image from "next/image";
import Link from "next/link";
+export type ProductCardProps = {
+ href?: string;
+ image: string;
+ title: string;
+ price?: string | null;
+ inStock?: boolean;
+ /** `carousel` keeps fixed snap widths; `grid` fills the parent cell. */
+ layout?: "carousel" | "grid";
+};
+
export default function ProductCard({
+ href = "#",
image,
title,
price,
-}: {
- image: string;
- title: string;
- price?: string;
-}) {
+ inStock = true,
+ layout = "carousel",
+}: ProductCardProps) {
+ const widthClass =
+ layout === "grid"
+ ? "w-full"
+ : "w-[80vw] shrink-0 snap-start sm:w-[300px]";
+
return (
-
+
+ {!inStock ? (
+
+ ناموجود
+
+ ) : null}
{title}
- {price && (
+ {price ? (
{price}
+ ) : (
+
+
+
+
+ تماس بگیرید
+
)}
diff --git a/src/components/ProductCarousel.tsx b/src/components/ProductCarousel.tsx
new file mode 100644
index 0000000..77183d2
--- /dev/null
+++ b/src/components/ProductCarousel.tsx
@@ -0,0 +1,59 @@
+import Link from "next/link";
+import Carousel from "@/components/Carousel";
+import ProductCard, { type ProductCardProps } from "@/components/ProductCard";
+
+export type ProductCarouselProps = {
+ id?: string;
+ eyebrow?: string;
+ title: string;
+ href?: string;
+ linkLabel?: string;
+ products: ProductCardProps[];
+ muted?: boolean;
+};
+
+export default function ProductCarousel({
+ id,
+ eyebrow,
+ title,
+ href = "/products",
+ linkLabel = "مشاهده همه محصولات ←",
+ products,
+ muted = false,
+}: ProductCarouselProps) {
+ if (products.length === 0) return null;
+
+ return (
+
+
+
+
+ {eyebrow ? (
+
+ {eyebrow}
+
+ ) : null}
+
{title}
+
+ {href ? (
+
+ {linkLabel}
+
+ ) : null}
+
+
+
+ {products.map((product) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/components/ProductCategories.tsx b/src/components/ProductCategories.tsx
index 000c2fd..cf80495 100644
--- a/src/components/ProductCategories.tsx
+++ b/src/components/ProductCategories.tsx
@@ -1,14 +1,12 @@
-import Image from "next/image";
-import Link from "next/link";
+import { SlotMedia } from "@/components/SlotMedia";
+import type { SlotMediaItem } from "@/lib/static-images";
-const categories = [
- { title: "هنرهای دستی", subtitle: "ARTS AND CRAFTS", icon: "/images/cat-crafts.png", href: "#" },
- { title: "دکوری و تزئینی", subtitle: "DECORATION", icon: "/images/cat-decoration.png", href: "#" },
- { title: "آشپز خانه", subtitle: "KITCHEN", icon: "/images/cat-kitchen.png", href: "#" },
- { title: "محصولات چوبی", subtitle: "WOOD PRODUCTS", icon: "/images/cat-wood.png", href: "#" },
-];
+type ProductCategoriesProps = {
+ items: SlotMediaItem[];
+ aspectRatio?: string;
+};
-export default function ProductCategories() {
+export default function ProductCategories({ items, aspectRatio }: ProductCategoriesProps) {
return (
- {categories.map((cat) => (
-
-
-
-
-
-
{cat.title}
-
- {cat.subtitle}
-
-
-
- ))}
+ {items.map((cat) => {
+ const content = (
+ <>
+
+
+
+
+ {cat.title ?
{cat.title} : null}
+ {cat.titleEn ? (
+
+ {cat.titleEn}
+
+ ) : null}
+ {cat.text ?
{cat.text}
: null}
+
+ >
+ );
+
+ const className =
+ "group flex flex-col items-center gap-4 rounded-2xl bg-white/80 p-6 text-center shadow-sm backdrop-blur-sm transition-shadow hover:shadow-lg";
+
+ if (!cat.href) {
+ return (
+
+ {content}
+
+ );
+ }
+
+ if (cat.href.startsWith("/") || cat.href.startsWith("#")) {
+ return (
+
+ {content}
+
+ );
+ }
+
+ return (
+
+ {content}
+
+ );
+ })}
diff --git a/src/components/ProductsMegaMenu.tsx b/src/components/ProductsMegaMenu.tsx
new file mode 100644
index 0000000..ff349ce
--- /dev/null
+++ b/src/components/ProductsMegaMenu.tsx
@@ -0,0 +1,150 @@
+"use client";
+
+import Link from "next/link";
+import { useEffect, useId, useRef, useState } from "react";
+import { categoryHref, type CategoryNode } from "@/lib/categories";
+import { useClickOutside } from "@/lib/use-click-outside";
+
+type ProductsMegaMenuProps = {
+ categories: CategoryNode[];
+};
+
+export default function ProductsMegaMenu({ categories }: ProductsMegaMenuProps) {
+ const [open, setOpen] = useState(false);
+ const rootRef = useRef
(null);
+ const closeTimer = useRef | null>(null);
+ const menuId = useId();
+
+ useClickOutside(rootRef, () => setOpen(false));
+
+ useEffect(() => {
+ return () => {
+ if (closeTimer.current) clearTimeout(closeTimer.current);
+ };
+ }, []);
+
+ const clearClose = () => {
+ if (closeTimer.current) {
+ clearTimeout(closeTimer.current);
+ closeTimer.current = null;
+ }
+ };
+
+ const openMenu = () => {
+ clearClose();
+ setOpen(true);
+ };
+
+ const scheduleClose = () => {
+ clearClose();
+ closeTimer.current = setTimeout(() => setOpen(false), 140);
+ };
+
+ if (categories.length === 0) {
+ return (
+
+ محصولات
+
+ );
+ }
+
+ return (
+
+
+ محصولات
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/ProfileMenu.tsx b/src/components/ProfileMenu.tsx
index 413b45e..7778986 100644
--- a/src/components/ProfileMenu.tsx
+++ b/src/components/ProfileMenu.tsx
@@ -1,16 +1,63 @@
"use client";
-import { useRef, useState } from "react";
-import { useAuth } from "@/lib/auth-context";
-import { CUSTOMER_PORTAL_URL } from "@/lib/meshkee";
+import { useRef, useState, type MouseEvent } from "react";
+import { useAuth, userDisplayName } from "@/lib/auth-context";
+import { BUSINESS_DASHBOARD_URL, CUSTOMER_PORTAL_URL } from "@/lib/meshkee";
import { useClickOutside } from "@/lib/use-click-outside";
+function UserIcon() {
+ return (
+
+
+
+
+ );
+}
+
export default function ProfileMenu() {
- const { user, isLoading, isAdmin, loginUrl, logout } = useAuth();
+ const { user, isLoading, isAdmin, loginUrl, logout, getAccessToken } = useAuth();
const [open, setOpen] = useState(false);
+ const [handoffBusy, setHandoffBusy] = useState(false);
const ref = useRef(null);
useClickOutside(ref, () => setOpen(false));
+ async function openBusinessDashboard(event: MouseEvent) {
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) return;
+ event.preventDefault();
+ if (handoffBusy) return;
+
+ const popup = window.open("about:blank", "_blank");
+ setHandoffBusy(true);
+ try {
+ const token = getAccessToken();
+ const res = await fetch("/api/auth/handoff", {
+ method: "POST",
+ headers: token ? { Authorization: `Bearer ${token}` } : undefined,
+ });
+ if (!res.ok) throw new Error("handoff failed");
+ const data = (await res.json()) as { ticket?: string };
+ const url = new URL(BUSINESS_DASHBOARD_URL);
+ if (data.ticket) url.searchParams.set("handoff", data.ticket);
+ if (popup) {
+ popup.opener = null;
+ popup.location.replace(url.toString());
+ } else {
+ window.open(url.toString(), "_blank", "noopener,noreferrer");
+ }
+ } catch {
+ if (popup) popup.location.replace(BUSINESS_DASHBOARD_URL);
+ else window.open(BUSINESS_DASHBOARD_URL, "_blank", "noopener,noreferrer");
+ } finally {
+ setHandoffBusy(false);
+ setOpen(false);
+ }
+ }
+
if (isLoading) {
return ;
}
@@ -18,14 +65,12 @@ export default function ProfileMenu() {
if (!user) {
return (
-
-
-
-
- ورود
+
+ ورود
);
}
@@ -33,33 +78,51 @@ export default function ProfileMenu() {
return (
setOpen((v) => !v)}
- className="hidden items-center gap-1.5 text-sm font-medium text-neutral-700 transition-colors hover:text-primary sm:flex"
+ className="flex items-center gap-1.5 text-sm font-medium text-neutral-700 transition-colors hover:text-primary"
+ aria-expanded={open}
+ aria-haspopup="menu"
>
-
-
-
+
+ {userDisplayName(user)} عزیز
+
+
- {user.firstName} عزیز
- {open && (
-
+ {open ? (
+
- )}
+ ) : null}
);
}
diff --git a/src/components/RemoteProductCard.tsx b/src/components/RemoteProductCard.tsx
index 04c36db..82459b5 100644
--- a/src/components/RemoteProductCard.tsx
+++ b/src/components/RemoteProductCard.tsx
@@ -1,53 +1,13 @@
-import Image from "next/image";
-import Link from "next/link";
-import type { Product } from "@/lib/meshkee";
-import { slugify } from "@/lib/slug";
+import ProductCard from "@/components/ProductCard";
+import type { StoreVariant } from "@/lib/meshkee";
+import { storeVariantToCard } from "@/lib/store";
-function formatToman(value?: number | null) {
- if (value === null || value === undefined) return null;
- return `${value.toLocaleString("fa-IR")} تومان`;
-}
-
-export default function RemoteProductCard({ product }: { product: Product }) {
- const href = `/products/${product.id}/${slugify(product.nameFa || product.title)}`;
- const price = formatToman(product.store?.minPrice);
-
- return (
-
-
- {product.thumbnailUrl ? (
-
- ) : (
-
بدون تصویر
- )}
- {product.store && !product.store.inStock && (
-
- ناموجود
-
- )}
-
-
-
- {product.nameFa || product.title}
-
- {price && (
-
- {price}
-
- )}
-
-
- );
+export default function RemoteProductCard({
+ variant,
+ layout = "carousel",
+}: {
+ variant: StoreVariant;
+ layout?: "carousel" | "grid";
+}) {
+ return
;
}
diff --git a/src/components/SlotMedia.tsx b/src/components/SlotMedia.tsx
new file mode 100644
index 0000000..ccfc083
--- /dev/null
+++ b/src/components/SlotMedia.tsx
@@ -0,0 +1,73 @@
+"use client";
+
+import type { ReactNode } from "react";
+import Link from "next/link";
+import { parseAspectRatio } from "@/lib/static-images";
+
+type SlotMediaProps = {
+ src: string;
+ alt?: string;
+ href?: string;
+ fallbackSrc?: string;
+ aspectRatio?: string;
+ className?: string;
+ imgClassName?: string;
+ fill?: boolean;
+};
+
+function wrap(href: string | undefined, children: ReactNode, className?: string) {
+ if (!href) return
{children}
;
+ if (href.startsWith("/") || href.startsWith("#")) {
+ return (
+
+ {children}
+
+ );
+ }
+ return (
+
+ {children}
+
+ );
+}
+
+export function SlotMedia({
+ src,
+ alt = "",
+ href,
+ fallbackSrc,
+ aspectRatio,
+ className = "",
+ imgClassName = "absolute inset-0 h-full w-full object-cover",
+ fill = false,
+}: SlotMediaProps) {
+ const image = (
+ // eslint-disable-next-line @next/next/no-img-element
+
{
+ if (fallbackSrc && event.currentTarget.src !== fallbackSrc) {
+ event.currentTarget.src = fallbackSrc;
+ }
+ }}
+ />
+ );
+
+ if (fill) {
+ return wrap(href, image, `absolute inset-0 ${className}`.trim());
+ }
+
+ const ratio = parseAspectRatio(aspectRatio);
+ return wrap(
+ href,
+
+ {image}
+ ,
+ className,
+ );
+}
diff --git a/src/lib/auth-context.tsx b/src/lib/auth-context.tsx
index 842bab7..e58eea2 100644
--- a/src/lib/auth-context.tsx
+++ b/src/lib/auth-context.tsx
@@ -7,11 +7,21 @@ import {
useEffect,
useMemo,
useState,
+ type ReactNode,
} from "react";
-import { meshkee, type AuthUser, CUSTOMER_PORTAL_URL } from "./meshkee";
-
-const ACCESS_TOKEN_KEY = "meshkee_access_token";
-const REFRESH_TOKEN_KEY = "meshkee_refresh_token";
+import {
+ CUSTOMER_PORTAL_URL,
+ meshkee,
+ type AuthUser,
+} from "./meshkee";
+import {
+ clearSharedAuthCookies,
+ readSharedAccessToken,
+ readSharedRefreshToken,
+ SHARED_ACCESS_TOKEN_KEY,
+ SHARED_REFRESH_TOKEN_KEY,
+ writeSharedAuthCookies,
+} from "./shared-auth-cookie";
type AuthContextValue = {
user: AuthUser | null;
@@ -19,81 +29,175 @@ type AuthContextValue = {
isLoading: boolean;
isAdmin: boolean;
loginUrl: string;
+ businessId: string;
setSession: (accessToken: string, refreshToken: string) => Promise
;
logout: () => void;
+ getAccessToken: () => string | null;
};
const AuthContext = createContext(null);
-function getLoginUrl() {
- if (typeof window === "undefined") return CUSTOMER_PORTAL_URL;
- const returnUrl = `${window.location.origin}/auth/callback?redirect=${encodeURIComponent(
- window.location.pathname
- )}`;
- return `${CUSTOMER_PORTAL_URL}/login?returnUrl=${encodeURIComponent(returnUrl)}`;
+function readLegacyToken(key: string) {
+ try {
+ return localStorage.getItem(key);
+ } catch {
+ return null;
+ }
}
-export function AuthProvider({ children }: { children: React.ReactNode }) {
+function writeLegacyToken(key: string, value: string | null) {
+ try {
+ if (value) localStorage.setItem(key, value);
+ else localStorage.removeItem(key);
+ } catch {
+ // ignore
+ }
+}
+
+function normalizeUser(raw: Partial & { id?: string | number } | null | undefined): AuthUser | null {
+ if (!raw || raw.id == null) return null;
+ return {
+ id: String(raw.id),
+ firstName: raw.firstName ?? "",
+ lastName: raw.lastName ?? "",
+ cellNumber: raw.cellNumber ?? "",
+ email: raw.email ?? null,
+ role: raw.role,
+ roles: Array.isArray(raw.roles) ? raw.roles : undefined,
+ isAdmin: Boolean(raw.isAdmin),
+ isSuperAdmin: Boolean(raw.isSuperAdmin),
+ businesses: Array.isArray(raw.businesses)
+ ? raw.businesses.map((business) => ({
+ id: String(business.id),
+ name: business.name,
+ slug: business.slug,
+ isOwner: Boolean(business.isOwner),
+ teamRole: business.teamRole ?? null,
+ }))
+ : undefined,
+ };
+}
+
+export function isWebsiteAdminForBusiness(user: AuthUser | null | undefined, businessId: string) {
+ if (!user || !businessId) return false;
+ if (user.isSuperAdmin || user.isAdmin || user.role === "admin" || user.role === "owner") return true;
+ if (user.roles?.includes("super_admin")) return true;
+ return Boolean(
+ user.businesses?.some(
+ (business) =>
+ String(business.id) === String(businessId) && (business.isOwner || Boolean(business.teamRole)),
+ ),
+ );
+}
+
+export function userDisplayName(user: AuthUser | null | undefined) {
+ if (!user) return "کاربر";
+ const full = [user.firstName, user.lastName].filter(Boolean).join(" ").trim();
+ return full || user.cellNumber || user.email || "کاربر";
+}
+
+function getLoginUrl() {
+ if (typeof window === "undefined") return `${CUSTOMER_PORTAL_URL}/login`;
+ const redirect = window.location.pathname || "/";
+ return `${CUSTOMER_PORTAL_URL}/login?redirect=${encodeURIComponent(redirect)}`;
+}
+
+export function AuthProvider({
+ businessId,
+ children,
+}: {
+ businessId: string;
+ children: ReactNode;
+}) {
const [user, setUser] = useState(null);
const [accessToken, setAccessToken] = useState(null);
const [isLoading, setIsLoading] = useState(true);
- const loadUser = useCallback(async (token: string) => {
- try {
- const { user } = await meshkee.me(token);
- setUser(user);
- setAccessToken(token);
- } catch {
- const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
- if (refreshToken) {
- try {
- const res = await meshkee.refresh(refreshToken);
- localStorage.setItem(ACCESS_TOKEN_KEY, res.accessToken);
- localStorage.setItem(REFRESH_TOKEN_KEY, res.refreshToken);
- setUser(res.user);
- setAccessToken(res.accessToken);
- return;
- } catch {
- // fall through to logout
- }
- }
- localStorage.removeItem(ACCESS_TOKEN_KEY);
- localStorage.removeItem(REFRESH_TOKEN_KEY);
- setUser(null);
- setAccessToken(null);
- }
+ const getAccessToken = useCallback(() => {
+ return (
+ readSharedAccessToken() ||
+ readLegacyToken(SHARED_ACCESS_TOKEN_KEY) ||
+ readLegacyToken("meshkee_access_token")
+ );
}, []);
- useEffect(() => {
- const token = localStorage.getItem(ACCESS_TOKEN_KEY);
- if (token) {
- // eslint-disable-next-line react-hooks/set-state-in-effect -- one-time session bootstrap on mount
- loadUser(token).finally(() => setIsLoading(false));
- } else {
- setIsLoading(false);
- }
- }, [loadUser]);
-
- const setSession = useCallback(
- async (newAccessToken: string, newRefreshToken: string) => {
- localStorage.setItem(ACCESS_TOKEN_KEY, newAccessToken);
- localStorage.setItem(REFRESH_TOKEN_KEY, newRefreshToken);
- setIsLoading(true);
- await loadUser(newAccessToken);
- setIsLoading(false);
- },
- [loadUser]
- );
-
- const logout = useCallback(() => {
- localStorage.removeItem(ACCESS_TOKEN_KEY);
- localStorage.removeItem(REFRESH_TOKEN_KEY);
+ const clearSession = useCallback(() => {
+ writeLegacyToken(SHARED_ACCESS_TOKEN_KEY, null);
+ writeLegacyToken(SHARED_REFRESH_TOKEN_KEY, null);
+ writeLegacyToken("meshkee_access_token", null);
+ writeLegacyToken("meshkee_refresh_token", null);
+ clearSharedAuthCookies();
setUser(null);
setAccessToken(null);
}, []);
- const isAdmin =
- !!user && (user.role === "admin" || user.role === "owner" || user.isAdmin === true);
+ const loadUser = useCallback(
+ async (token: string) => {
+ try {
+ const data = await meshkee.me(token);
+ const next = normalizeUser(data.user ?? (data as unknown as AuthUser));
+ if (!next) throw new Error("invalid user");
+ setUser(next);
+ setAccessToken(token);
+ writeLegacyToken(SHARED_ACCESS_TOKEN_KEY, token);
+ writeSharedAuthCookies(token, readSharedRefreshToken() ?? undefined);
+ } catch {
+ const refreshToken =
+ readSharedRefreshToken() ||
+ readLegacyToken(SHARED_REFRESH_TOKEN_KEY) ||
+ readLegacyToken("meshkee_refresh_token");
+ if (refreshToken) {
+ try {
+ const res = await meshkee.refresh(refreshToken);
+ writeLegacyToken(SHARED_ACCESS_TOKEN_KEY, res.accessToken);
+ writeLegacyToken(SHARED_REFRESH_TOKEN_KEY, res.refreshToken);
+ writeSharedAuthCookies(res.accessToken, res.refreshToken);
+ const next = normalizeUser(res.user);
+ setUser(next);
+ setAccessToken(res.accessToken);
+ return;
+ } catch {
+ // fall through
+ }
+ }
+ clearSession();
+ }
+ },
+ [clearSession],
+ );
+
+ useEffect(() => {
+ let cancelled = false;
+ const token = getAccessToken();
+ if (!token) {
+ setIsLoading(false);
+ return;
+ }
+ loadUser(token).finally(() => {
+ if (!cancelled) setIsLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [getAccessToken, loadUser]);
+
+ const setSession = useCallback(
+ async (newAccessToken: string, newRefreshToken: string) => {
+ writeLegacyToken(SHARED_ACCESS_TOKEN_KEY, newAccessToken);
+ writeLegacyToken(SHARED_REFRESH_TOKEN_KEY, newRefreshToken);
+ writeSharedAuthCookies(newAccessToken, newRefreshToken);
+ setIsLoading(true);
+ await loadUser(newAccessToken);
+ setIsLoading(false);
+ },
+ [loadUser],
+ );
+
+ const logout = useCallback(() => {
+ clearSession();
+ }, [clearSession]);
+
+ const isAdmin = useMemo(() => isWebsiteAdminForBusiness(user, businessId), [user, businessId]);
const value = useMemo(
() => ({
@@ -102,10 +206,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
isLoading,
isAdmin,
loginUrl: getLoginUrl(),
+ businessId,
setSession,
logout,
+ getAccessToken,
}),
- [user, accessToken, isLoading, isAdmin, setSession, logout]
+ [user, accessToken, isLoading, isAdmin, businessId, setSession, logout, getAccessToken],
);
return {children} ;
diff --git a/src/lib/cart-context.tsx b/src/lib/cart-context.tsx
index 3d9b764..1b6f173 100644
--- a/src/lib/cart-context.tsx
+++ b/src/lib/cart-context.tsx
@@ -7,92 +7,117 @@ import {
useEffect,
useMemo,
useState,
+ type ReactNode,
} from "react";
-import { meshkee, type Cart } from "./meshkee";
-import { useAuth } from "./auth-context";
+import {
+ getContinueCheckoutUrl,
+ readGuestCart,
+ writeGuestCart,
+ type GuestCartItem,
+} from "./guest-cart";
type CartContextValue = {
- cart: Cart | null;
+ items: GuestCartItem[];
itemCount: number;
+ total: number;
isLoading: boolean;
- refresh: () => Promise;
- addItem: (storeItemVariantId: string, quantity?: number) => Promise;
- updateItem: (itemId: string, quantity: number) => Promise;
- removeItem: (itemId: string) => Promise;
+ addItem: (item: Omit & { quantity?: number }) => void;
+ updateItem: (id: string, quantity: number) => void;
+ removeItem: (id: string) => void;
+ clear: () => void;
+ continueUrl: string;
};
const CartContext = createContext(null);
-export function CartProvider({
- businessId,
- children,
-}: {
- businessId: string;
- children: React.ReactNode;
-}) {
- const { accessToken } = useAuth();
- const [cart, setCart] = useState(null);
- const [isLoading, setIsLoading] = useState(false);
-
- const refresh = useCallback(async () => {
- if (!accessToken) {
- setCart(null);
- return;
- }
- setIsLoading(true);
- try {
- const { cart } = await meshkee.getCart(businessId, accessToken);
- setCart(cart);
- } catch {
- setCart(null);
- } finally {
- setIsLoading(false);
- }
- }, [accessToken, businessId]);
+export function CartProvider({ children }: { children: ReactNode }) {
+ const [items, setItems] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
- // eslint-disable-next-line react-hooks/set-state-in-effect -- refetch cart when auth state changes
- refresh();
- }, [refresh]);
+ setItems(readGuestCart());
+ setIsLoading(false);
+ }, []);
+
+ const persist = useCallback((next: GuestCartItem[]) => {
+ setItems(next);
+ writeGuestCart(next);
+ }, []);
const addItem = useCallback(
- async (storeItemVariantId: string, quantity = 1) => {
- if (!accessToken) return;
- const { cart } = await meshkee.addCartItem(businessId, accessToken, {
- storeItemVariantId,
- quantity,
+ (item: Omit & { quantity?: number }) => {
+ const quantity = Math.max(1, item.quantity ?? 1);
+ setItems((prev) => {
+ const existing = prev.find((line) => line.id === item.id);
+ const next = existing
+ ? prev.map((line) =>
+ line.id === item.id ? { ...line, quantity: line.quantity + quantity } : line,
+ )
+ : [
+ ...prev,
+ {
+ id: item.id,
+ name: item.name,
+ slug: item.slug,
+ price: item.price,
+ originalPrice: item.originalPrice,
+ image: item.image,
+ quantity,
+ },
+ ];
+ writeGuestCart(next);
+ return next;
});
- setCart(cart);
},
- [accessToken, businessId]
+ [],
);
- const updateItem = useCallback(
- async (itemId: string, quantity: number) => {
- if (!accessToken) return;
- const { cart } = await meshkee.updateCartItem(businessId, accessToken, itemId, quantity);
- setCart(cart);
- },
- [accessToken, businessId]
- );
+ const updateItem = useCallback((id: string, quantity: number) => {
+ setItems((prev) => {
+ const next =
+ quantity <= 0
+ ? prev.filter((line) => line.id !== id)
+ : prev.map((line) => (line.id === id ? { ...line, quantity } : line));
+ writeGuestCart(next);
+ return next;
+ });
+ }, []);
- const removeItem = useCallback(
- async (itemId: string) => {
- if (!accessToken) return;
- const { cart } = await meshkee.removeCartItem(businessId, accessToken, itemId);
- setCart(cart);
- },
- [accessToken, businessId]
- );
+ const removeItem = useCallback((id: string) => {
+ setItems((prev) => {
+ const next = prev.filter((line) => line.id !== id);
+ writeGuestCart(next);
+ return next;
+ });
+ }, []);
+
+ const clear = useCallback(() => persist([]), [persist]);
const itemCount = useMemo(
- () => cart?.items?.reduce((sum, item) => sum + item.quantity, 0) ?? 0,
- [cart]
+ () => items.reduce((sum, item) => sum + item.quantity, 0),
+ [items],
);
+ const total = useMemo(
+ () => items.reduce((sum, item) => sum + item.price * item.quantity, 0),
+ [items],
+ );
+
+ const continueUrl = useMemo(() => getContinueCheckoutUrl(items), [items]);
+
const value = useMemo(
- () => ({ cart, itemCount, isLoading, refresh, addItem, updateItem, removeItem }),
- [cart, itemCount, isLoading, refresh, addItem, updateItem, removeItem]
+ () => ({
+ items,
+ itemCount,
+ total,
+ isLoading,
+ addItem,
+ updateItem,
+ removeItem,
+ clear,
+ continueUrl,
+ }),
+ [items, itemCount, total, isLoading, addItem, updateItem, removeItem, clear, continueUrl],
);
return {children} ;
diff --git a/src/lib/categories.ts b/src/lib/categories.ts
new file mode 100644
index 0000000..b9eef00
--- /dev/null
+++ b/src/lib/categories.ts
@@ -0,0 +1,31 @@
+import type { Category } from "@/lib/meshkee";
+import { slugify } from "@/lib/slug";
+
+export type CategoryNode = Category & {
+ children: Category[];
+};
+
+export function categoryHref(category: Pick) {
+ const slug = category.slug || slugify(category.nameFa || category.name);
+ return `/products/category/${category.id}/${slug}`;
+}
+
+export function buildCategoryTree(categories: Category[]): CategoryNode[] {
+ const active = categories.filter((category) => category.isActive !== false);
+ const childrenByParent = new Map();
+
+ for (const category of active) {
+ if (!category.parentId) continue;
+ const key = String(category.parentId);
+ const list = childrenByParent.get(key) ?? [];
+ list.push(category);
+ childrenByParent.set(key, list);
+ }
+
+ return active
+ .filter((category) => !category.parentId)
+ .map((category) => ({
+ ...category,
+ children: childrenByParent.get(String(category.id)) ?? [],
+ }));
+}
diff --git a/src/lib/guest-cart.ts b/src/lib/guest-cart.ts
new file mode 100644
index 0000000..901e4e7
--- /dev/null
+++ b/src/lib/guest-cart.ts
@@ -0,0 +1,90 @@
+import { WEBSITE_DOMAIN } from "@/lib/meshkee";
+import { readSharedAccessToken } from "@/lib/shared-auth-cookie";
+
+export const GUEST_CART_KEY = "meshkee-guest-cart";
+
+export type GuestCartItem = {
+ id: string;
+ name: string;
+ slug: string;
+ price: number;
+ originalPrice?: number;
+ image?: string;
+ quantity: number;
+};
+
+function cookieDomain(): string | undefined {
+ if (typeof window === "undefined") return undefined;
+ const host = window.location.hostname.toLowerCase();
+ if (host === "localhost" || host === "127.0.0.1") return undefined;
+ const parts = host.split(".").filter(Boolean);
+ if (parts.length < 2) return undefined;
+ return `.${parts.slice(-2).join(".")}`;
+}
+
+function writeGuestCartCookie(items: GuestCartItem[]) {
+ if (typeof document === "undefined") return;
+ const domain = cookieDomain();
+ const encoded = encodeURIComponent(JSON.stringify(items));
+ if (encoded.length > 3500) {
+ let clear = `${GUEST_CART_KEY}=; path=/; max-age=0; SameSite=Lax`;
+ if (domain) clear += `; domain=${domain}`;
+ document.cookie = clear;
+ return;
+ }
+ const maxAge = 60 * 60 * 24 * 30;
+ let cookie = `${GUEST_CART_KEY}=${encoded}; path=/; max-age=${maxAge}; SameSite=Lax`;
+ if (domain) cookie += `; domain=${domain}`;
+ if (window.location.protocol === "https:") cookie += "; Secure";
+ document.cookie = cookie;
+}
+
+export function readGuestCart(): GuestCartItem[] {
+ if (typeof window === "undefined") return [];
+ try {
+ const raw = localStorage.getItem(GUEST_CART_KEY);
+ if (!raw) return [];
+ const parsed = JSON.parse(raw);
+ if (!Array.isArray(parsed)) return [];
+ return parsed
+ .map((item) => ({
+ id: String(item.id),
+ name: String(item.name || ""),
+ slug: String(item.slug || ""),
+ price: Number(item.price) || 0,
+ originalPrice:
+ item.originalPrice === undefined || item.originalPrice === null
+ ? undefined
+ : Number(item.originalPrice),
+ image: item.image ? String(item.image) : undefined,
+ quantity: Math.max(1, Number(item.quantity) || 1),
+ }))
+ .filter((item) => item.id);
+ } catch {
+ return [];
+ }
+}
+
+export function writeGuestCart(items: GuestCartItem[]) {
+ if (typeof window === "undefined") return;
+ localStorage.setItem(GUEST_CART_KEY, JSON.stringify(items));
+ writeGuestCartCookie(items);
+}
+
+export function encodeGuestCart(items: GuestCartItem[]) {
+ const json = JSON.stringify(items);
+ return btoa(unescape(encodeURIComponent(json)))
+ .replace(/\+/g, "-")
+ .replace(/\//g, "_")
+ .replace(/=+$/, "");
+}
+
+export function getContinueCheckoutUrl(items: GuestCartItem[]) {
+ const encoded = encodeGuestCart(items);
+ const checkoutPath = `/checkout/cart?guestCart=${encoded}`;
+ const loggedIn = Boolean(readSharedAccessToken());
+ if (loggedIn) {
+ return `https://customer.${WEBSITE_DOMAIN}${checkoutPath}`;
+ }
+ return `https://customer.${WEBSITE_DOMAIN}/login?redirect=${encodeURIComponent(checkoutPath)}`;
+}
diff --git a/src/lib/meshkee-server.ts b/src/lib/meshkee-server.ts
new file mode 100644
index 0000000..1168ae3
--- /dev/null
+++ b/src/lib/meshkee-server.ts
@@ -0,0 +1,38 @@
+import { API_BASE } from "@/lib/meshkee";
+
+export function getBearerToken(request: Request) {
+ const header = request.headers.get("authorization") || request.headers.get("Authorization");
+ if (!header) return null;
+ const match = /^Bearer\s+(.+)$/i.exec(header.trim());
+ return match?.[1]?.trim() || null;
+}
+
+export function jsonResponse(data: unknown, status = 200) {
+ return Response.json(data, { status });
+}
+
+export function unauthorizedResponse() {
+ return jsonResponse({ error: "Unauthorized" }, 401);
+}
+
+export async function meshkeeFetch(
+ path: string,
+ init: RequestInit & { token?: string } = {},
+) {
+ const { token, ...rest } = init;
+ const headers = new Headers(rest.headers);
+ if (!headers.has("Content-Type") && rest.body) {
+ headers.set("Content-Type", "application/json");
+ }
+ if (token) headers.set("Authorization", `Bearer ${token}`);
+
+ const res = await fetch(`${API_BASE}${path}`, {
+ ...rest,
+ headers,
+ cache: "no-store",
+ });
+
+ const isJson = res.headers.get("content-type")?.includes("application/json");
+ const data = isJson ? await res.json().catch(() => null) : null;
+ return { ok: res.ok, status: res.status, data };
+}
diff --git a/src/lib/meshkee.ts b/src/lib/meshkee.ts
index dc4dff3..ffcaf99 100644
--- a/src/lib/meshkee.ts
+++ b/src/lib/meshkee.ts
@@ -1,6 +1,8 @@
export const API_BASE = "https://api.meshkee.com/api/v1";
export const WEBSITE_DOMAIN = "dcorya.com";
export const CUSTOMER_PORTAL_URL = `https://customer.${WEBSITE_DOMAIN}`;
+export const BUSINESS_DASHBOARD_URL = `https://business.${WEBSITE_DOMAIN}`;
+export const DEFAULT_FAVICON = "/images/logo.png";
export type Tenant = {
id: string;
@@ -148,18 +150,66 @@ export type Comment = {
createdAt: string;
};
-export type StoreItemVariant = {
+export type StoreVariant = {
id: string;
- price: number;
- stockQuantity: number | null;
+ storeItemId?: string;
+ productId?: string | null;
+ productTitle?: string | null;
+ productNameFa?: string | null;
+ productImage?: string | null;
+ productTotalStock?: number | null;
+ label?: string | null;
+ price?: number | null;
+ discountedPrice?: number | null;
+ stockQuantity?: number | null;
unlimited?: boolean;
+ isFestival?: boolean;
+ isUsed?: boolean;
+ sortOrder?: number | null;
+ selections?: { variationId?: string; variationName?: string; optionId?: string; value?: string }[];
attributes?: Record;
};
+/** @deprecated Prefer StoreVariant — kept for older call sites */
+export type StoreItemVariant = StoreVariant;
+
export type StoreItem = {
id: string;
productId: string;
- variants: StoreItemVariant[];
+ isActive?: boolean;
+ variants: StoreVariant[];
+};
+
+export type StoreItemListResponse = {
+ items: StoreVariant[];
+ total: number;
+ page: number;
+ pageSize: number;
+};
+
+export type StoreSpecialItem = {
+ id: string;
+ productId?: string | null;
+ productTitle?: string | null;
+ productNameFa?: string | null;
+ productImage?: string | null;
+ isActive?: boolean;
+ sortOrder?: number | null;
+ variants?: StoreVariant[];
+};
+
+export type StoreSpecial = {
+ id: string;
+ key: string;
+ title: string;
+ sortOrder?: number;
+ isActive?: boolean;
+ items: StoreSpecialItem[];
+};
+
+export type StoreSpecialsResponse = {
+ source: "product" | "store_item" | null;
+ items: StoreSpecial[];
};
export type CartItem = {
@@ -177,6 +227,14 @@ export type Cart = {
total?: number;
};
+export type AuthBusinessMembership = {
+ id: string;
+ name: string;
+ slug: string;
+ isOwner: boolean;
+ teamRole: string | null;
+};
+
export type AuthUser = {
id: string;
firstName: string;
@@ -184,7 +242,10 @@ export type AuthUser = {
cellNumber: string;
email?: string | null;
role?: string;
+ roles?: string[];
isAdmin?: boolean;
+ isSuperAdmin?: boolean;
+ businesses?: AuthBusinessMembership[];
};
export type AuthResponse = {
@@ -281,6 +342,30 @@ export const meshkee = {
{ next: { revalidate: 30 } } as RequestInit
),
+ getStoreItems: (params: {
+ page?: number;
+ pageSize?: number;
+ categoryId?: string;
+ productId?: string;
+ name?: string;
+ inStock?: boolean;
+ } = {}) =>
+ request(
+ `/tenants/${WEBSITE_DOMAIN}/store-items${qs(params)}`,
+ { next: { revalidate: 60 } } as RequestInit
+ ),
+
+ getStoreVariantById: (variantId: string) =>
+ request<{ variant: StoreVariant }>(
+ `/tenants/${WEBSITE_DOMAIN}/store-items/${variantId}`,
+ { next: { revalidate: 30 } } as RequestInit
+ ),
+
+ getStoreSpecials: () =>
+ request(`/tenants/${WEBSITE_DOMAIN}/store-specials`, {
+ next: { revalidate: 60 },
+ } as RequestInit),
+
getCategories: (entityType: "product" = "product") =>
request<{ items: Category[] }>(
`/tenants/${WEBSITE_DOMAIN}/categories${qs({ entityType })}`,
@@ -359,33 +444,6 @@ export const meshkee = {
method: "POST",
body: JSON.stringify({ refreshToken }),
}),
-
- // --- Cart (requires businessId + bearer token) ---
- getCart: (businessId: string, token: string) =>
- request<{ cart: Cart }>(`/businesses/${businessId}/cart`, { token, cache: "no-store" }),
-
- addCartItem: (
- businessId: string,
- token: string,
- body: { storeItemVariantId: string; quantity?: number }
- ) =>
- request<{ message: string; cart: Cart }>(`/businesses/${businessId}/cart/items`, {
- method: "POST",
- token,
- body: JSON.stringify(body),
- }),
-
- updateCartItem: (businessId: string, token: string, itemId: string, quantity: number) =>
- request<{ message: string; cart: Cart }>(
- `/businesses/${businessId}/cart/items/${itemId}`,
- { method: "PATCH", token, body: JSON.stringify({ quantity }) }
- ),
-
- removeCartItem: (businessId: string, token: string, itemId: string) =>
- request<{ message: string; cart: Cart }>(
- `/businesses/${businessId}/cart/items/${itemId}`,
- { method: "DELETE", token }
- ),
};
export { ApiError };
diff --git a/src/lib/providers.tsx b/src/lib/providers.tsx
index 6fe7914..b560a79 100644
--- a/src/lib/providers.tsx
+++ b/src/lib/providers.tsx
@@ -12,9 +12,9 @@ export default function Providers({
children: React.ReactNode;
}) {
return (
-
+
- {children}
+ {children}
);
diff --git a/src/lib/shared-auth-cookie.ts b/src/lib/shared-auth-cookie.ts
new file mode 100644
index 0000000..0eb8d8c
--- /dev/null
+++ b/src/lib/shared-auth-cookie.ts
@@ -0,0 +1,110 @@
+/** Shared auth cookies so storefront + customer.{domain} share one login. */
+
+export const SHARED_ACCESS_TOKEN_KEY = "meshkee_customer_access_token";
+export const SHARED_REFRESH_TOKEN_KEY = "meshkee_customer_refresh_token";
+
+const CHUNK_SIZE = 2800;
+const MAX_CHUNKS = 24;
+
+function cookieDomain(): string | undefined {
+ if (typeof window === "undefined") return undefined;
+ const host = window.location.hostname.toLowerCase();
+ if (host === "localhost" || host === "127.0.0.1") return undefined;
+ const parts = host.split(".").filter(Boolean);
+ if (parts.length < 2) return undefined;
+ return `.${parts.slice(-2).join(".")}`;
+}
+
+function readRawCookie(name: string): string | null {
+ if (typeof document === "undefined") return null;
+ const prefix = `${name}=`;
+ const match = document.cookie.split("; ").find((row) => row.startsWith(prefix));
+ if (!match) return null;
+ return match.slice(prefix.length);
+}
+
+function writeRawCookie(name: string, value: string | null) {
+ if (typeof document === "undefined") return;
+ const domain = cookieDomain();
+ if (value == null) {
+ let cookie = `${name}=; path=/; max-age=0; SameSite=Lax`;
+ if (domain) cookie += `; domain=${domain}`;
+ document.cookie = cookie;
+ return;
+ }
+ const maxAge = 60 * 60 * 24 * 30;
+ let cookie = `${name}=${value}; path=/; max-age=${maxAge}; SameSite=Lax`;
+ if (domain) cookie += `; domain=${domain}`;
+ if (window.location.protocol === "https:") cookie += "; Secure";
+ document.cookie = cookie;
+}
+
+function clearChunkedCookie(name: string) {
+ writeRawCookie(name, null);
+ const n = Number(readRawCookie(`${name}_n`) || "0");
+ writeRawCookie(`${name}_n`, null);
+ for (let i = 0; i < Math.max(n, MAX_CHUNKS); i++) {
+ writeRawCookie(`${name}_${i}`, null);
+ }
+}
+
+function writeCookie(name: string, value: string | null) {
+ clearChunkedCookie(name);
+ if (value == null || value === "") return;
+
+ const encoded = encodeURIComponent(value);
+ if (encoded.length <= 3500) {
+ writeRawCookie(name, encoded);
+ return;
+ }
+
+ const chunks: string[] = [];
+ for (let i = 0; i < encoded.length; i += CHUNK_SIZE) {
+ chunks.push(encoded.slice(i, i + CHUNK_SIZE));
+ }
+ writeRawCookie(`${name}_n`, String(chunks.length));
+ chunks.forEach((chunk, i) => writeRawCookie(`${name}_${i}`, chunk));
+}
+
+function readCookie(name: string): string | null {
+ const n = Number(readRawCookie(`${name}_n`) || "0");
+ if (n > 0) {
+ let encoded = "";
+ for (let i = 0; i < n; i++) {
+ const part = readRawCookie(`${name}_${i}`);
+ if (part == null) return null;
+ encoded += part;
+ }
+ try {
+ return decodeURIComponent(encoded);
+ } catch {
+ return null;
+ }
+ }
+
+ const raw = readRawCookie(name);
+ if (raw == null) return null;
+ try {
+ return decodeURIComponent(raw);
+ } catch {
+ return null;
+ }
+}
+
+export function writeSharedAuthCookies(accessToken: string, refreshToken?: string) {
+ writeCookie(SHARED_ACCESS_TOKEN_KEY, accessToken);
+ if (refreshToken) writeCookie(SHARED_REFRESH_TOKEN_KEY, refreshToken);
+}
+
+export function clearSharedAuthCookies() {
+ writeCookie(SHARED_ACCESS_TOKEN_KEY, null);
+ writeCookie(SHARED_REFRESH_TOKEN_KEY, null);
+}
+
+export function readSharedAccessToken(): string | null {
+ return readCookie(SHARED_ACCESS_TOKEN_KEY);
+}
+
+export function readSharedRefreshToken(): string | null {
+ return readCookie(SHARED_REFRESH_TOKEN_KEY);
+}
diff --git a/src/lib/specials.ts b/src/lib/specials.ts
new file mode 100644
index 0000000..a7c5ed5
--- /dev/null
+++ b/src/lib/specials.ts
@@ -0,0 +1,124 @@
+import type { ProductCardProps } from "@/components/ProductCard";
+import { meshkee, type StoreSpecial, type StoreSpecialItem, type StoreVariant } from "@/lib/meshkee";
+import { storeVariantToCard } from "@/lib/store";
+import { slugify } from "@/lib/slug";
+
+export const HOMEPAGE_SPECIAL_KEYS = [
+ "new-collection",
+ "special-sale",
+ "our-handmades",
+] as const;
+
+export type HomepageSpecialKey = (typeof HOMEPAGE_SPECIAL_KEYS)[number];
+
+export const SPECIAL_META: Record<
+ HomepageSpecialKey,
+ { eyebrow: string; fallbackTitle: string; muted?: boolean }
+> = {
+ "new-collection": {
+ eyebrow: "NEW ARRIVALS",
+ fallbackTitle: "جدیدترین پیشنهادهای دکوریا",
+ },
+ "special-sale": {
+ eyebrow: "SPECIAL SALE",
+ fallbackTitle: "فروش ویژه",
+ },
+ "our-handmades": {
+ eyebrow: "HANDCRAFTED",
+ fallbackTitle: "صنایع دستی و تزئینی دکوریا",
+ muted: true,
+ },
+};
+
+const FALLBACK_PRODUCTS: Record = {
+ "new-collection": [
+ { image: "/images/product-1.jpg", title: "ساعت ایستاده CL001", price: "۲۹٬۷۰۰٬۰۰۰ تومان", href: "/products" },
+ { image: "/images/product-2.jpg", title: "میز کنسول سه درب نیم گرد نقاشی برنزخور B084", price: "۴۹٬۳۰۰٬۰۰۰ تومان", href: "/products" },
+ { image: "/images/product-3.jpg", title: "میز کنسول دودرب نقاشی برنزخور B003", price: "۴۷٬۵۰۰٬۰۰۰ تومان", href: "/products" },
+ { image: "/images/product-4.jpg", title: "میز کنسول سه درب نقاشی برنزخور B096", price: "۴۹٬۵۰۰٬۰۰۰ تومان", href: "/products" },
+ { image: "/images/product-5.jpg", title: "آینه کنسول تک درب نقاشی برنزخور B020", price: "۴۳٬۵۰۰٬۰۰۰ تومان", href: "/products" },
+ { image: "/images/product-6.jpg", title: "میز کنسول تک درب نقاشی برنزخور B075", price: "۳۵٬۵۰۰٬۰۰۰ تومان", href: "/products" },
+ { image: "/images/product-7.jpg", title: "میز کنسول ویترین نقاشی برنزخور B083", price: "۴۶٬۹۰۰٬۰۰۰ تومان", href: "/products" },
+ { image: "/images/product-8.jpg", title: "میز کنسول خمره ای وسط کشو نقاشی برنزخور B087", price: "۴۹٬۷۰۰٬۰۰۰ تومان", href: "/products" },
+ ],
+ "special-sale": [
+ { image: "/images/product-3.jpg", title: "میز کنسول دودرب نقاشی برنزخور B003", href: "/products" },
+ { image: "/images/product-5.jpg", title: "آینه کنسول تک درب نقاشی برنزخور B020", href: "/products" },
+ { image: "/images/product-1.jpg", title: "ساعت ایستاده CL001", href: "/products" },
+ { image: "/images/product-7.jpg", title: "میز کنسول ویترین نقاشی برنزخور B083", href: "/products" },
+ ],
+ "our-handmades": [
+ { image: "/images/decor-1.jpg", title: "ساعت و شمعدان دو پسر برنز طلایی", href: "/products" },
+ { image: "/images/decor-2.jpg", title: "ساعت و شمعدان زن و مرد برنز طلایی آنتیک", href: "/products" },
+ { image: "/images/decor-3.jpg", title: "ساعت و شمعدان زن و مرد برنز طلایی", href: "/products" },
+ { image: "/images/decor-4.jpg", title: "ساعت و شمعدان ریسه ای برنز آنتیک", href: "/products" },
+ { image: "/images/decor-5.jpg", title: "ست ساعت و شمعدان پرنسس ۰۱۱", href: "/products" },
+ { image: "/images/decor-6.jpg", title: "ست شمعدان و ساعت سنگ و برنز ۰۱۰", href: "/products" },
+ ],
+};
+
+export function specialItemToCard(item: StoreSpecialItem): ProductCardProps | null {
+ const variant = item.variants?.[0];
+ if (variant) {
+ return storeVariantToCard({
+ ...variant,
+ productId: item.productId ?? variant.productId,
+ productTitle: item.productTitle ?? variant.productTitle,
+ productNameFa: item.productNameFa ?? variant.productNameFa,
+ productImage: item.productImage ?? variant.productImage,
+ });
+ }
+
+ const title = item.productNameFa?.trim() || item.productTitle?.trim();
+ const image = item.productImage?.trim();
+ if (!title || !image) return null;
+ const productId = item.productId ? String(item.productId) : null;
+ return {
+ href: productId ? `/products/${productId}/${slugify(title)}` : "/products",
+ image,
+ title,
+ price: null,
+ inStock: false,
+ };
+}
+
+export async function fetchStoreSpecials(): Promise {
+ try {
+ const data = await meshkee.getStoreSpecials();
+ return (data.items ?? []).filter((item) => item.isActive !== false);
+ } catch {
+ return [];
+ }
+}
+
+export function pickSpecial(specials: StoreSpecial[], key: string) {
+ return specials.find((item) => item.key === key);
+}
+
+export async function resolveSpecialCarousel(key: HomepageSpecialKey, specials: StoreSpecial[]) {
+ const meta = SPECIAL_META[key];
+ const special = pickSpecial(specials, key);
+ const fromApi = (special?.items ?? [])
+ .map(specialItemToCard)
+ .filter((item): item is ProductCardProps => Boolean(item));
+
+ let products = fromApi;
+
+ if (products.length === 0 && key === "new-collection") {
+ const { items } = await meshkee.getStoreItems({ pageSize: 8 }).catch(() => ({ items: [] as StoreVariant[] }));
+ products = items.map(storeVariantToCard);
+ }
+
+ if (products.length === 0) {
+ products = FALLBACK_PRODUCTS[key];
+ }
+
+ return {
+ key,
+ id: key,
+ eyebrow: meta.eyebrow,
+ title: special?.title?.trim() || meta.fallbackTitle,
+ muted: meta.muted,
+ products,
+ };
+}
diff --git a/src/lib/static-images.ts b/src/lib/static-images.ts
new file mode 100644
index 0000000..d531e69
--- /dev/null
+++ b/src/lib/static-images.ts
@@ -0,0 +1,200 @@
+import { API_BASE, WEBSITE_DOMAIN } from "@/lib/meshkee";
+
+export type SlotKind = "single" | "list";
+
+export type SlotImage = {
+ id: string;
+ url: string;
+ titleFa?: string | null;
+ titleEn?: string | null;
+ subtext?: string | null;
+ linkUrl?: string | null;
+ width?: number | null;
+ height?: number | null;
+ sortOrder?: number | null;
+};
+
+export type StaticImageSlotCatalogItem = {
+ key: string;
+ label: string;
+ kind: SlotKind;
+ pageKey: string;
+ aspectRatio: string;
+ itemCount: number | null;
+ recommendedWidth: number;
+};
+
+export type StaticImageSlot = StaticImageSlotCatalogItem & {
+ images: SlotImage[];
+};
+
+export type SlotMediaItem = {
+ id: string;
+ image: string;
+ href?: string;
+ title?: string;
+ titleEn?: string;
+ text?: string;
+ fallbackSrc?: string;
+};
+
+/**
+ * Slot catalog published at GET /meshkee/static-image-slots.
+ * Ratios match the live homepage layout. If a section’s CSS size or
+ * aspect ratio changes, update this catalog in the same change — Meshkee
+ * reads it as the static-image source of truth.
+ */
+export const STATIC_IMAGE_SLOTS: StaticImageSlotCatalogItem[] = [
+ {
+ key: "slider",
+ label: "Homepage slider",
+ kind: "list",
+ pageKey: "home",
+ aspectRatio: "1920:1100",
+ itemCount: null,
+ recommendedWidth: 1920,
+ },
+ {
+ key: "categories",
+ label: "Featured categories",
+ kind: "list",
+ pageKey: "home",
+ aspectRatio: "1:1",
+ itemCount: null,
+ recommendedWidth: 160,
+ },
+ {
+ key: "two-banner-below-slider",
+ label: "Two banners below products",
+ kind: "list",
+ pageKey: "home",
+ aspectRatio: "2:1",
+ itemCount: 2,
+ recommendedWidth: 900,
+ },
+ {
+ key: "second-categories",
+ label: "Console / decorative categories",
+ kind: "list",
+ pageKey: "home",
+ aspectRatio: "1:1",
+ itemCount: null,
+ recommendedWidth: 320,
+ },
+ {
+ key: "about-us-image",
+ label: "About us image",
+ kind: "single",
+ pageKey: "home",
+ aspectRatio: "4:3",
+ itemCount: 1,
+ recommendedWidth: 960,
+ },
+];
+
+export function catalogSlot(key: string) {
+ return STATIC_IMAGE_SLOTS.find((slot) => slot.key === key);
+}
+
+export function parseAspectRatio(ratio?: string | null) {
+ if (!ratio) return undefined;
+ const match = /^(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)$/.exec(ratio.trim());
+ if (!match) return undefined;
+ return `${match[1]} / ${match[2]}`;
+}
+
+export function pickSlot(slots: StaticImageSlot[], key: string) {
+ return slots.find((slot) => slot.key === key);
+}
+
+function normalizeImages(images: SlotImage[] | undefined) {
+ return [...(images ?? [])]
+ .filter((image) => Boolean(image?.url))
+ .sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
+}
+
+export function imagesFromSlot(slot?: StaticImageSlot | null): SlotImage[] {
+ if (!slot) return [];
+ const images = normalizeImages(slot.images);
+ if (slot.kind === "single") return images.slice(0, 1);
+ return images;
+}
+
+function toMedia(image: SlotImage, fallback?: SlotMediaItem): SlotMediaItem {
+ return {
+ id: String(image.id),
+ image: image.url,
+ href: image.linkUrl?.trim() || fallback?.href,
+ title: image.titleFa?.trim() || fallback?.title,
+ titleEn: image.titleEn?.trim() || fallback?.titleEn,
+ text: image.subtext?.trim() || fallback?.text,
+ fallbackSrc: fallback?.image || fallback?.fallbackSrc,
+ };
+}
+
+export function overlaySlotImages(
+ slot: StaticImageSlot | undefined,
+ fallbacks: SlotMediaItem[],
+ key: string,
+): { items: SlotMediaItem[]; aspectRatio?: string } {
+ const catalog = catalogSlot(key);
+ const aspectRatio = slot?.aspectRatio || catalog?.aspectRatio;
+ const kind = slot?.kind ?? catalog?.kind ?? "list";
+ const itemCount = slot?.itemCount ?? catalog?.itemCount ?? null;
+ const apiImages = imagesFromSlot(slot ? { ...slot, kind, images: slot.images ?? [] } : undefined);
+
+ if (kind === "single") {
+ const image = apiImages[0];
+ const fallback = fallbacks[0];
+ return {
+ aspectRatio,
+ items: image ? [toMedia(image, fallback)] : fallbacks.slice(0, 1),
+ };
+ }
+
+ if (apiImages.length === 0) {
+ return {
+ aspectRatio,
+ items: itemCount ? fallbacks.slice(0, itemCount) : fallbacks,
+ };
+ }
+
+ if (itemCount) {
+ return {
+ aspectRatio,
+ items: Array.from({ length: itemCount }, (_, index) => {
+ const image = apiImages[index];
+ const fallback = fallbacks[index] ?? fallbacks[fallbacks.length - 1];
+ return image ? toMedia(image, fallback) : fallback;
+ }).filter(Boolean),
+ };
+ }
+
+ return {
+ aspectRatio,
+ items: apiImages.map((image, index) => toMedia(image, fallbacks[index])),
+ };
+}
+
+function readItems(payload: unknown): StaticImageSlot[] {
+ if (Array.isArray(payload)) return payload as StaticImageSlot[];
+ if (payload && typeof payload === "object") {
+ const record = payload as { items?: StaticImageSlot[]; slots?: StaticImageSlot[] };
+ return record.items ?? record.slots ?? [];
+ }
+ return [];
+}
+
+export async function fetchStaticImageSlots(pageKey = "home"): Promise {
+ const url = `${API_BASE}/tenants/${encodeURIComponent(WEBSITE_DOMAIN)}/website/static-images?pageKey=${encodeURIComponent(pageKey)}`;
+
+ try {
+ const response = await fetch(url, {
+ next: { revalidate: 60 },
+ });
+ if (!response.ok) return [];
+ return readItems(await response.json());
+ } catch {
+ return [];
+ }
+}
diff --git a/src/lib/store.ts b/src/lib/store.ts
new file mode 100644
index 0000000..864efb5
--- /dev/null
+++ b/src/lib/store.ts
@@ -0,0 +1,43 @@
+import type { ProductCardProps } from "@/components/ProductCard";
+import type { StoreVariant } from "@/lib/meshkee";
+import { slugify } from "@/lib/slug";
+
+export function formatToman(value?: number | null) {
+ if (value === null || value === undefined) return null;
+ return `${value.toLocaleString("fa-IR")} تومان`;
+}
+
+export function variantInStock(variant: Pick) {
+ return Boolean(variant.unlimited || variant.stockQuantity == null || variant.stockQuantity > 0);
+}
+
+export function variantSellingPrice(variant: Pick) {
+ return variant.discountedPrice ?? variant.price ?? null;
+}
+
+export function variantTitle(variant: StoreVariant) {
+ return (
+ variant.productNameFa?.trim() ||
+ variant.productTitle?.trim() ||
+ variant.label?.trim() ||
+ "محصول"
+ );
+}
+
+export function variantHref(variant: StoreVariant) {
+ const title = variantTitle(variant);
+ const productId = variant.productId ? String(variant.productId) : null;
+ if (!productId) return "/products";
+ return `/products/${productId}/${slugify(title)}`;
+}
+
+export function storeVariantToCard(variant: StoreVariant): ProductCardProps {
+ const price = formatToman(variantSellingPrice(variant));
+ return {
+ href: variantHref(variant),
+ image: variant.productImage || "/images/product-1.jpg",
+ title: variantTitle(variant),
+ price,
+ inStock: variantInStock(variant),
+ };
+}