From f4911ccee9ef4ba184273db31558d99f972e4803 Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Fri, 18 Sep 2026 18:06:31 +0330 Subject: [PATCH] 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 --- src/app/layout.tsx | 153 ++++++++++++++----------- src/components/AuthProvider.tsx | 140 +++++++++++++++++++++++ src/components/Header.tsx | 25 +--- src/components/Providers.tsx | 13 +++ src/components/UserMenu.tsx | 196 ++++++++++++++++++++++++++++++++ src/lib/api.ts | 133 ++++++++++++++++++++++ src/lib/auth-cookies.ts | 138 ++++++++++++++++++++++ 7 files changed, 711 insertions(+), 87 deletions(-) create mode 100644 src/components/AuthProvider.tsx create mode 100644 src/components/Providers.tsx create mode 100644 src/components/UserMenu.tsx create mode 100644 src/lib/auth-cookies.ts diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 105c0e9..e54f22c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 { + 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 ( - {children} + + {children} + ); } diff --git a/src/components/AuthProvider.tsx b/src/components/AuthProvider.tsx new file mode 100644 index 0000000..f8132dd --- /dev/null +++ b/src/components/AuthProvider.tsx @@ -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(null); + +export function AuthProvider({ + children, + businessId = null, +}: { + children: ReactNode; + businessId?: string | null; +}) { + const [user, setUser] = useState(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( + () => ({ + user, + ready, + isAuthenticated: Boolean(user), + isWebsiteAdmin, + getAccessToken, + logout, + }), + [user, ready, isWebsiteAdmin, getAccessToken, logout], + ); + + return {children}; +} + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error("useAuth must be used within AuthProvider"); + return ctx; +} diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 524b708..4efe375 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -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 */}
- + @@ -250,14 +238,7 @@ export function Header() { تماس با ما
- + diff --git a/src/components/Providers.tsx b/src/components/Providers.tsx new file mode 100644 index 0000000..68c48b4 --- /dev/null +++ b/src/components/Providers.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { AuthProvider } from "@/components/AuthProvider"; + +export function Providers({ + children, + businessId = null, +}: { + children: React.ReactNode; + businessId?: string | null; +}) { + return {children}; +} diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx new file mode 100644 index 0000000..0351a56 --- /dev/null +++ b/src/components/UserMenu.tsx @@ -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(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 ( + + ); + } + + const name = displayName(user); + + async function openBusinessAdmin(event: MouseEvent) { + 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 ( +
+ + + {open ? ( + + ) : null} +
+ ); +} + +function UserIcon({ className }: { className?: string }) { + return ( + + + + + ); +} + +function ChevronIcon({ open }: { open: boolean }) { + return ( + + + + ); +} diff --git a/src/lib/api.ts b/src/lib/api.ts index f073811..ee93247 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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 { + return apiFetch(`/tenants/${SITE_DOMAIN}`, { + next: { revalidate: 3600 }, + }); +} + +export async function getFavicon(): Promise { + return apiFetch(`/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): 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( + path: string, + init: RequestInit & { token?: string | null } = {}, +): Promise { + 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; +} + +export async function getMe(token: string): Promise { + 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, + }); +} diff --git a/src/lib/auth-cookies.ts b/src/lib/auth-cookies.ts new file mode 100644 index 0000000..2385e5c --- /dev/null +++ b/src/lib/auth-cookies.ts @@ -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); +}