Update carousels, card hover, and IranBay branding.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-10-04 13:21:51 +03:30
co-authored by Cursor
commit 42aff5aee9
162 changed files with 16828 additions and 0 deletions
+88
View File
@@ -0,0 +1,88 @@
import {
clearTokens,
getAccessToken,
} from "@/lib/session";
export type WebsiteAuthUser = {
id: string;
cellNumber: string;
email: string | null;
firstName: string | null;
lastName: string | null;
firstNameEn: string | null;
lastNameEn: string | null;
roles: string[];
dashboard: "super_admin" | "business" | "customer";
businesses: {
id: string;
name: string;
slug: string;
isOwner: boolean;
}[];
};
const MESHKEE_API_URL =
process.env.NEXT_PUBLIC_MESHKEE_API_URL?.replace(/\/$/, "") ||
"https://api.meshkee.com/api/v1";
export function getCustomerDashboardUrl() {
return (
process.env.NEXT_PUBLIC_CUSTOMER_DASHBOARD_URL?.replace(/\/$/, "") ||
"http://customer.localhost:3002"
);
}
export function getAdminDashboardUrl() {
return (
process.env.NEXT_PUBLIC_ADMIN_DASHBOARD_URL?.replace(/\/$/, "") ||
process.env.NEXT_PUBLIC_DASHBOARD_URL?.replace(/\/$/, "") ||
"http://localhost:3002"
);
}
export function getBusinessDashboardUrl() {
return (
process.env.NEXT_PUBLIC_BUSINESS_DASHBOARD_URL?.replace(/\/$/, "") ||
"http://business.localhost:3002"
);
}
export function displayNameFor(user: WebsiteAuthUser | null | undefined) {
if (!user) return "";
const first = (user.firstNameEn || user.firstName || "").trim();
const last = (user.lastNameEn || user.lastName || "").trim();
const full = [first, last].filter(Boolean).join(" ");
if (full) return full;
return user.cellNumber || "Account";
}
/** Header label: first name only. */
export function firstNameFor(user: WebsiteAuthUser | null | undefined) {
if (!user) return "";
const first = (user.firstNameEn || user.firstName || "").trim();
if (first) return first.split(/\s+/)[0] || first;
return user.cellNumber || "Account";
}
export async function fetchCurrentUser(
signal?: AbortSignal,
): Promise<WebsiteAuthUser | null> {
const token = getAccessToken();
if (!token) return null;
const response = await fetch(`${MESHKEE_API_URL}/auth/me`, {
headers: { Authorization: `Bearer ${token}` },
signal,
cache: "no-store",
});
if (response.status === 401 || response.status === 403) {
clearTokens();
return null;
}
if (!response.ok) return null;
const payload = (await response.json()) as { user?: WebsiteAuthUser };
return payload.user ?? null;
}
+44
View File
@@ -0,0 +1,44 @@
import { getAccessToken } from "@/lib/session";
const API_BASE_URL =
process.env.NEXT_PUBLIC_MARKETPLACE_API_URL?.replace(/\/$/, "") ||
"http://localhost:3001/api/v1";
export async function registerShop(body: {
name: string;
slug?: string;
about?: string;
meshkeeCategoryIds?: string[];
ownerPassword: string;
}) {
const token = getAccessToken();
if (!token) {
throw new Error("Sign in required");
}
const response = await fetch(`${API_BASE_URL}/businesses/register-shop`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message =
typeof payload?.message === "string"
? payload.message
: Array.isArray(payload?.message)
? payload.message.join(", ")
: `Registration failed (${response.status})`;
throw new Error(message);
}
return payload as {
listing: { id: string; slug: string; status: string };
meshkeeBusinessId: string;
message: string;
};
}
+156
View File
@@ -0,0 +1,156 @@
import type { MegaTab } from "@/data/home";
import type { MeshkeeCategory } from "@/lib/meshkee-categories";
type TreeNode = MeshkeeCategory & { children: TreeNode[] };
const FEATURED_BY_SLUG: Record<
string,
{ title: string; image: string; imageClass?: string }
> = {
industry: {
title: "Iranian industry",
image: "/images/categories/industry.png",
imageClass: "drum",
},
industries: {
title: "Iranian industry",
image: "/images/categories/industry.png",
imageClass: "drum",
},
stores: {
title: "Persian unique style",
image: "/images/categories/clothes.png",
imageClass: "dress",
},
wholesellers: {
title: "Wholesale market",
image: "/images/categories/shoes.png",
imageClass: "shoes",
},
"whole-sellers": {
title: "Wholesale market",
image: "/images/categories/shoes.png",
imageClass: "shoes",
},
services: {
title: "Professional services",
image: "/images/categories/services.png",
imageClass: "notebook",
},
};
const ICON_BY_SLUG: Record<string, string> = {
industry: "/images/icons/gear.svg",
industries: "/images/icons/gear.svg",
stores: "/images/icons/cart.svg",
wholesellers: "/images/icons/gear.svg",
"whole-sellers": "/images/icons/gear.svg",
services: "/images/icons/home-outline.svg",
};
const DEFAULT_FEATURED = {
title: "Explore IranBay",
image: "/images/categories/clothes.png",
imageClass: "dress",
};
function sortNodes(nodes: TreeNode[]) {
nodes.sort(
(a, b) =>
a.sortOrder - b.sortOrder ||
a.name.localeCompare(b.name, undefined, { sensitivity: "base" }),
);
for (const node of nodes) sortNodes(node.children);
}
function buildTree(categories: MeshkeeCategory[]): TreeNode[] {
const byId = new Map<string, TreeNode>();
for (const category of categories) {
byId.set(category.id, { ...category, children: [] });
}
const roots: TreeNode[] = [];
for (const node of byId.values()) {
if (node.parentId && byId.has(node.parentId)) {
byId.get(node.parentId)!.children.push(node);
} else {
roots.push(node);
}
}
sortNodes(roots);
return roots;
}
function categoryHref(category: MeshkeeCategory) {
const slug = category.slug?.trim();
if (slug) return `/businesses?category=${encodeURIComponent(slug)}`;
return `/businesses?categoryId=${encodeURIComponent(category.id)}`;
}
function resolveIcon(category: MeshkeeCategory) {
const icon = category.icon?.trim();
if (icon) {
if (
icon.startsWith("/") ||
icon.startsWith("http://") ||
icon.startsWith("https://")
) {
return icon;
}
}
return ICON_BY_SLUG[category.slug] ?? "/images/icons/category.svg";
}
function resolveFeatured(category: MeshkeeCategory) {
const mapped = FEATURED_BY_SLUG[category.slug];
if (mapped) return mapped;
return {
...DEFAULT_FEATURED,
title: category.name ? `Explore ${category.name}` : DEFAULT_FEATURED.title,
};
}
/** Roots → groups (L2) → links (L3) for the website mega menu. */
export function buildMegaMenuTabs(categories: MeshkeeCategory[]): MegaTab[] {
const roots = buildTree(categories).filter(
(root) => root.slug !== "other" && root.name.trim(),
);
return roots.map((root) => {
const groups = root.children.map((group) => {
const links = group.children.length
? group.children.map((child) => ({
label: child.name,
href: categoryHref(child),
}))
: [
{
label: group.name,
href: categoryHref(group),
},
];
return {
title: group.name,
links,
};
});
return {
id: root.slug || root.id,
label: root.name,
icon: resolveIcon(root),
groups:
groups.length > 0
? groups
: [
{
title: root.name,
links: [{ label: "Browse", href: categoryHref(root) }],
},
],
featured: resolveFeatured(root),
};
});
}
+76
View File
@@ -0,0 +1,76 @@
import { getAccessToken } from "@/lib/session";
export interface MeshkeeCategory {
id: string;
parentId: string | null;
name: string;
nameFa: string | null;
slug: string;
description: string | null;
icon: string | null;
sortOrder: number;
tags: string[];
}
export function categoryLabel(category: {
name: string;
nameFa?: string | null;
slug: string;
}) {
if (category.nameFa && category.nameFa !== category.name) {
return `${category.name} / ${category.nameFa}`;
}
return category.name || category.slug;
}
function mapCategory(row: Record<string, unknown>): MeshkeeCategory {
return {
id: String(row.id),
parentId:
row.parentId == null && row.parent_id == null
? null
: String(row.parentId ?? row.parent_id),
name: String(row.name ?? ""),
nameFa:
row.nameFa == null && row.name_fa == null
? null
: String(row.nameFa ?? row.name_fa),
slug: String(row.slug ?? ""),
description: row.description == null ? null : String(row.description),
icon: row.icon == null ? null : String(row.icon),
sortOrder: Number(row.sortOrder ?? row.sort_order ?? 0),
tags: Array.isArray(row.tags) ? (row.tags as string[]) : [],
};
}
export async function searchMeshkeeCategories(
q = "",
signal?: AbortSignal,
): Promise<MeshkeeCategory[]> {
const params = new URLSearchParams();
if (q.trim()) params.set("q", q.trim());
const headers = new Headers();
const token = getAccessToken();
if (token) headers.set("Authorization", `Bearer ${token}`);
const response = await fetch(`/api/meshkee/categories?${params}`, {
headers,
signal,
cache: "no-store",
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message =
typeof payload?.message === "string"
? payload.message
: Array.isArray(payload?.message)
? payload.message.join(", ")
: `Failed to load categories (${response.status})`;
throw new Error(message);
}
const items = Array.isArray(payload?.items) ? payload.items : [];
return items.map((row: Record<string, unknown>) => mapCategory(row));
}
+80
View File
@@ -0,0 +1,80 @@
const ACCESS_TOKEN_KEY = "marketplace_access_token";
const REFRESH_TOKEN_KEY = "marketplace_refresh_token";
const TOKEN_MAX_AGE_SEC = 60 * 60 * 24 * 14;
function cookieDomain(): string {
const root = (process.env.NEXT_PUBLIC_ROOT_DOMAIN || "localhost").trim();
if (!root || root === "localhost" || root.startsWith("127.")) {
return "localhost";
}
return root.startsWith(".") ? root : `.${root}`;
}
function readCookie(name: string): string | null {
if (typeof document === "undefined") return null;
const prefix = `${name}=`;
for (const part of document.cookie.split(";")) {
const trimmed = part.trim();
if (trimmed.startsWith(prefix)) {
return decodeURIComponent(trimmed.slice(prefix.length));
}
}
return null;
}
function writeCookie(name: string, value: string) {
if (typeof document === "undefined") return;
const secure =
typeof window !== "undefined" && window.location.protocol === "https:"
? "; Secure"
: "";
document.cookie = `${name}=${encodeURIComponent(value)}; Path=/; Max-Age=${TOKEN_MAX_AGE_SEC}; SameSite=Lax; Domain=${cookieDomain()}${secure}`;
}
export function getAccessToken() {
if (typeof window === "undefined") return null;
return readCookie(ACCESS_TOKEN_KEY) || localStorage.getItem(ACCESS_TOKEN_KEY);
}
export function getRefreshToken() {
if (typeof window === "undefined") return null;
return (
readCookie(REFRESH_TOKEN_KEY) || localStorage.getItem(REFRESH_TOKEN_KEY)
);
}
export function setTokens(accessToken: string, refreshToken: string) {
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
writeCookie(ACCESS_TOKEN_KEY, accessToken);
writeCookie(REFRESH_TOKEN_KEY, refreshToken);
}
export function clearTokens() {
localStorage.removeItem(ACCESS_TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
if (typeof document === "undefined") return;
const domain = cookieDomain();
document.cookie = `${ACCESS_TOKEN_KEY}=; Path=/; Max-Age=0; SameSite=Lax; Domain=${domain}`;
document.cookie = `${REFRESH_TOKEN_KEY}=; Path=/; Max-Age=0; SameSite=Lax; Domain=${domain}`;
}
export function getWebsiteOrigin() {
return (
process.env.NEXT_PUBLIC_WEBSITE_URL?.replace(/\/$/, "") ||
(typeof window !== "undefined" ? window.location.origin : "http://localhost:3001")
);
}
export function getCustomerLoginUrl(returnPath = "/register") {
const customer =
process.env.NEXT_PUBLIC_CUSTOMER_DASHBOARD_URL?.replace(/\/$/, "") ||
"http://customer.localhost:3002";
const website = getWebsiteOrigin();
const next = returnPath.startsWith("http")
? returnPath
: `${website}${returnPath.startsWith("/") ? returnPath : `/${returnPath}`}`;
const url = new URL(`${customer}/login`);
url.searchParams.set("next", next);
return url.toString();
}
+69
View File
@@ -0,0 +1,69 @@
import type { Producer } from "@/data/home";
const API_BASE_URL =
process.env.NEXT_PUBLIC_MARKETPLACE_API_URL?.replace(/\/$/, "") ||
process.env.MARKETPLACE_API_URL?.replace(/\/$/, "") ||
"http://localhost:3001/api/v1";
export type WebsiteCarouselBusiness = {
id: string;
slug: string;
name: string;
tagline: string;
about: string;
logoUrl: string | null;
bannerUrl: string | null;
categoryName: string | null;
productCount: number;
publishedAt: string | null;
};
export type WebsiteCarousel = {
id: string;
title: string;
subtext: string;
slug: string;
businesses: WebsiteCarouselBusiness[];
};
const PLACEHOLDER_COVER = "/images/producers/cover-laundry.png";
const PLACEHOLDER_LOGO = "/images/producers/logo-sani.png";
const DEFAULT_DESCRIPTION =
"we will show your business to verity of visitor around the world.";
export async function getWebsiteCarousels(): Promise<WebsiteCarousel[]> {
try {
const response = await fetch(`${API_BASE_URL}/website/carousels`, {
cache: "no-store",
});
if (!response.ok) return [];
const data = (await response.json()) as { carousels?: WebsiteCarousel[] };
return Array.isArray(data.carousels) ? data.carousels : [];
} catch {
return [];
}
}
export function carouselBusinessToProducer(
business: WebsiteCarouselBusiness,
): Producer {
const since = business.publishedAt
? String(new Date(business.publishedAt).getFullYear())
: "—";
return {
slug: business.slug,
name: business.name || business.slug,
tagline: business.tagline || "",
products: String(business.productCount ?? 0),
since,
// English about when present; otherwise English placeholder.
// Farsi about is already stripped by the API.
description: business.about?.trim() || DEFAULT_DESCRIPTION,
typeIcon: "gear",
typeLabel: "Business",
category: business.categoryName || "Business",
cover: business.bannerUrl || PLACEHOLDER_COVER,
logo: business.logoUrl || PLACEHOLDER_LOGO,
};
}
+56
View File
@@ -0,0 +1,56 @@
const API_BASE_URL =
process.env.NEXT_PUBLIC_MARKETPLACE_API_URL?.replace(/\/$/, "") ||
process.env.MARKETPLACE_API_URL?.replace(/\/$/, "") ||
"http://localhost:3001/api/v1";
export type WebsiteMediaItem = {
id: string;
imageUrl: string;
linkUrl: string;
title?: string;
subtitle?: string;
cta?: string;
};
export type WebsiteHomeMedia = {
slider: WebsiteMediaItem[];
standingBanners: WebsiteMediaItem[];
categories: WebsiteMediaItem[];
menuImages: WebsiteMediaItem[];
aboutUs: WebsiteMediaItem;
banners: WebsiteMediaItem[];
};
function resolveMediaUrl(url: string) {
if (!url) return url;
if (url.startsWith("http://") || url.startsWith("https://")) return url;
if (url.startsWith("/api/v1/")) {
const origin = API_BASE_URL.replace(/\/api\/v1$/, "");
return `${origin}${url}`;
}
return url;
}
function mapItem(item: WebsiteMediaItem): WebsiteMediaItem {
return { ...item, imageUrl: resolveMediaUrl(item.imageUrl) };
}
export async function getWebsiteHomeMedia(): Promise<WebsiteHomeMedia | null> {
try {
const response = await fetch(`${API_BASE_URL}/website/home-media`, {
next: { revalidate: 30 },
});
if (!response.ok) return null;
const data = (await response.json()) as WebsiteHomeMedia;
return {
slider: (data.slider ?? []).map(mapItem),
standingBanners: (data.standingBanners ?? []).map(mapItem),
categories: (data.categories ?? []).map(mapItem),
menuImages: (data.menuImages ?? []).map(mapItem),
aboutUs: mapItem(data.aboutUs),
banners: (data.banners ?? []).map(mapItem),
};
} catch {
return null;
}
}