diff --git a/src/components/ProductCard.tsx b/src/components/ProductCard.tsx
index e1f6197..ed3b1e4 100644
--- a/src/components/ProductCard.tsx
+++ b/src/components/ProductCard.tsx
@@ -2,6 +2,7 @@ import Image from "next/image";
import Link from "next/link";
import type { Product } from "@/lib/meshkee";
import { productImage, productPriceLabel, stripHtml } from "@/lib/meshkee";
+import { productPath } from "@/lib/urls";
export default function ProductCard({ product }: { product: Product }) {
const image = productImage(product);
@@ -9,7 +10,7 @@ export default function ProductCard({ product }: { product: Product }) {
return (
diff --git a/src/components/Providers.tsx b/src/components/Providers.tsx
new file mode 100644
index 0000000..abe0ebe
--- /dev/null
+++ b/src/components/Providers.tsx
@@ -0,0 +1,13 @@
+"use client";
+
+import { SessionProvider } from "@/lib/session";
+
+export default function Providers({
+ businessId,
+ children,
+}: {
+ businessId: string;
+ children: React.ReactNode;
+}) {
+ return
{children};
+}
diff --git a/src/lib/config.ts b/src/lib/config.ts
new file mode 100644
index 0000000..b572797
--- /dev/null
+++ b/src/lib/config.ts
@@ -0,0 +1,22 @@
+export const websiteDomain =
+ process.env.NEXT_PUBLIC_WEBSITE_DOMAIN ??
+ process.env.WEBSITE_DOMAIN ??
+ process.env.NEXT_PUBLIC_DOMAIN ??
+ "aradarisman.com";
+
+export const meshkeeApiBase = (
+ process.env.NEXT_PUBLIC_MESHKEE_API_BASE ??
+ process.env.MESHKEE_API_BASE ??
+ process.env.NEXT_PUBLIC_API_BASE_URL ??
+ "https://api.meshkee.com/api/v1"
+).replace(/\/$/, "");
+
+export const customerPortalUrl = (
+ process.env.NEXT_PUBLIC_CUSTOMER_PORTAL_URL ??
+ process.env.NEXT_PUBLIC_CUSTOMER_DASHBOARD_URL ??
+ `https://customer.${websiteDomain}`
+).replace(/\/$/, "");
+
+export const businessDashboardUrl = (
+ process.env.NEXT_PUBLIC_BUSINESS_ORIGIN ?? `https://business.${websiteDomain}`
+).replace(/\/$/, "");
diff --git a/src/lib/meshkee-server.ts b/src/lib/meshkee-server.ts
new file mode 100644
index 0000000..97b9bce
--- /dev/null
+++ b/src/lib/meshkee-server.ts
@@ -0,0 +1,42 @@
+import { meshkeeApiBase } from "@/lib/config";
+
+export function getBearerToken(request: Request): string | null {
+ const auth = request.headers.get("authorization");
+ if (auth?.startsWith("Bearer ")) return auth.slice(7);
+ return null;
+}
+
+export async function meshkeeFetch(
+ path: string,
+ init?: RequestInit & { token?: string | null },
+): Promise<{ ok: boolean; status: number; data: unknown }> {
+ const headers = new Headers(init?.headers);
+ if (!headers.has("Content-Type") && init?.body) {
+ headers.set("Content-Type", "application/json");
+ }
+ if (init?.token) {
+ headers.set("Authorization", `Bearer ${init.token}`);
+ }
+
+ const res = await fetch(`${meshkeeApiBase}${path}`, {
+ ...init,
+ headers,
+ });
+
+ let data: unknown = null;
+ try {
+ data = await res.json();
+ } catch {
+ data = null;
+ }
+
+ return { ok: res.ok, status: res.status, data };
+}
+
+export function jsonResponse(data: unknown, status = 200) {
+ return Response.json(data, { status });
+}
+
+export function unauthorizedResponse() {
+ return jsonResponse({ error: "Unauthorized" }, 401);
+}
diff --git a/src/lib/meshkee.ts b/src/lib/meshkee.ts
index e9299f3..681fe19 100644
--- a/src/lib/meshkee.ts
+++ b/src/lib/meshkee.ts
@@ -3,6 +3,52 @@ import { toFa } from "@/lib/format";
const API_BASE = "https://api.meshkee.com/api/v1";
export const TENANT_DOMAIN = "aradarisman.com";
+export type Tenant = {
+ id: number;
+ name: string;
+ nameFa: string;
+ slug: string;
+ domain: string;
+};
+
+export async function getTenant(): Promise
{
+ try {
+ return await meshkeeFetch(`/tenants/${TENANT_DOMAIN}`, 3600);
+ } catch {
+ return null;
+ }
+}
+
+export async function getBusinessId(): Promise {
+ const tenant = await getTenant();
+ return tenant?.id != null ? String(tenant.id) : "";
+}
+
+export type WebsiteFavicon = {
+ faviconUrl: string | null;
+ logoUrl: string | null;
+ logoDarkUrl: string | null;
+ hasDedicatedFavicon: boolean;
+};
+
+const defaultFavicon: WebsiteFavicon = {
+ faviconUrl: null,
+ logoUrl: null,
+ logoDarkUrl: null,
+ hasDedicatedFavicon: false,
+};
+
+export async function getWebsiteFavicon(): Promise {
+ try {
+ return await meshkeeFetch(
+ `/tenants/${TENANT_DOMAIN}/website/favicon`,
+ 3600
+ );
+ } catch {
+ return defaultFavicon;
+ }
+}
+
/**
* Business-managed image overrides (hero slider, category tiles, feature
* icons, about photo, brand logos). Each slot has a stable `key`; when a
@@ -139,6 +185,17 @@ export async function getProduct(slug: string): Promise {
return data.product as Product;
}
+export async function getProductById(id: string): Promise {
+ const res = await fetch(
+ `${API_BASE}/tenants/${TENANT_DOMAIN}/products/by-id/${id}`,
+ { next: { revalidate: 300 } }
+ );
+ if (res.status === 404) return null;
+ if (!res.ok) throw new Error(`Meshkee API product id "${id}" failed: ${res.status}`);
+ const data = await res.json();
+ return data.product as Product;
+}
+
export function productImage(product: Product): string | null {
return product.thumbnail || product.image || product.thumbnailUrl || null;
}
@@ -151,9 +208,72 @@ export function productPriceLabel(product: Product): string {
return "تماس بگیرید";
}
+export type Category = {
+ id: string;
+ parentId: string | null;
+ name: string;
+ nameFa: string;
+ slug: string;
+ sortOrder: number;
+ isActive: boolean;
+};
+
+export async function getCategories(): Promise {
+ try {
+ const data = await meshkeeFetch<{ items: Category[] }>(
+ `/tenants/${TENANT_DOMAIN}/categories`
+ );
+ return data.items.filter((c) => c.isActive);
+ } catch {
+ return [];
+ }
+}
+
+export async function getCategoryById(id: string): Promise {
+ const res = await fetch(
+ `${API_BASE}/tenants/${TENANT_DOMAIN}/categories/by-id/${id}`,
+ { next: { revalidate: 300 } }
+ );
+ if (res.status === 404) return null;
+ if (!res.ok) throw new Error(`Meshkee API category id "${id}" failed: ${res.status}`);
+ const data = await res.json();
+ return data.category as Category;
+}
+
+export async function getTopLevelCategories(): Promise {
+ const items = await getCategories();
+ return items
+ .filter((c) => c.parentId === null)
+ .sort(
+ (a, b) =>
+ a.sortOrder - b.sortOrder ||
+ a.nameFa.localeCompare(b.nameFa, "fa")
+ );
+}
+
+/** Includes the category itself and all nested descendants. */
+export function getCategoryDescendantIds(
+ categoryId: string,
+ categories: Category[]
+): string[] {
+ const ids = new Set([categoryId]);
+ let changed = true;
+ while (changed) {
+ changed = false;
+ for (const c of categories) {
+ if (c.parentId && ids.has(c.parentId) && !ids.has(c.id)) {
+ ids.add(c.id);
+ changed = true;
+ }
+ }
+ }
+ return Array.from(ids);
+}
+
export type CategorySummary = {
categoryId: string;
name: string;
+ nameEn: string | null;
count: number;
image: string | null;
};
@@ -172,7 +292,7 @@ export async function getCategorySummary(limit = 8): Promise
const perId = new Map<
string,
- { id: string; name: string; count: number; image: string | null }
+ { id: string; name: string; nameEn: string | null; count: number; image: string | null }
>();
for (const p of items) {
if (!p.categoryId || !p.categoryNameFa) continue;
@@ -181,8 +301,15 @@ export async function getCategorySummary(limit = 8): Promise
if (entry) {
entry.count += 1;
if (!entry.image && img) entry.image = img;
+ if (!entry.nameEn && p.categoryName) entry.nameEn = p.categoryName;
} else {
- perId.set(p.categoryId, { id: p.categoryId, name: p.categoryNameFa, count: 1, image: img });
+ perId.set(p.categoryId, {
+ id: p.categoryId,
+ name: p.categoryNameFa,
+ nameEn: p.categoryName ?? null,
+ count: 1,
+ image: img,
+ });
}
}
@@ -193,6 +320,7 @@ export async function getCategorySummary(limit = 8): Promise
perName.set(entry.name, {
categoryId: entry.id,
name: entry.name,
+ nameEn: entry.nameEn,
count: entry.count,
image: entry.image,
});
@@ -200,8 +328,12 @@ export async function getCategorySummary(limit = 8): Promise
}
existing.count += entry.count;
const currentBest = perId.get(existing.categoryId)?.count ?? 0;
- if (entry.count > currentBest) existing.categoryId = entry.id;
+ if (entry.count > currentBest) {
+ existing.categoryId = entry.id;
+ if (entry.nameEn) existing.nameEn = entry.nameEn;
+ }
if (!existing.image && entry.image) existing.image = entry.image;
+ if (!existing.nameEn && entry.nameEn) existing.nameEn = entry.nameEn;
}
return Array.from(perName.values())
@@ -290,6 +422,16 @@ export async function getPost(slug: string): Promise {
return (data.blog ?? data.post ?? data) as Post;
}
+export async function getPostById(id: string): Promise {
+ const res = await fetch(`${API_BASE}/tenants/${TENANT_DOMAIN}/blogs/by-id/${id}`, {
+ next: { revalidate: 300 },
+ });
+ if (res.status === 404) return null;
+ if (!res.ok) throw new Error(`Meshkee API blog id "${id}" failed: ${res.status}`);
+ const data = await res.json();
+ return (data.blog ?? data.post ?? data) as Post;
+}
+
export function postTitle(post: Post): string {
return post.nameFa || post.titleFa || post.title || "بدون عنوان";
}
diff --git a/src/lib/seo.ts b/src/lib/seo.ts
new file mode 100644
index 0000000..905c8f1
--- /dev/null
+++ b/src/lib/seo.ts
@@ -0,0 +1,27 @@
+import type { Metadata } from "next";
+
+export const SITE_URL =
+ process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, "") ?? "https://aradarisman.com";
+
+export function canonicalPath(path: string): string {
+ if (!path || path === "/") return SITE_URL;
+ return `${SITE_URL}${path.startsWith("/") ? path : `/${path}`}`;
+}
+
+export function pageMetadata({
+ title,
+ description,
+ path,
+}: {
+ title: string;
+ description: string;
+ path: string;
+}): Metadata {
+ return {
+ title,
+ description,
+ alternates: {
+ canonical: canonicalPath(path),
+ },
+ };
+}
diff --git a/src/lib/session.tsx b/src/lib/session.tsx
new file mode 100644
index 0000000..bc953a3
--- /dev/null
+++ b/src/lib/session.tsx
@@ -0,0 +1,188 @@
+"use client";
+
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+ type ReactNode,
+} from "react";
+
+import {
+ clearSharedAuthCookies,
+ hydrateLocalTokensFromSharedCookies,
+ SHARED_ACCESS_TOKEN_KEY,
+ SHARED_REFRESH_TOKEN_KEY,
+ writeSharedAuthCookies,
+} from "@/lib/shared-auth-cookie";
+
+export type AuthBusinessMembership = {
+ id: string;
+ name: string;
+ slug: string;
+ isOwner: boolean;
+ teamRole: string | null;
+};
+
+export type AuthUser = {
+ id: string;
+ firstName?: string;
+ lastName?: string;
+ cellNumber?: string;
+ email?: string;
+ roles?: string[];
+ isSuperAdmin?: boolean;
+ businesses?: AuthBusinessMembership[];
+};
+
+type SessionContextValue = {
+ user: AuthUser | null;
+ isAuthenticated: boolean;
+ ready: boolean;
+ businessId: string;
+ isWebsiteAdmin: boolean;
+ logout: () => void;
+ getAccessToken: () => string | null;
+};
+
+function normalizeUser(raw: Partial & { id?: string | number }): AuthUser | null {
+ if (raw.id == null) return null;
+ return {
+ id: String(raw.id),
+ firstName: raw.firstName ?? undefined,
+ lastName: raw.lastName ?? undefined,
+ cellNumber: raw.cellNumber ?? undefined,
+ email: raw.email ?? undefined,
+ roles: Array.isArray(raw.roles) ? raw.roles : undefined,
+ 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): boolean {
+ if (!user || !businessId) return false;
+ if (user.isSuperAdmin || user.roles?.includes("super_admin")) return true;
+ return Boolean(
+ user.businesses?.some(
+ (business) => String(business.id) === String(businessId) && (business.isOwner || Boolean(business.teamRole)),
+ ),
+ );
+}
+
+const SessionContext = createContext(null);
+
+function readToken(key: string): string | null {
+ try {
+ return localStorage.getItem(key);
+ } catch {
+ return null;
+ }
+}
+
+function writeToken(key: string, value: string | null) {
+ try {
+ if (value) localStorage.setItem(key, value);
+ else localStorage.removeItem(key);
+ } catch {
+ // ignore
+ }
+}
+
+export function SessionProvider({
+ children,
+ businessId,
+}: {
+ children: ReactNode;
+ businessId: string;
+}) {
+ const [user, setUser] = useState(null);
+ const [ready, setReady] = useState(false);
+
+ const getAccessToken = useCallback(() => {
+ hydrateLocalTokensFromSharedCookies(SHARED_ACCESS_TOKEN_KEY, SHARED_REFRESH_TOKEN_KEY);
+ return (
+ readToken(SHARED_ACCESS_TOKEN_KEY) ||
+ readToken("accessToken") ||
+ readToken("meshkeeToken") ||
+ readToken("token")
+ );
+ }, []);
+
+ const clearTokens = useCallback(() => {
+ writeToken(SHARED_ACCESS_TOKEN_KEY, null);
+ writeToken(SHARED_REFRESH_TOKEN_KEY, null);
+ writeToken("accessToken", null);
+ writeToken("meshkeeToken", null);
+ writeToken("token", null);
+ clearSharedAuthCookies();
+ setUser(null);
+ }, []);
+
+ useEffect(() => {
+ const token = getAccessToken();
+ if (!token) {
+ setReady(true);
+ return;
+ }
+
+ const refresh = readToken(SHARED_REFRESH_TOKEN_KEY);
+ writeSharedAuthCookies(token, refresh ?? undefined);
+
+ fetch("/api/auth/me", { headers: { Authorization: `Bearer ${token}` } })
+ .then(async (res) => {
+ if (res.status === 401) {
+ clearTokens();
+ return;
+ }
+ if (!res.ok) return;
+ const data = (await res.json()) as { user?: AuthUser } & AuthUser;
+ const next = normalizeUser(data.user ?? data);
+ if (next) setUser(next);
+ })
+ .catch(() => undefined)
+ .finally(() => setReady(true));
+ }, [clearTokens, getAccessToken]);
+
+ const logout = useCallback(() => {
+ clearTokens();
+ }, [clearTokens]);
+
+ const isWebsiteAdmin = useMemo(() => isWebsiteAdminForBusiness(user, businessId), [user, businessId]);
+
+ const value = useMemo(
+ () => ({
+ user,
+ isAuthenticated: Boolean(user),
+ ready,
+ businessId,
+ isWebsiteAdmin,
+ logout,
+ getAccessToken,
+ }),
+ [user, ready, businessId, isWebsiteAdmin, logout, getAccessToken],
+ );
+
+ return {children};
+}
+
+export function useSession() {
+ const ctx = useContext(SessionContext);
+ if (!ctx) throw new Error("useSession must be used within SessionProvider");
+ return ctx;
+}
+
+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 || "کاربر";
+}
diff --git a/src/lib/shared-auth-cookie.ts b/src/lib/shared-auth-cookie.ts
new file mode 100644
index 0000000..87843c4
--- /dev/null
+++ b/src/lib/shared-auth-cookie.ts
@@ -0,0 +1,123 @@
+/** 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}`;
+ 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);
+}
+
+export function hydrateLocalTokensFromSharedCookies(
+ accessKey = SHARED_ACCESS_TOKEN_KEY,
+ refreshKey = SHARED_REFRESH_TOKEN_KEY,
+): boolean {
+ if (typeof window === "undefined") return false;
+ if (localStorage.getItem(accessKey)) return false;
+ const access = readSharedAccessToken();
+ if (!access) return false;
+ localStorage.setItem(accessKey, access);
+ const refresh = readSharedRefreshToken();
+ if (refresh) localStorage.setItem(refreshKey, refresh);
+ return true;
+}
diff --git a/src/lib/slugify.ts b/src/lib/slugify.ts
new file mode 100644
index 0000000..10d8b24
--- /dev/null
+++ b/src/lib/slugify.ts
@@ -0,0 +1,9 @@
+/** Farsi letters kept, spaces → -, other punctuation stripped. */
+export function slugifyFa(text: string): string {
+ return text
+ .trim()
+ .replace(/\s+/g, "-")
+ .replace(/[^\p{Script=Arabic}\p{N}-]+/gu, "")
+ .replace(/-+/g, "-")
+ .replace(/^-+|-+$/g, "");
+}
diff --git a/src/lib/urls.ts b/src/lib/urls.ts
new file mode 100644
index 0000000..7e7de84
--- /dev/null
+++ b/src/lib/urls.ts
@@ -0,0 +1,42 @@
+import type { Category, Post, Product } from "@/lib/meshkee";
+import { postTitle } from "@/lib/meshkee";
+import { slugifyFa } from "@/lib/slugify";
+
+export function productSlug(product: Pick): string {
+ return slugifyFa(product.nameFa);
+}
+
+export function productPath(product: Pick): string {
+ const slug = productSlug(product);
+ return `/products/${product.id}/${encodeURIComponent(slug)}`;
+}
+
+export function productUrl(product: Pick): string {
+ return productPath(product);
+}
+
+export function categorySlug(category: Pick): string {
+ return slugifyFa(category.nameFa);
+}
+
+export function categoryPath(category: Pick): string {
+ const slug = categorySlug(category);
+ return `/products/category/${category.id}/${encodeURIComponent(slug)}`;
+}
+
+export function categoryUrl(category: Pick): string {
+ return categoryPath(category);
+}
+
+export function blogSlug(post: Post): string {
+ return slugifyFa(postTitle(post));
+}
+
+export function blogPath(post: Pick & Post): string {
+ const slug = blogSlug(post);
+ return `/blog/${post.id}/${encodeURIComponent(slug)}`;
+}
+
+export function blogUrl(post: Pick & Post): string {
+ return blogPath(post);
+}