Add API favicon and customer portal account menu.

Load the tenant favicon from Meshkee API and replace the header login control with shared-auth session UI for profile, logout, and admin website management.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-18 18:06:31 +03:30
co-authored by Cursor
parent e6404eaaec
commit f4911ccee9
7 changed files with 711 additions and 87 deletions
+88 -65
View File
@@ -1,5 +1,7 @@
import type { Metadata } from "next";
import { Montserrat, Vazirmatn } from "next/font/google";
import { Providers } from "@/components/Providers";
import { getFavicon, getTenant } from "@/lib/api";
import "./globals.css";
const vazirmatn = Vazirmatn({
@@ -15,89 +17,110 @@ const montserrat = Montserrat({
});
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "https://www.meshkee.com";
const fallbackIcon = "/images/brand/favicon.png";
export const metadata: Metadata = {
metadataBase: new URL(siteUrl),
title: {
default: "تیم برنامه‌نویسی و تجارت الکترونیک مشکی | Meshkee",
template: "%s | مشکی",
},
description:
"راهکارهای مدرن برای تجارت شما — طراحی وبسایت، اپلیکیشن، سیستم‌های مدیریتی، سئو و گرافیک توسط تیم مشکی.",
applicationName: "Meshkee",
authors: [{ name: "Meshkee", url: siteUrl }],
creator: "Meshkee",
publisher: "Meshkee",
keywords: [
"مشکی",
"Meshkee",
"طراحی وبسایت",
"طراحی اپلیکیشن",
"سئو",
"CMS",
"قم",
],
icons: {
icon: [
{ url: "/favicon.ico", sizes: "any" },
{ url: "/favicon.png", type: "image/png", sizes: "48x48" },
{ url: "/images/brand/favicon.png", type: "image/png", sizes: "512x512" },
],
apple: [{ url: "/apple-icon.png", type: "image/png", sizes: "180x180" }],
shortcut: ["/favicon.ico"],
},
openGraph: {
type: "website",
locale: "fa_IR",
url: siteUrl,
siteName: "Meshkee",
title: "تیم برنامه‌نویسی و تجارت الکترونیک مشکی | Meshkee",
export async function generateMetadata(): Promise<Metadata> {
let icon = fallbackIcon;
try {
const favicon = await getFavicon();
icon = favicon.faviconUrl || favicon.logoUrl || fallbackIcon;
} catch {
// keep local brand favicon
}
return {
metadataBase: new URL(siteUrl),
title: {
default: "تیم برنامه‌نویسی و تجارت الکترونیک مشکی | Meshkee",
template: "%s | مشکی",
},
description:
"راهکارهای مدرن برای تجارت شما — طراحی وبسایت، اپلیکیشن، سیستم‌های مدیریتی، سئو و گرافیک توسط تیم مشکی.",
images: [
{
url: "/images/brand/favicon.png",
width: 512,
height: 512,
alt: "لوگوی مشکی",
},
applicationName: "Meshkee",
authors: [{ name: "Meshkee", url: siteUrl }],
creator: "Meshkee",
publisher: "Meshkee",
keywords: [
"مشکی",
"Meshkee",
"طراحی وبسایت",
"طراحی اپلیکیشن",
"سئو",
"CMS",
"قم",
],
},
twitter: {
card: "summary",
title: "تیم برنامه‌نویسی و تجارت الکترونیک مشکی | Meshkee",
description:
"راهکارهای مدرن برای تجارت شما — طراحی وبسایت، اپلیکیشن، سیستم‌های مدیریتی، سئو و گرافیک توسط تیم مشکی.",
images: ["/images/brand/favicon.png"],
},
robots: {
index: true,
follow: true,
googleBot: {
icons: {
icon: [
{ url: icon },
{ url: "/favicon.ico", sizes: "any" },
{ url: "/favicon.png", type: "image/png", sizes: "48x48" },
],
apple: [{ url: icon }],
shortcut: [icon],
},
openGraph: {
type: "website",
locale: "fa_IR",
url: siteUrl,
siteName: "Meshkee",
title: "تیم برنامه‌نویسی و تجارت الکترونیک مشکی | Meshkee",
description:
"راهکارهای مدرن برای تجارت شما — طراحی وبسایت، اپلیکیشن، سیستم‌های مدیریتی، سئو و گرافیک توسط تیم مشکی.",
images: [
{
url: icon,
width: 512,
height: 512,
alt: "لوگوی مشکی",
},
],
},
twitter: {
card: "summary",
title: "تیم برنامه‌نویسی و تجارت الکترونیک مشکی | Meshkee",
description:
"راهکارهای مدرن برای تجارت شما — طراحی وبسایت، اپلیکیشن، سیستم‌های مدیریتی، سئو و گرافیک توسط تیم مشکی.",
images: [icon],
},
robots: {
index: true,
follow: true,
"max-image-preview": "large",
"max-snippet": -1,
"max-video-preview": -1,
googleBot: {
index: true,
follow: true,
"max-image-preview": "large",
"max-snippet": -1,
"max-video-preview": -1,
},
},
},
alternates: {
canonical: "/",
},
};
alternates: {
canonical: "/",
},
};
}
export default function RootLayout({
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
let businessId: string | null = null;
try {
const tenant = await getTenant();
businessId = String(tenant.id);
} catch {
businessId = null;
}
return (
<html
lang="fa"
dir="rtl"
className={`${vazirmatn.variable} ${montserrat.variable} h-full antialiased`}
>
<body className="min-h-full font-sans text-ink">{children}</body>
<body className="min-h-full font-sans text-ink">
<Providers businessId={businessId}>{children}</Providers>
</body>
</html>
);
}
+140
View File
@@ -0,0 +1,140 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import {
getMe,
isWebsiteAdminForBusiness,
refreshTokens,
type AuthUser,
} from "@/lib/api";
import {
clearLocalAuthTokens,
hydrateLocalTokensFromSharedCookies,
readLocalAccessToken,
readLocalRefreshToken,
writeLocalTokens,
} from "@/lib/auth-cookies";
type AuthContextValue = {
user: AuthUser | null;
ready: boolean;
isAuthenticated: boolean;
isWebsiteAdmin: boolean;
getAccessToken: () => string | null;
logout: () => void;
};
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({
children,
businessId = null,
}: {
children: ReactNode;
businessId?: string | null;
}) {
const [user, setUser] = useState<AuthUser | null>(null);
const [ready, setReady] = useState(false);
const logout = useCallback(() => {
clearLocalAuthTokens();
setUser(null);
}, []);
const getAccessToken = useCallback(() => readLocalAccessToken(), []);
const refreshUser = useCallback(async () => {
hydrateLocalTokensFromSharedCookies();
let accessToken = readLocalAccessToken();
const refreshToken = readLocalRefreshToken();
if (!accessToken && !refreshToken) {
setUser(null);
return;
}
try {
if (!accessToken && refreshToken) {
const tokens = await refreshTokens(refreshToken);
writeLocalTokens(tokens.accessToken, tokens.refreshToken);
accessToken = tokens.accessToken;
}
if (!accessToken) {
setUser(null);
return;
}
const me = await getMe(accessToken);
setUser(me);
} catch {
if (refreshToken) {
try {
const tokens = await refreshTokens(refreshToken);
writeLocalTokens(tokens.accessToken, tokens.refreshToken);
const me = await getMe(tokens.accessToken);
setUser(me);
return;
} catch {
clearLocalAuthTokens();
setUser(null);
}
} else {
clearLocalAuthTokens();
setUser(null);
}
}
}, []);
useEffect(() => {
let cancelled = false;
(async () => {
try {
await refreshUser();
} catch {
if (!cancelled) {
clearLocalAuthTokens();
setUser(null);
}
} finally {
if (!cancelled) setReady(true);
}
})().catch(() => undefined);
return () => {
cancelled = true;
};
}, [refreshUser]);
const isWebsiteAdmin = useMemo(
() => isWebsiteAdminForBusiness(user, businessId),
[user, businessId],
);
const value = useMemo<AuthContextValue>(
() => ({
user,
ready,
isAuthenticated: Boolean(user),
isWebsiteAdmin,
getAccessToken,
logout,
}),
[user, ready, isWebsiteAdmin, getAccessToken, logout],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}
+3 -22
View File
@@ -3,6 +3,7 @@
import Link from "next/link";
import { useState } from "react";
import { Button } from "@/components/Button";
import { UserMenu } from "@/components/UserMenu";
import { nav, site, socials, type NavItem } from "@/data/home";
import { images } from "@/data/assets";
@@ -132,20 +133,7 @@ export function Header() {
{/* Login + CTA on the left */}
<div className="hidden items-center gap-2 md:flex">
<Button href="/login" variant="outline" size="sm">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
className="h-3.5 w-3.5"
aria-hidden
>
<circle cx="12" cy="8" r="4" />
<path d="M4 20c0-4 3.582-7 8-7s8 3 8 7" />
</svg>
ورود
</Button>
<UserMenu />
<Button href="/contact" size="sm" className="px-4">
تماس با ما
</Button>
@@ -250,14 +238,7 @@ export function Header() {
تماس با ما
</Button>
</div>
<Button
href="/login"
variant="outline"
className="w-full py-3"
onClick={() => setOpen(false)}
>
ورود
</Button>
<UserMenu compact />
</div>
</nav>
</div>
+13
View File
@@ -0,0 +1,13 @@
"use client";
import { AuthProvider } from "@/components/AuthProvider";
export function Providers({
children,
businessId = null,
}: {
children: React.ReactNode;
businessId?: string | null;
}) {
return <AuthProvider businessId={businessId}>{children}</AuthProvider>;
}
+196
View File
@@ -0,0 +1,196 @@
"use client";
import { useEffect, useRef, useState, type MouseEvent } from "react";
import { useAuth } from "@/components/AuthProvider";
import { Button } from "@/components/Button";
import {
businessDashboardUrl,
createAuthHandoff,
customerLoginUrl,
customerProfileUrl,
displayName,
} from "@/lib/api";
export function UserMenu({ compact = false }: { compact?: boolean }) {
const { user, ready, isAuthenticated, isWebsiteAdmin, logout, getAccessToken } =
useAuth();
const [open, setOpen] = useState(false);
const [handingOff, setHandingOff] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onClick = (event: Event) => {
if (
rootRef.current &&
event.target instanceof Node &&
!rootRef.current.contains(event.target)
) {
setOpen(false);
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onClick);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("mousedown", onClick);
document.removeEventListener("keydown", onKeyDown);
};
}, [open]);
if (!ready || !isAuthenticated || !user) {
return (
<Button
href={customerLoginUrl("/")}
variant="outline"
size={compact ? "md" : "sm"}
className={compact ? "w-full py-3" : undefined}
aria-label="ورود"
>
<UserIcon className={compact ? "h-4 w-4" : "h-3.5 w-3.5"} />
ورود
</Button>
);
}
const name = displayName(user);
async function openBusinessAdmin(event: MouseEvent<HTMLAnchorElement>) {
if (
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey ||
event.button !== 0
) {
return;
}
event.preventDefault();
if (handingOff) return;
const target = businessDashboardUrl();
const popup = window.open("about:blank", "_blank");
setHandingOff(true);
try {
const token = getAccessToken();
const { ticket } = await createAuthHandoff(token || "");
const url = new URL(target);
if (ticket) url.searchParams.set("handoff", 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(target);
else window.open(target, "_blank", "noopener,noreferrer");
} finally {
setHandingOff(false);
setOpen(false);
}
}
return (
<div ref={rootRef} className={`relative ${compact ? "w-full" : ""}`}>
<button
type="button"
className={`btn-outline inline-flex max-w-[14rem] items-center justify-center gap-1.5 rounded-full font-medium ${
compact ? "w-full px-6 py-3 text-sm" : "px-3.5 py-2 text-xs"
}`}
aria-expanded={open}
aria-haspopup="menu"
onClick={() => setOpen((value) => !value)}
>
<span className="btn-outline-label">
<span className="btn-outline-label-base inline-flex min-w-0 items-center gap-1.5">
<UserIcon className={compact ? "h-4 w-4" : "h-3.5 w-3.5"} />
<span className="truncate">{name} عزیز</span>
<ChevronIcon open={open} />
</span>
<span className="btn-outline-label-grad" aria-hidden>
<UserIcon className={compact ? "h-4 w-4" : "h-3.5 w-3.5"} />
<span className="truncate">{name} عزیز</span>
<ChevronIcon open={open} />
</span>
</span>
</button>
{open ? (
<div
role="menu"
className={`absolute z-50 mt-2 min-w-[12rem] overflow-hidden rounded-2xl border border-line/70 bg-white py-1 shadow-[0_16px_40px_rgba(14,17,22,0.08)] ${
compact ? "inset-inline-0" : "inset-inline-end-0"
}`}
>
<a
role="menuitem"
href={customerProfileUrl()}
className="block px-4 py-2.5 text-sm text-ink-soft transition hover:bg-[#FAFAFA] hover:text-ink"
onClick={() => setOpen(false)}
>
پروفایل من
</a>
{isWebsiteAdmin ? (
<a
role="menuitem"
href={businessDashboardUrl()}
className="block px-4 py-2.5 text-sm text-ink-soft transition hover:bg-[#FAFAFA] hover:text-ink"
onClick={(event) => void openBusinessAdmin(event)}
>
{handingOff ? "در حال انتقال…" : "مدیریت وبسایت"}
</a>
) : null}
<button
type="button"
role="menuitem"
className="block w-full px-4 py-2.5 text-right text-sm text-ink-soft transition hover:bg-[#FAFAFA] hover:text-ink"
onClick={() => {
logout();
setOpen(false);
}}
>
خروج
</button>
</div>
) : null}
</div>
);
}
function UserIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
className={className}
aria-hidden
>
<circle cx="12" cy="8" r="4" />
<path d="M4 20c0-4 3.582-7 8-7s8 3 8 7" />
</svg>
);
}
function ChevronIcon({ open }: { open: boolean }) {
return (
<svg
viewBox="0 0 12 12"
className={`h-2.5 w-2.5 shrink-0 transition ${open ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
strokeWidth="1.6"
aria-hidden
>
<path d="M2.5 4.5 6 8l3.5-3.5" />
</svg>
);
}
+133
View File
@@ -1,6 +1,8 @@
const API_BASE_URL = "https://api.meshkee.com/api/v1";
export const SITE_DOMAIN = "meshkee.com";
export const CUSTOMER_APP_URL = `https://customer.${SITE_DOMAIN}`;
export const BUSINESS_APP_URL = `https://business.${SITE_DOMAIN}`;
export class ApiNotFoundError extends Error {
constructor(path: string) {
@@ -9,6 +11,38 @@ export class ApiNotFoundError extends Error {
}
}
export type Tenant = {
id: string | number;
name?: string | null;
nameFa?: string | null;
domain?: string | null;
logoUrl?: string | null;
faviconUrl?: string | null;
};
export type FaviconInfo = {
faviconUrl?: string | null;
logoUrl?: string | null;
logoDarkUrl?: string | null;
hasDedicatedFavicon?: boolean;
};
export type AuthUser = {
id: string;
firstName?: string | null;
lastName?: string | null;
cellNumber?: string | null;
email?: string | null;
roles?: string[];
isSuperAdmin?: boolean;
businesses?: Array<{
id: string;
name?: string;
isOwner?: boolean;
teamRole?: string | null;
}>;
};
export type BlogAuthor = {
id: string;
firstName: string | null;
@@ -409,3 +443,102 @@ export function authorName(author: BlogAuthor | null | undefined): string | null
const name = [author.firstName, author.lastName].filter(Boolean).join(" ");
return name || null;
}
export async function getTenant(): Promise<Tenant> {
return apiFetch<Tenant>(`/tenants/${SITE_DOMAIN}`, {
next: { revalidate: 3600 },
});
}
export async function getFavicon(): Promise<FaviconInfo> {
return apiFetch<FaviconInfo>(`/tenants/${SITE_DOMAIN}/website/favicon`, {
next: { revalidate: 300 },
});
}
export function customerLoginUrl(redirectPath = "/"): string {
const url = new URL("/login", CUSTOMER_APP_URL);
url.searchParams.set(
"redirect",
redirectPath.startsWith("/") ? redirectPath : `/${redirectPath}`,
);
return url.toString();
}
export function customerProfileUrl(): string {
return `${CUSTOMER_APP_URL}/profile`;
}
export function businessDashboardUrl(): string {
return `${BUSINESS_APP_URL}/`;
}
export function displayName(user: Pick<AuthUser, "firstName" | "lastName" | "cellNumber">): string {
const first = user.firstName?.trim();
if (first) return first;
const last = user.lastName?.trim();
if (last) return last;
return user.cellNumber?.trim() || "کاربر";
}
export function isWebsiteAdminForBusiness(
user: AuthUser | null | undefined,
businessId: string | null | undefined,
): 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)),
),
);
}
async function authRequest<T>(
path: string,
init: RequestInit & { token?: string | null } = {},
): Promise<T> {
const { token, headers, ...rest } = init;
const response = await fetch(`${API_BASE_URL}${path}`, {
...rest,
headers: {
Accept: "application/json",
...(rest.body ? { "Content-Type": "application/json" } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...headers,
},
cache: "no-store",
});
if (!response.ok) {
throw new Error(`Meshkee auth ${response.status}: ${path}`);
}
return response.json() as Promise<T>;
}
export async function getMe(token: string): Promise<AuthUser> {
const data = await authRequest<{ user?: AuthUser } & AuthUser>("/auth/me", {
token,
});
return data.user ?? data;
}
export async function refreshTokens(refreshToken: string) {
return authRequest<{ accessToken: string; refreshToken: string }>(
"/auth/refresh",
{
method: "POST",
body: JSON.stringify({ refreshToken }),
},
);
}
export async function createAuthHandoff(token: string) {
return authRequest<{ ticket?: string }>("/auth/handoff", {
method: "POST",
token,
});
}
+138
View File
@@ -0,0 +1,138 @@
/** Shared auth cookies so meshkee.com + customer.meshkee.com share one login. */
export const ACCESS_TOKEN_KEY = "meshkee_customer_access_token";
export const 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 clearSharedAuthCookies() {
writeCookie(ACCESS_TOKEN_KEY, null);
writeCookie(REFRESH_TOKEN_KEY, null);
}
export function readSharedAccessToken(): string | null {
return readCookie(ACCESS_TOKEN_KEY);
}
export function readSharedRefreshToken(): string | null {
return readCookie(REFRESH_TOKEN_KEY);
}
export function hydrateLocalTokensFromSharedCookies(): boolean {
if (typeof window === "undefined") return false;
if (localStorage.getItem(ACCESS_TOKEN_KEY)) return false;
const access = readSharedAccessToken();
if (!access) return false;
localStorage.setItem(ACCESS_TOKEN_KEY, access);
const refresh = readSharedRefreshToken();
if (refresh) localStorage.setItem(REFRESH_TOKEN_KEY, refresh);
return true;
}
export function clearLocalAuthTokens() {
if (typeof window === "undefined") return;
localStorage.removeItem(ACCESS_TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
clearSharedAuthCookies();
}
export function readLocalAccessToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem(ACCESS_TOKEN_KEY);
}
export function readLocalRefreshToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem(REFRESH_TOKEN_KEY);
}
export function writeLocalTokens(accessToken: string, refreshToken?: string) {
if (typeof window === "undefined") return;
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
if (refreshToken) localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
}