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:
co-authored by
Cursor
parent
e6404eaaec
commit
f4911ccee9
+88
-65
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user