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:27 +03:30
co-authored by Cursor
commit 1153e8e78f
178 changed files with 30517 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
/** Normalize Iranian/local input to E.164 (e.g. +989121111111). */
export function toE164CellNumber(input: string): string {
const digits = input.replace(/\D/g, "");
if (!digits) {
return "";
}
if (digits.startsWith("98")) {
return `+${digits}`;
}
if (digits.startsWith("0")) {
return `+98${digits.slice(1)}`;
}
if (digits.length === 10 && digits.startsWith("9")) {
return `+98${digits}`;
}
return `+${digits}`;
}
+87
View File
@@ -0,0 +1,87 @@
export type Area = {
x: number;
y: number;
width: number;
height: number;
};
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image();
image.addEventListener("load", () => resolve(image));
image.addEventListener("error", (error) => reject(error));
image.crossOrigin = "anonymous";
image.src = src;
});
}
/**
* Crop that may extend outside the source image.
* Outside pixels stay transparent (PNG) or fillColor (JPEG).
*/
export async function getCroppedImageFile(
imageSrc: string,
pixelCrop: Area,
fileName: string,
mimeType = "image/jpeg",
quality = 0.92,
fillColor = "#ffffff",
): Promise<File> {
const image = await loadImage(imageSrc);
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Could not crop image.");
const outputWidth = Math.max(1, Math.round(pixelCrop.width));
const outputHeight = Math.max(1, Math.round(pixelCrop.height));
canvas.width = outputWidth;
canvas.height = outputHeight;
if (mimeType !== "image/png") {
ctx.fillStyle = fillColor;
ctx.fillRect(0, 0, outputWidth, outputHeight);
} else {
ctx.clearRect(0, 0, outputWidth, outputHeight);
}
const srcX = Math.max(0, pixelCrop.x);
const srcY = Math.max(0, pixelCrop.y);
const srcRight = Math.min(image.naturalWidth, pixelCrop.x + pixelCrop.width);
const srcBottom = Math.min(
image.naturalHeight,
pixelCrop.y + pixelCrop.height,
);
const srcWidth = srcRight - srcX;
const srcHeight = srcBottom - srcY;
if (srcWidth > 0 && srcHeight > 0) {
const destX = Math.round(srcX - pixelCrop.x);
const destY = Math.round(srcY - pixelCrop.y);
ctx.drawImage(
image,
srcX,
srcY,
srcWidth,
srcHeight,
destX,
destY,
srcWidth,
srcHeight,
);
}
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(result) => {
if (result) resolve(result);
else reject(new Error("Could not export cropped image."));
},
mimeType,
quality,
);
});
const base = fileName.replace(/\.[^.]+$/, "") || "image";
const extension = mimeType === "image/png" ? "png" : "jpg";
return new File([blob], `${base}-cropped.${extension}`, { type: mimeType });
}
+38
View File
@@ -0,0 +1,38 @@
import type { ReactNode } from "react";
const ARABIC_SCRIPT = /[\u0600-\u06FF]/;
/** Wrap Persian/Arabic runs in IRANYekan; leave Latin as-is. */
export function withFaSpans(value: string): ReactNode {
if (!value || !ARABIC_SCRIPT.test(value)) return value;
const parts = value.split(/([\u0600-\u06FF][\u0600-\u06FF\s\u200c]*)/g);
return parts.map((part, index) =>
ARABIC_SCRIPT.test(part) ? (
<span key={index} className="fa" lang="fa" dir="rtl">
{part}
</span>
) : (
part
),
);
}
export function hasArabicScript(value: string | null | undefined) {
return Boolean(value && ARABIC_SCRIPT.test(value));
}
/** Keep phone / E.164 numbers LTR even next to Persian text. */
export function PhoneLtr({
value,
className,
}: {
value: string;
className?: string;
}) {
return (
<span className={className} dir="ltr" lang="en">
{value}
</span>
);
}
+131
View File
@@ -0,0 +1,131 @@
import type { DashboardType } from "@/types/auth";
export type HostApp = "admin" | "customer" | "business";
const BUSINESS_PREFIX = "business.";
const CUSTOMER_PREFIX = "customer.";
/** Host without port, lowercased. */
export function normalizeHost(hostHeader: string | null | undefined): string {
if (!hostHeader) return "";
return hostHeader.split(":")[0]?.toLowerCase() ?? "";
}
export function isBusinessHost(hostHeader: string | null | undefined): boolean {
const host = normalizeHost(hostHeader);
if (!host) return false;
if (host === "business.localhost") return true;
return host.startsWith(BUSINESS_PREFIX);
}
export function isCustomerHost(hostHeader: string | null | undefined): boolean {
const host = normalizeHost(hostHeader);
if (!host) return false;
if (host === "customer.localhost") return true;
return host.startsWith(CUSTOMER_PREFIX);
}
export function hostAppFor(hostHeader: string | null | undefined): HostApp {
if (isBusinessHost(hostHeader)) return "business";
if (isCustomerHost(hostHeader)) return "customer";
return "admin";
}
/** @deprecated Prefer hostAppFor + access checks. */
export function expectedDashboardForHost(
hostHeader: string | null | undefined,
): DashboardType {
const app = hostAppFor(hostHeader);
if (app === "business") return "business";
if (app === "customer") return "customer";
return "super_admin";
}
function originFromEnv(
envValue: string | undefined,
fallbackLocal: string,
fallbackProd: (root: string) => string,
): string {
const fromEnv = envValue?.replace(/\/$/, "");
if (fromEnv) {
if (fromEnv.startsWith("http://") || fromEnv.startsWith("https://")) {
return fromEnv;
}
const protocol =
fromEnv.includes("localhost") || fromEnv.startsWith("127.")
? "http"
: "https";
return `${protocol}://${fromEnv}`;
}
const root = process.env.NEXT_PUBLIC_ROOT_DOMAIN || "localhost";
if (root === "localhost" || root.startsWith("127.")) {
return fallbackLocal;
}
return fallbackProd(root);
}
/** Absolute origin for the business dashboard (no trailing slash). */
export function getBusinessDashboardOrigin(): string {
return originFromEnv(
process.env.NEXT_PUBLIC_BUSINESS_DASHBOARD_HOST,
"http://business.localhost:3002",
(root) => `https://business.${root}`,
);
}
/** Absolute origin for the customer dashboard. */
export function getCustomerDashboardOrigin(): string {
return originFromEnv(
process.env.NEXT_PUBLIC_CUSTOMER_DASHBOARD_HOST,
"http://customer.localhost:3002",
(root) => `https://customer.${root}`,
);
}
/** Absolute origin for the superadmin dashboard. */
export function getAdminDashboardOrigin(): string {
return originFromEnv(
process.env.NEXT_PUBLIC_ADMIN_DASHBOARD_HOST,
"http://localhost:3002",
(root) => `https://admin.${root}`,
);
}
/** Map public business URL path → internal /business/* path. */
export function rewriteBusinessPath(pathname: string): string | null {
if (pathname === "/") return "/business";
if (pathname === "/login") return "/login";
if (pathname === "/forgot-password") return "/forgot-password";
if (pathname === "/auth/handoff") return "/auth/handoff";
const known = [
"/profile",
"/products",
"/blog",
"/portfolios",
"/gallery",
] as const;
for (const path of known) {
if (pathname === path || pathname.startsWith(`${path}/`)) {
return `/business${pathname}`;
}
}
return null;
}
/** Map public customer URL path → internal /customer/* path. */
export function rewriteCustomerPath(pathname: string): string | null {
if (pathname === "/") return "/customer";
if (pathname === "/login") return "/login";
if (pathname === "/forgot-password") return "/forgot-password";
if (pathname === "/auth/handoff") return "/auth/handoff";
if (pathname === "/profile" || pathname.startsWith("/profile/")) {
return `/customer${pathname}`;
}
return null;
}
+106
View File
@@ -0,0 +1,106 @@
export const en = {
brandName: "IranBay",
brandSubtitle: "Business",
customerBrandSubtitle: "Account",
workspaceEyebrow: "Business workspace",
workspaceHint: "Manage your catalog",
customerEyebrow: "Customer workspace",
customerHint: "Your account",
viewWebsite: "View website",
openBusinessDashboard: "Business dashboard",
openCustomerDashboard: "Customer dashboard",
openAdminDashboard: "Admin dashboard",
logOut: "Log out",
loading: "Loading",
nav: {
home: "Home",
profile: "Profile",
products: "Products",
blog: "Blog",
portfolios: "Portfolios",
gallery: "Gallery",
},
customerHome: {
title: "Welcome",
lead: "Manage your account from here.",
businessCta: "Open business dashboard",
businessLead: "You own a business on IranBay.",
adminCta: "Open admin dashboard",
adminLead: "Review marketplace listings and approve new shops.",
},
customerProfile: {
title: "Profile",
lead: "Your account details.",
phone: "Phone",
email: "Email",
name: "Name",
emptyEmail: "Not set",
},
profile: {
title: "Profile",
lead: "Update how your business appears on Meshkee.",
},
products: {
title: "Products",
lead: "Your product catalog.",
empty: "No products yet.",
emptyLead: "Add your first product to start building the catalog.",
emptyFiltered: "Nothing in this category.",
emptyFilteredLead: "Try another category, or add a product here.",
add: "Add product",
addTitle: "Add product",
addLead: "Create a product like in Meshkee CMS.",
manageCategories: "Manage categories",
categoriesTitle: "Product categories",
categoriesLead: "Organize products the same way as Meshkee CMS.",
},
blog: {
title: "Blog",
lead: "Articles and updates.",
empty: "No blog posts yet.",
emptyLead: "Publish your first article, news piece, or blog post.",
add: "Add post",
addTitle: "Add blog post",
addLead: "Create a post like in Meshkee CMS.",
manageCategories: "Manage categories",
categoriesTitle: "Blog categories",
categoriesLead: "Organize posts the same way as Meshkee CMS.",
},
portfolios: {
title: "Portfolios",
lead: "Project showcases.",
empty: "No portfolios yet.",
emptyLead: "Showcase a project with images, story, and links.",
add: "Add portfolio",
addTitle: "Add portfolio",
addLead: "Create a portfolio like in Meshkee CMS.",
manageCategories: "Manage categories",
categoriesTitle: "Portfolio categories",
categoriesLead: "Organize portfolios the same way as Meshkee CMS.",
},
gallery: {
title: "Gallery",
lead: "Upload and arrange images for your business.",
empty: "No gallery images yet.",
emptyLead: "Drop images above, or browse files to fill the gallery.",
dropTitle: "Drop images here",
dropHint: "or click to browse — JPG, PNG, WebP, GIF",
uploading: "Uploading…",
add: "Add images",
},
common: {
edit: "Edit",
remove: "Remove",
all: "All",
allInCategory: "All in category",
prev: "Prev",
next: "Next",
comingSoon: "Edit coming soon",
},
} as const;
export type EnMessages = typeof en;
export function t(): EnMessages {
return en;
}
+29
View File
@@ -0,0 +1,29 @@
const MANAGED_BUSINESS_KEY = "marketplace_managed_business_id";
/**
* Superadmin-only: which Meshkee business is open on the shared business.* host.
* Regular business users ignore this and use `user.businesses` instead.
*/
export function getManagedBusinessId(): string | null {
if (typeof window === "undefined") return null;
return (
sessionStorage.getItem(MANAGED_BUSINESS_KEY) ||
localStorage.getItem(MANAGED_BUSINESS_KEY)
);
}
export function setManagedBusinessId(businessId: string | null) {
if (typeof window === "undefined") return;
if (!businessId) {
sessionStorage.removeItem(MANAGED_BUSINESS_KEY);
localStorage.removeItem(MANAGED_BUSINESS_KEY);
return;
}
const id = String(businessId);
sessionStorage.setItem(MANAGED_BUSINESS_KEY, id);
localStorage.setItem(MANAGED_BUSINESS_KEY, id);
}
export function clearManagedBusinessId() {
setManagedBusinessId(null);
}
+185
View File
@@ -0,0 +1,185 @@
const API_BASE_URL =
process.env.NEXT_PUBLIC_MESHKEE_API_URL?.replace(/\/$/, "") ||
"http://localhost:3010/api/v1";
const ACCESS_TOKEN_KEY = "marketplace_access_token";
const REFRESH_TOKEN_KEY = "marketplace_refresh_token";
const TOKEN_MAX_AGE_SEC = 60 * 60 * 24 * 14; // 14 days
export class ApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.name = "ApiError";
this.status = status;
}
}
export function isAbortError(err: unknown): boolean {
return err instanceof DOMException && err.name === "AbortError";
}
/** Share cookies across admin / customer / business hosts on the same root. */
function cookieDomain(): string | undefined {
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 domain = cookieDomain();
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=${domain}${secure}`;
}
function deleteCookie(name: string) {
if (typeof document === "undefined") return;
const domain = cookieDomain();
document.cookie = `${name}=; Path=/; Max-Age=0; SameSite=Lax; Domain=${domain}`;
}
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);
deleteCookie(ACCESS_TOKEN_KEY);
deleteCookie(REFRESH_TOKEN_KEY);
}
/** Promote existing localStorage sessions into shared cookies (once per load). */
export function migrateTokensToSharedCookies() {
if (typeof window === "undefined") return;
const access = localStorage.getItem(ACCESS_TOKEN_KEY);
const refresh = localStorage.getItem(REFRESH_TOKEN_KEY);
if (access && refresh && !readCookie(ACCESS_TOKEN_KEY)) {
writeCookie(ACCESS_TOKEN_KEY, access);
writeCookie(REFRESH_TOKEN_KEY, refresh);
}
}
function parseErrorMessage(payload: unknown, fallback: string) {
if (!payload || typeof payload !== "object") {
return fallback;
}
const message = (payload as { message?: string | string[] }).message;
if (Array.isArray(message)) {
return message.join(", ");
}
if (typeof message === "string") {
return message;
}
return fallback;
}
async function refreshAccessToken() {
const refreshToken = getRefreshToken();
if (!refreshToken) {
return false;
}
const response = await fetch(`${API_BASE_URL}/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
});
if (!response.ok) {
clearTokens();
return false;
}
const data = await response.json();
setTokens(data.accessToken, data.refreshToken);
return true;
}
interface RequestOptions extends Omit<RequestInit, "body"> {
body?: unknown;
auth?: boolean;
}
export async function apiRequest<T>(
path: string,
options: RequestOptions = {},
): Promise<T> {
const { body, auth = false, headers, ...rest } = options;
const requestHeaders = new Headers(headers);
if (body !== undefined) {
requestHeaders.set("Content-Type", "application/json");
}
if (auth) {
const accessToken = getAccessToken();
if (accessToken) {
requestHeaders.set("Authorization", `Bearer ${accessToken}`);
}
}
const response = await fetch(`${API_BASE_URL}${path}`, {
...rest,
headers: requestHeaders,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (response.status === 401 && auth) {
const refreshed = await refreshAccessToken();
if (refreshed) {
return apiRequest<T>(path, options);
}
}
const payload = await response.json().catch(() => null);
if (!response.ok) {
throw new ApiError(
parseErrorMessage(payload, `Request failed with status ${response.status}`),
response.status,
);
}
return payload as T;
}
+100
View File
@@ -0,0 +1,100 @@
import {
getAccessToken,
getRefreshToken,
setTokens,
} from "@/lib/meshkee-api";
import {
getBusinessDashboardOrigin,
getCustomerDashboardOrigin,
} from "@/lib/host";
import {
clearManagedBusinessId,
setManagedBusinessId,
} from "@/lib/managed-business";
/**
* Superadmin “Manage business” — same origin as admin (no subdomain bounce).
* Example: /manage/{meshkeeBusinessId}/profile
*/
export function openBusinessDashboardAsAdmin(
meshkeeBusinessId: string,
nextPath = "/profile",
) {
const id = meshkeeBusinessId.trim();
if (!id) return;
setManagedBusinessId(id);
const path = nextPath.startsWith("/") ? nextPath : `/${nextPath}`;
const suffix = path === "/" ? "/profile" : path;
window.location.assign(`/manage/${encodeURIComponent(id)}${suffix}`);
}
/**
* Business owner / staff → business.* host from their account.
*/
export function openBusinessDashboardAsUser(nextPath = "/profile") {
const access = getAccessToken();
const refresh = getRefreshToken();
if (!access || !refresh) {
window.location.assign(`${getCustomerDashboardOrigin()}/login`);
return;
}
setTokens(access, refresh);
clearManagedBusinessId();
const next = nextPath.startsWith("/") ? nextPath : `/${nextPath}`;
const hash = new URLSearchParams({
access,
refresh,
next,
clearManaged: "1",
}).toString();
window.location.assign(
`${getBusinessDashboardOrigin()}/auth/handoff#${hash}`,
);
}
export function businessDashboardHrefForUser(nextPath = "/profile"): string {
const next = nextPath.startsWith("/") ? nextPath : `/${nextPath}`;
const access = getAccessToken();
const refresh = getRefreshToken();
if (!access || !refresh) {
return `${getCustomerDashboardOrigin()}/login`;
}
const hash = new URLSearchParams({
access,
refresh,
next,
clearManaged: "1",
}).toString();
return `${getBusinessDashboardOrigin()}/auth/handoff#${hash}`;
}
export function businessDashboardHrefForAdmin(
meshkeeBusinessId: string,
nextPath = "/profile",
): string {
const id = meshkeeBusinessId.trim();
if (!id) return "/businesses";
const path = nextPath.startsWith("/") ? nextPath : `/${nextPath}`;
const suffix = path === "/" ? "/profile" : path;
return `/manage/${encodeURIComponent(id)}${suffix}`;
}
export function customerDashboardHref(nextPath = "/"): string {
const next = nextPath.startsWith("/") ? nextPath : `/${nextPath}`;
const access = getAccessToken();
const refresh = getRefreshToken();
if (!access || !refresh) {
return `${getCustomerDashboardOrigin()}${next}`;
}
const hash = new URLSearchParams({
access,
refresh,
next,
}).toString();
return `${getCustomerDashboardOrigin()}/auth/handoff#${hash}`;
}
+28
View File
@@ -0,0 +1,28 @@
import type { AuthUser } from "@/types/auth";
/** Prefer Meshkee English name fields; fall back to cell. */
export function getDisplayName(user: AuthUser | null | undefined): string {
if (!user) return "Admin";
const english = [user.firstNameEn, user.lastNameEn]
.filter(Boolean)
.join(" ")
.trim();
if (english) return english;
const local = [user.firstName, user.lastName].filter(Boolean).join(" ").trim();
if (local && !/[\u0600-\u06FF]/.test(local)) return local;
return user.cellNumber || "User";
}
export function getFirstName(user: AuthUser | null | undefined): string {
if (!user) return "there";
const raw =
user.firstNameEn?.trim() ||
(user.firstName?.trim() && !/[\u0600-\u06FF]/.test(user.firstName)
? user.firstName.trim()
: "");
if (!raw) return "there";
return raw.split(/\s+/)[0] || raw;
}