Update carousels, card hover, and IranBay branding.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user