Ship fonts, customer auth menu, contact API, and Meshkee SEO wiring.

Host Iran Yekan/Montserrat locally, link login to customer.havaran.com with a logged-in dropdown, submit contact forms to Meshkee, and publish sitemap-config with canonical id/slug detail URLs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-08 22:53:33 +03:30
co-authored by Cursor
parent aaa3078145
commit e4ca605e93
44 changed files with 1405 additions and 330 deletions
+10 -2
View File
@@ -1,4 +1,6 @@
import { redirect } from "next/navigation";
import { permanentRedirect } from "next/navigation";
import { getBlog, getBlogById } from "@/lib/meshkee";
import { blogPath } from "@/lib/urls";
type PageProps = {
params: Promise<{ id: string }>;
@@ -6,5 +8,11 @@ type PageProps = {
export default async function ArticleDetailsAliasPage({ params }: PageProps) {
const { id } = await params;
redirect(`/articles/${id}`);
const byId = await getBlogById(id);
if (byId.item) permanentRedirect(blogPath(byId.item));
const bySlug = await getBlog(id);
if (bySlug.item) permanentRedirect(blogPath(bySlug.item));
permanentRedirect("/articles");
}
+64
View File
@@ -0,0 +1,64 @@
import { notFound, permanentRedirect } from "next/navigation";
import { Footer } from "@/components/Footer";
import { Header } from "@/components/Header";
import { PageHero } from "@/components/PageShared";
import { ArticleDetailView } from "@/components/ArticleDetail";
import { getBlogById } from "@/lib/meshkee";
import { pageMetadata } from "@/lib/seo";
import { blogPath, blogTitleSlug } from "@/lib/urls";
type PageProps = {
params: Promise<{ id: string; slug: string }>;
};
export async function generateMetadata({ params }: PageProps) {
const { id } = await params;
const { item } = await getBlogById(id);
if (!item) {
return pageMetadata({
title: "مقاله",
description: "مقاله مورد نظر یافت نشد.",
path: `/articles/${id}`,
noIndex: true,
});
}
const description = item.excerpt || item.title;
return pageMetadata({
title: item.title,
description,
path: blogPath(item),
image: item.image || undefined,
});
}
export default async function ArticleDetailPage({ params }: PageProps) {
const { id, slug: slugParam } = await params;
const slug = decodeURIComponent(slugParam);
const { item } = await getBlogById(id);
if (!item) notFound();
const canonicalSlug = blogTitleSlug(item);
if (slug !== canonicalSlug) {
permanentRedirect(blogPath(item));
}
return (
<>
<Header />
<main>
<PageHero
title={item.title}
titleEn="ARTICLE"
breadcrumb={[
{ label: "صفحه اصلی", href: "/" },
{ label: "مقالات", href: "/articles" },
{ label: item.title, href: blogPath(item) },
]}
/>
<ArticleDetailView item={item} />
</main>
<Footer />
</>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { permanentRedirect } from "next/navigation";
import { getBlog, getBlogById } from "@/lib/meshkee";
import { blogPath } from "@/lib/urls";
type PageProps = {
params: Promise<{ id: string }>;
};
/** Legacy `/articles/{id|slug}` → canonical `/articles/{id}/{titleSlug}`. */
export default async function LegacyArticleIdPage({ params }: PageProps) {
const { id } = await params;
const byId = await getBlogById(id);
if (byId.item) permanentRedirect(blogPath(byId.item));
const bySlug = await getBlog(id);
if (bySlug.item) permanentRedirect(blogPath(bySlug.item));
permanentRedirect("/articles");
}
-78
View File
@@ -1,78 +0,0 @@
import { notFound } from "next/navigation";
import { Footer } from "@/components/Footer";
import { Header } from "@/components/Header";
import { PageHero } from "@/components/PageShared";
import { ArticleDetailView } from "@/components/ArticleDetail";
import { articlesFallback } from "@/data/articles";
import { getBlog } from "@/lib/meshkee";
type PageProps = {
params: Promise<{ slug: string }>;
};
export async function generateStaticParams() {
return articlesFallback.map((item) => ({ slug: item.slug }));
}
export async function generateMetadata({ params }: PageProps) {
const { slug } = await params;
const { item } = await getBlog(slug);
if (!item) {
return {
title: "مقاله",
robots: { index: false, follow: true },
};
}
const description = item.excerpt || item.title;
const image = item.image || "/images/brand/logo.png";
return {
title: item.title,
description,
alternates: {
canonical: `/articles/${item.slug}`,
},
openGraph: {
type: "article",
locale: "fa_IR",
url: `/articles/${item.slug}`,
siteName: "رسام تجارت هواران",
title: item.title,
description,
images: [{ url: image, alt: item.title }],
publishedTime: item.date || undefined,
},
twitter: {
card: "summary_large_image",
title: item.title,
description,
images: [image],
},
};
}
export default async function ArticleDetailPage({ params }: PageProps) {
const { slug } = await params;
const { item } = await getBlog(slug);
if (!item) notFound();
return (
<>
<Header />
<main>
<PageHero
title={item.title}
titleEn="ARTICLE"
breadcrumb={[
{ label: "صفحه اصلی", href: "/" },
{ label: "مقالات", href: "/articles" },
{ label: "جزئیات مقاله", href: `/articles/${item.slug}` },
]}
/>
<ArticleDetailView item={item} />
</main>
<Footer />
</>
);
}
+55 -4
View File
@@ -2,7 +2,7 @@
@font-face {
font-family: "iran-yekan";
src: url("https://cdn.meshkee.com/web-builder/fonts/iran-yekan/iran-yekan-regular.ttf");
src: url("/fonts/iran-yekan-regular.ttf") format("truetype");
font-weight: 400;
font-style: normal;
font-display: swap;
@@ -10,7 +10,7 @@
@font-face {
font-family: "iran-yekan";
src: url("https://cdn.meshkee.com/web-builder/fonts/iran-yekan/iran-yekan-medium.ttf");
src: url("/fonts/iran-yekan-medium.ttf") format("truetype");
font-weight: 500;
font-style: normal;
font-display: swap;
@@ -18,7 +18,7 @@
@font-face {
font-family: "iran-yekan";
src: url("https://cdn.meshkee.com/web-builder/fonts/iran-yekan/iran-yekan-bold.ttf");
src: url("/fonts/iran-yekan-bold.ttf") format("truetype");
font-weight: 700;
font-style: normal;
font-display: swap;
@@ -26,12 +26,44 @@
@font-face {
font-family: "iran-yekan";
src: url("https://cdn.meshkee.com/web-builder/fonts/iran-yekan/iran-yekan-extrabold.ttf");
src: url("/fonts/iran-yekan-extrabold.ttf") format("truetype");
font-weight: 800;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Montserrat";
src: url("/fonts/montserrat-regular.ttf") format("truetype");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Montserrat";
src: url("/fonts/montserrat-medium.ttf") format("truetype");
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Montserrat";
src: url("/fonts/montserrat-semibold.ttf") format("truetype");
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Montserrat";
src: url("/fonts/montserrat-bold.ttf") format("truetype");
font-weight: 700;
font-style: normal;
font-display: swap;
}
:root {
--background: #ffffff;
--foreground: #232323;
@@ -56,6 +88,7 @@
--color-brand-navy: var(--brand-navy);
--color-accent-green: var(--accent-green);
--font-sans: "iran-yekan", Tahoma, sans-serif;
--font-en: "Montserrat", Arial, sans-serif;
}
* {
@@ -82,6 +115,23 @@ body {
unicode-bidi: isolate;
}
.title-en,
.font-en {
font-family: "Montserrat", Arial, sans-serif;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
a {
color: inherit;
text-decoration: none;
@@ -121,6 +171,7 @@ img {
.section-head p {
margin: 0;
color: #9a9a9a;
font-family: "Montserrat", Arial, sans-serif;
font-size: 15px;
letter-spacing: 2px;
text-transform: uppercase;
+8 -2
View File
@@ -1,5 +1,7 @@
import type { Metadata, Viewport } from "next";
import { AuthProvider } from "@/components/AuthProvider";
import { LatinDigitsEnhancer } from "@/components/LatinDigits";
import { ToastProvider } from "@/components/ToastProvider";
import {
DEFAULT_DESCRIPTION,
DEFAULT_OG_IMAGE,
@@ -95,8 +97,12 @@ export default function RootLayout({
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
{children}
<LatinDigitsEnhancer />
<AuthProvider>
<ToastProvider>
{children}
<LatinDigitsEnhancer />
</ToastProvider>
</AuthProvider>
</body>
</html>
);
@@ -0,0 +1,17 @@
import sitemapConfig from "@/lib/sitemap-config.generated.json";
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Cache-Control": "public, max-age=60",
"Content-Type": "application/json; charset=utf-8",
};
export async function OPTIONS() {
return new Response(null, { status: 204, headers });
}
export async function GET() {
return Response.json(sitemapConfig, { headers });
}
+1
View File
@@ -29,6 +29,7 @@ export default function Home() {
<>
<Header />
<main>
<h1 className="sr-only">رسام تجارت هواران | طراحی و تولید تجهیزات تهویه مطبوع</h1>
<Hero />
<Partners />
<Products />
+10 -2
View File
@@ -1,4 +1,6 @@
import { redirect } from "next/navigation";
import { permanentRedirect } from "next/navigation";
import { getPortfolio, getPortfolioById } from "@/lib/meshkee";
import { portfolioPath } from "@/lib/urls";
type PageProps = {
params: Promise<{ id: string }>;
@@ -6,5 +8,11 @@ type PageProps = {
export default async function PortfolioDetailsAliasPage({ params }: PageProps) {
const { id } = await params;
redirect(`/portfolios/${id}`);
const byId = await getPortfolioById(id);
if (byId.item) permanentRedirect(portfolioPath(byId.item));
const bySlug = await getPortfolio(id);
if (bySlug.item) permanentRedirect(portfolioPath(bySlug.item));
permanentRedirect("/portfolios");
}
+67
View File
@@ -0,0 +1,67 @@
import { notFound, permanentRedirect } from "next/navigation";
import { Footer } from "@/components/Footer";
import { Header } from "@/components/Header";
import { PageHero } from "@/components/PageShared";
import { PortfolioDetailView } from "@/components/PortfolioDetail";
import { getPortfolioById } from "@/lib/meshkee";
import { pageMetadata } from "@/lib/seo";
import { portfolioPath, portfolioTitleSlug } from "@/lib/urls";
type PageProps = {
params: Promise<{ id: string; slug: string }>;
};
export async function generateMetadata({ params }: PageProps) {
const { id } = await params;
const { item } = await getPortfolioById(id);
if (!item) {
return pageMetadata({
title: "پروژه",
description: "پروژه مورد نظر یافت نشد.",
path: `/portfolios/${id}`,
noIndex: true,
});
}
const description =
item.description?.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim() ||
`نمونه کار ${item.title} — شرکت رسام تجارت هواران`;
return pageMetadata({
title: item.title,
description: description.slice(0, 160),
path: portfolioPath(item),
image: item.coverImage || item.image || undefined,
});
}
export default async function PortfolioDetailPage({ params }: PageProps) {
const { id, slug: slugParam } = await params;
const slug = decodeURIComponent(slugParam);
const { item } = await getPortfolioById(id);
if (!item) notFound();
const canonicalSlug = portfolioTitleSlug(item);
if (slug !== canonicalSlug) {
permanentRedirect(portfolioPath(item));
}
return (
<>
<Header />
<main>
<PageHero
title={item.title}
titleEn={item.titleEn || "PROJECT DETAILS"}
breadcrumb={[
{ label: "صفحه اصلی", href: "/" },
{ label: "پروژه ها", href: "/portfolios" },
{ label: item.title, href: portfolioPath(item) },
]}
/>
<PortfolioDetailView item={item} />
</main>
<Footer />
</>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { permanentRedirect } from "next/navigation";
import { getPortfolio, getPortfolioById } from "@/lib/meshkee";
import { portfolioPath } from "@/lib/urls";
type PageProps = {
params: Promise<{ id: string }>;
};
/** Legacy `/portfolios/{id|slug}` → canonical `/portfolios/{id}/{titleFaSlug}`. */
export default async function LegacyPortfolioIdPage({ params }: PageProps) {
const { id } = await params;
const byId = await getPortfolioById(id);
if (byId.item) permanentRedirect(portfolioPath(byId.item));
const bySlug = await getPortfolio(id);
if (bySlug.item) permanentRedirect(portfolioPath(bySlug.item));
permanentRedirect("/portfolios");
}
-78
View File
@@ -1,78 +0,0 @@
import { notFound } from "next/navigation";
import { Footer } from "@/components/Footer";
import { Header } from "@/components/Header";
import { PageHero } from "@/components/PageShared";
import { PortfolioDetailView } from "@/components/PortfolioDetail";
import { portfoliosFallback } from "@/data/portfolios";
import { getPortfolio } from "@/lib/meshkee";
type PageProps = {
params: Promise<{ slug: string }>;
};
export async function generateStaticParams() {
return portfoliosFallback.map((item) => ({ slug: item.slug }));
}
export async function generateMetadata({ params }: PageProps) {
const { slug } = await params;
const { item } = await getPortfolio(slug);
if (!item) {
return {
title: "پروژه",
robots: { index: false, follow: true },
};
}
const description =
item.description || `نمونه کار ${item.title} — شرکت رسام تجارت هواران`;
const image = item.coverImage || item.image || "/images/brand/logo.png";
return {
title: item.title,
description,
alternates: {
canonical: `/portfolios/${item.slug}`,
},
openGraph: {
type: "website",
locale: "fa_IR",
url: `/portfolios/${item.slug}`,
siteName: "رسام تجارت هواران",
title: item.title,
description,
images: [{ url: image, alt: item.title }],
},
twitter: {
card: "summary_large_image",
title: item.title,
description,
images: [image],
},
};
}
export default async function PortfolioDetailPage({ params }: PageProps) {
const { slug } = await params;
const { item } = await getPortfolio(slug);
if (!item) notFound();
return (
<>
<Header />
<main>
<PageHero
title={item.title}
titleEn={item.titleEn || "PROJECT DETAILS"}
breadcrumb={[
{ label: "صفحه اصلی", href: "/" },
{ label: "پروژه ها", href: "/portfolios" },
{ label: "جزئیات نمونه کار", href: `/portfolios/${item.slug}` },
]}
/>
<PortfolioDetailView item={item} />
</main>
<Footer />
</>
);
}
-16
View File
@@ -1,16 +0,0 @@
import type { MetadataRoute } from "next";
import { SITE_URL } from "@/lib/seo";
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: ["/api/", "/blogs", "/projects", "/article-details/", "/portfolio-details/"],
},
],
sitemap: `${SITE_URL}/sitemap.xml`,
host: SITE_URL,
};
}
-63
View File
@@ -1,63 +0,0 @@
import type { MetadataRoute } from "next";
import { articlesFallback } from "@/data/articles";
import { productsList, solutionsList } from "@/data/pages";
import { portfoliosFallback } from "@/data/portfolios";
import { SITE_URL } from "@/lib/seo";
const staticRoutes: Array<{
path: string;
changeFrequency: MetadataRoute.Sitemap[number]["changeFrequency"];
priority: number;
}> = [
{ path: "/", changeFrequency: "weekly", priority: 1 },
{ path: "/products", changeFrequency: "weekly", priority: 0.9 },
{ path: "/solutions", changeFrequency: "weekly", priority: 0.9 },
{ path: "/portfolios", changeFrequency: "weekly", priority: 0.8 },
{ path: "/articles", changeFrequency: "weekly", priority: 0.8 },
{ path: "/documents", changeFrequency: "monthly", priority: 0.7 },
{ path: "/catalog", changeFrequency: "monthly", priority: 0.7 },
{ path: "/about", changeFrequency: "monthly", priority: 0.7 },
{ path: "/contact", changeFrequency: "monthly", priority: 0.7 },
{ path: "/ims-policy", changeFrequency: "yearly", priority: 0.5 },
];
export default function sitemap(): MetadataRoute.Sitemap {
const lastModified = new Date();
const pages = staticRoutes.map((route) => ({
url: `${SITE_URL}${route.path === "/" ? "" : route.path}`,
lastModified,
changeFrequency: route.changeFrequency,
priority: route.priority,
}));
const products = productsList.map((item) => ({
url: `${SITE_URL}${item.href}`,
lastModified,
changeFrequency: "monthly" as const,
priority: 0.8,
}));
const solutions = solutionsList.map((item) => ({
url: `${SITE_URL}${item.href}`,
lastModified,
changeFrequency: "monthly" as const,
priority: 0.75,
}));
const articles = articlesFallback.map((item) => ({
url: `${SITE_URL}/articles/${item.slug}`,
lastModified: item.date ? new Date(item.date) : lastModified,
changeFrequency: "monthly" as const,
priority: 0.6,
}));
const portfolios = portfoliosFallback.map((item) => ({
url: `${SITE_URL}/portfolios/${item.slug}`,
lastModified,
changeFrequency: "monthly" as const,
priority: 0.6,
}));
return [...pages, ...products, ...solutions, ...articles, ...portfolios];
}
+2 -2
View File
@@ -5,9 +5,9 @@ export function About() {
<section id="about" className="section">
<div className="container-page">
<div className="mx-auto max-w-3xl border-x border-[#ddd] px-6 py-2 text-center sm:px-12">
<h1 className="mb-5 text-2xl font-bold text-[#232323] sm:text-[28px]">
<h2 className="mb-5 text-2xl font-bold text-[#232323] sm:text-[28px]">
{about.title}
</h1>
</h2>
<p className="mb-4 leading-8 text-[#666]">{about.paragraphs[0]}</p>
<p className="mb-6 leading-8 text-[#666]">{about.paragraphs[1]}</p>
<a
+13 -4
View File
@@ -17,10 +17,19 @@ function formatDate(date: string) {
export function ArticleDetailView({ item }: { item: BlogArticle }) {
const image = resolveMediaUrl(item.image);
const contentHtml = (item.contentHtml || "").replace(
/\bsrc=["']([^"']+)["']/gi,
(_full, src: string) => `src="${resolveMediaUrl(src)}"`,
);
const contentHtml = (item.contentHtml || "")
.replace(
/\bsrc=["']([^"']+)["']/gi,
(_full, src: string) => `src="${resolveMediaUrl(src)}"`,
)
.replace(/<img\b([^>]*?)>/gi, (full, attrs: string) => {
if (/\balt\s*=\s*["'][^"']+["']/i.test(attrs)) return full;
if (/\balt\s*=\s*["']\s*["']/i.test(attrs)) {
return `<img${attrs.replace(/\balt\s*=\s*["']\s*["']/i, `alt="${item.title}"`)}>`;
}
return `<img alt="${item.title}"${attrs}>`;
});
return (
<section className="section">
+37 -36
View File
@@ -1,5 +1,6 @@
import Image from "next/image";
import { listBlogs } from "@/lib/meshkee";
import { blogPath } from "@/lib/urls";
export async function Articles() {
const { items } = await listBlogs({ page: 1, pageSize: 6, type: "blog" });
@@ -14,44 +15,44 @@ export async function Articles() {
</div>
<div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
{articles.map((article) => (
<article
key={article.id}
className="card-shadow overflow-hidden bg-white"
>
<a
href={`/articles/${article.slug}`}
className="block overflow-hidden bg-[#f3f3f3]"
{articles.map((article) => {
const href = blogPath(article);
return (
<article
key={article.id}
className="card-shadow overflow-hidden bg-white"
>
{article.image ? (
<Image
src={article.image}
alt={article.title}
width={800}
height={600}
unoptimized
className="h-auto w-full object-contain"
sizes="(max-width: 768px) 100vw, 33vw"
/>
) : null}
</a>
<div className="p-4">
<h3 className="mb-3 min-h-14 text-[15px] font-bold leading-7 text-[#232323]">
<a href={`/articles/${article.slug}`}>{article.title}</a>
</h3>
<p className="mb-4 line-clamp-3 text-sm leading-7 text-[#777]">
{article.excerpt}
</p>
<a
href={`/articles/${article.slug}`}
className="inline-flex items-center gap-1 text-sm font-bold text-[#232323]"
>
بیشتر بخوانید
<span aria-hidden>›</span>
<a href={href} className="block overflow-hidden bg-[#f3f3f3]">
{article.image ? (
<Image
src={article.image}
alt={article.title}
width={800}
height={600}
unoptimized
className="h-auto w-full object-contain"
sizes="(max-width: 768px) 100vw, 33vw"
/>
) : null}
</a>
</div>
</article>
))}
<div className="p-4">
<h3 className="mb-3 min-h-14 text-[15px] font-bold leading-7 text-[#232323]">
<a href={href}>{article.title}</a>
</h3>
<p className="mb-4 line-clamp-3 text-sm leading-7 text-[#777]">
{article.excerpt}
</p>
<a
href={href}
className="inline-flex items-center gap-1 text-sm font-bold text-[#232323]"
>
بیشتر بخوانید
<span aria-hidden>›</span>
</a>
</div>
</article>
);
})}
</div>
<div className="mt-8 text-center">
+5 -6
View File
@@ -1,6 +1,7 @@
import Image from "next/image";
import type { BlogArticle } from "@/data/articles";
import { resolveMediaUrl } from "@/lib/meshkee";
import { blogPath } from "@/lib/urls";
function formatDate(date: string) {
if (!date) return "";
@@ -28,15 +29,13 @@ export function ArticlesGrid({ items }: { items: BlogArticle[] }) {
<div className="grid gap-7 sm:grid-cols-2 xl:grid-cols-3">
{items.map((item) => {
const image = resolveMediaUrl(item.image);
const href = blogPath(item);
return (
<article
key={item.id}
className="article-card flex h-full flex-col overflow-hidden bg-white shadow-[0_0_10px_rgba(0,0,0,0.05)]"
>
<a
href={`/articles/${item.slug}`}
className="block overflow-hidden bg-[#f3f3f3]"
>
<a href={href} className="block overflow-hidden bg-[#f3f3f3]">
{image ? (
<Image
src={image}
@@ -57,7 +56,7 @@ export function ArticlesGrid({ items }: { items: BlogArticle[] }) {
</p>
) : null}
<h2 className="mb-3 text-[15px] font-bold leading-7 text-[#232323]">
<a href={`/articles/${item.slug}`} className="hover:text-brand">
<a href={href} className="hover:text-brand">
{item.title}
</a>
</h2>
@@ -66,7 +65,7 @@ export function ArticlesGrid({ items }: { items: BlogArticle[] }) {
</p>
<div className="mt-auto border-t border-[#eee] pt-3">
<a
href={`/articles/${item.slug}`}
href={href}
className="inline-flex items-center gap-1 text-[13px] font-medium text-[#232323] hover:text-brand"
>
بیشتر بخوانید
+200
View File
@@ -0,0 +1,200 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import {
BUSINESS_URL,
CUSTOMER_URL,
MESHKEE_DOMAIN,
displayName,
getMe,
getTenant,
isWebsiteAdmin,
refreshAuth,
type MeshkeeUser,
} from "@/lib/meshkee";
import {
clearSharedAuthCookies,
hydrateLocalTokensFromSharedCookies,
SHARED_ACCESS_TOKEN_KEY,
SHARED_REFRESH_TOKEN_KEY,
writeSharedAuthCookies,
} from "@/lib/shared-auth-cookie";
type AuthContextValue = {
user: MeshkeeUser | null;
loading: boolean;
isAdmin: boolean;
label: string;
customerUrl: string;
businessUrl: string;
profileUrl: string;
loginUrl: string;
logout: () => void;
refresh: () => Promise<void>;
};
const AuthContext = createContext<AuthContextValue | null>(null);
const TOKEN_KEYS = {
access: SHARED_ACCESS_TOKEN_KEY,
refresh: SHARED_REFRESH_TOKEN_KEY,
} as const;
function readToken(key: string) {
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
function writeTokens(access?: string | null, refresh?: string | null) {
try {
if (access) localStorage.setItem(TOKEN_KEYS.access, access);
else localStorage.removeItem(TOKEN_KEYS.access);
if (refresh) localStorage.setItem(TOKEN_KEYS.refresh, refresh);
else localStorage.removeItem(TOKEN_KEYS.refresh);
if (access) writeSharedAuthCookies(access, refresh || undefined);
else clearSharedAuthCookies();
} catch {
/* ignore */
}
}
function captureTokensFromUrl() {
if (typeof window === "undefined") return;
const url = new URL(window.location.href);
const hash = new URLSearchParams(url.hash.replace(/^#/, ""));
const access =
url.searchParams.get("accessToken") ||
url.searchParams.get("token") ||
hash.get("accessToken") ||
hash.get("token");
const refresh =
url.searchParams.get("refreshToken") || hash.get("refreshToken");
if (!access && !refresh) return;
writeTokens(access, refresh);
url.searchParams.delete("accessToken");
url.searchParams.delete("refreshToken");
url.searchParams.delete("token");
url.hash = "";
window.history.replaceState({}, "", `${url.pathname}${url.search}`);
}
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<MeshkeeUser | null>(null);
const [loading, setLoading] = useState(true);
const [businessId, setBusinessId] = useState<string | number | null>(null);
const loginUrl = useMemo(() => {
const returnTo =
typeof window !== "undefined"
? window.location.origin
: `https://www.${MESHKEE_DOMAIN}`;
return `${CUSTOMER_URL}?redirect=${encodeURIComponent(returnTo)}`;
}, []);
const refresh = useCallback(async () => {
captureTokensFromUrl();
hydrateLocalTokensFromSharedCookies(TOKEN_KEYS.access, TOKEN_KEYS.refresh);
const access = readToken(TOKEN_KEYS.access);
const refreshToken = readToken(TOKEN_KEYS.refresh);
if (!access && !refreshToken) {
setUser(null);
setLoading(false);
return;
}
try {
let token = access;
if (!token && refreshToken) {
const refreshed = await refreshAuth(refreshToken);
writeTokens(refreshed.accessToken, refreshed.refreshToken);
token = refreshed.accessToken;
if (refreshed.user) {
setUser(refreshed.user);
setLoading(false);
return;
}
}
if (!token) {
setUser(null);
return;
}
const me = await getMe(token);
setUser(me);
} catch {
if (refreshToken) {
try {
const refreshed = await refreshAuth(refreshToken);
writeTokens(refreshed.accessToken, refreshed.refreshToken);
const me = refreshed.user || (await getMe(refreshed.accessToken));
setUser(me);
return;
} catch {
writeTokens(null, null);
setUser(null);
}
} else {
writeTokens(null, null);
setUser(null);
}
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const tenant = await getTenant();
if (!cancelled) setBusinessId(tenant.id);
} catch {
/* ignore */
}
if (!cancelled) await refresh();
})();
return () => {
cancelled = true;
};
}, [refresh]);
const logout = useCallback(() => {
writeTokens(null, null);
setUser(null);
}, []);
const value: AuthContextValue = {
user,
loading,
isAdmin: user ? isWebsiteAdmin(user, businessId) : false,
label: user ? `${displayName(user)} عزیز` : "",
customerUrl: CUSTOMER_URL,
businessUrl: BUSINESS_URL,
profileUrl: `${CUSTOMER_URL}/profile`,
loginUrl,
logout,
refresh,
};
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;
}
+40 -4
View File
@@ -4,8 +4,10 @@ import { FormEvent, useState } from "react";
import { Footer } from "@/components/Footer";
import { Header } from "@/components/Header";
import { PageHero } from "@/components/PageShared";
import { useToast } from "@/components/ToastProvider";
import { contactPage } from "@/data/company";
import { site } from "@/data/home";
import { ApiError, submitContact } from "@/lib/meshkee";
function IconShell({ children }: { children: React.ReactNode }) {
return (
@@ -87,11 +89,42 @@ function PhoneIcon() {
}
export function ContactUsView() {
const { toast } = useToast();
const [sent, setSent] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
function onSubmit(e: FormEvent<HTMLFormElement>) {
async function onSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setSent(true);
setError("");
setPending(true);
const form = e.currentTarget;
const data = new FormData(form);
const name = String(data.get("name") || "").trim();
const email = String(data.get("email") || "").trim();
const subject = String(data.get("subject") || "").trim();
const text = String(data.get("text") || "").trim();
try {
await submitContact({
title: subject || `پیام تماس از ${name}`,
name,
email: email || undefined,
text,
});
form.reset();
setSent(true);
toast("پیام شما با موفقیت ارسال شد.", "success");
} catch (err) {
const msg =
err instanceof ApiError
? err.message
: "ارسال پیام ناموفق بود. دوباره تلاش کنید.";
setError(msg);
toast(msg, "error");
} finally {
setPending(false);
}
}
return (
@@ -203,14 +236,17 @@ export function ContactUsView() {
placeholder="متن پیام"
className="w-full resize-y border border-[#ddd] bg-white px-4 py-3 text-sm outline-none focus:border-brand-navy"
/>
<button type="submit" className="btn-more">
{contactPage.submitLabel}
<button type="submit" className="btn-more" disabled={pending}>
{pending ? "در حال ارسال..." : contactPage.submitLabel}
</button>
{sent ? (
<p className="text-sm text-accent-green">
پیام شما ثبت شد. به زودی با شما تماس می‌گیریم.
</p>
) : null}
{error ? (
<p className="text-sm text-[#9A0000]">{error}</p>
) : null}
</form>
</div>
+17 -27
View File
@@ -2,6 +2,7 @@
import Image from "next/image";
import { useEffect, useState } from "react";
import { UserMenu } from "@/components/UserMenu";
import { navItems, site } from "@/data/home";
export function Header() {
@@ -93,35 +94,24 @@ export function Header() {
<circle cx="17.5" cy="6.5" r="1" fill="currentColor" />
</svg>
</a>
<a
href="#"
className="inline-flex items-center gap-2 rounded border border-[#ddd] px-3 py-1.5 text-sm text-[#333]"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path
d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4M10 17l5-5-5-5M15 12H3"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
ورود
</a>
<UserMenu />
</div>
<button
type="button"
className="inline-flex h-10 w-10 items-center justify-center border border-[#ddd] lg:hidden"
aria-label="منو"
onClick={() => setOpen((v) => !v)}
>
<span className="flex w-5 flex-col gap-1.5">
<span className="h-0.5 w-full bg-[#333]" />
<span className="h-0.5 w-full bg-[#333]" />
<span className="h-0.5 w-full bg-[#333]" />
</span>
</button>
<div className="flex items-center gap-2 lg:hidden">
<UserMenu />
<button
type="button"
className="inline-flex h-10 w-10 items-center justify-center border border-[#ddd]"
aria-label="منو"
onClick={() => setOpen((v) => !v)}
>
<span className="flex w-5 flex-col gap-1.5">
<span className="h-0.5 w-full bg-[#333]" />
<span className="h-0.5 w-full bg-[#333]" />
<span className="h-0.5 w-full bg-[#333]" />
</span>
</button>
</div>
</div>
{open ? (
+5 -3
View File
@@ -26,7 +26,7 @@ export function PageHero({ title, titleEn, breadcrumb }: PageHeroProps) {
{title}
</h1>
{titleEn ? (
<p className="text-sm tracking-[2px] text-[#9a9a9a] uppercase">
<p className="title-en text-sm tracking-[2px] text-[#9a9a9a] uppercase">
{titleEn}
</p>
) : null}
@@ -66,7 +66,7 @@ export function CatalogCard({
<h2 className="mb-1 text-[16px] font-bold text-[#232323]">
<a href={href}>{title}</a>
</h2>
<p className="mb-3 text-xs font-semibold tracking-wide text-[#999]">
<p className="title-en mb-3 text-xs font-semibold tracking-wide text-[#999]">
{titleEn}
</p>
<p className="mb-5 line-clamp-3 flex-1 text-sm leading-7 text-[#666]">
@@ -123,7 +123,9 @@ export function ContentSection({
{title}
</h2>
{titleEn ? (
<p className="mb-4 text-sm tracking-wide text-[#999]">{titleEn}</p>
<p className="title-en mb-4 text-sm tracking-wide text-[#999]">
{titleEn}
</p>
) : null}
<div className="space-y-3 text-sm leading-8 text-[#666]">{children}</div>
</div>
+1 -1
View File
@@ -52,7 +52,7 @@ export function PortfolioDetailView({ item }: { item: PortfolioItem }) {
</p>
) : null}
{item.titleEn ? (
<p className="mt-4 text-sm tracking-wide text-[#999]">
<p className="title-en mt-4 text-sm tracking-wide text-[#999]">
{item.titleEn}
</p>
) : null}
+5 -2
View File
@@ -1,6 +1,7 @@
import Image from "next/image";
import type { PortfolioItem } from "@/data/portfolios";
import { resolveMediaUrl } from "@/lib/meshkee";
import { portfolioPath } from "@/lib/urls";
export function PortfolioGrid({ items }: { items: PortfolioItem[] }) {
if (!items.length) {
@@ -18,7 +19,7 @@ export function PortfolioGrid({ items }: { items: PortfolioItem[] }) {
return (
<a
key={item.id}
href={`/portfolios/${item.slug}`}
href={portfolioPath(item)}
className="portfolio-card group"
>
<div className="relative aspect-[4/3] overflow-hidden bg-[#f0f0f0]">
@@ -35,7 +36,9 @@ export function PortfolioGrid({ items }: { items: PortfolioItem[] }) {
<div className="portfolio-card-overlay" />
<div className="portfolio-card-text">
<h2>{item.title}</h2>
{item.titleEn ? <p>{item.titleEn}</p> : null}
{item.titleEn ? (
<p className="title-en">{item.titleEn}</p>
) : null}
</div>
</div>
</a>
+66
View File
@@ -0,0 +1,66 @@
"use client";
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
} from "react";
type ToastTone = "success" | "error" | "info";
type ToastItem = {
id: number;
message: string;
tone: ToastTone;
};
type ToastContextValue = {
toast: (message: string, tone?: ToastTone) => void;
};
const ToastContext = createContext<ToastContextValue | null>(null);
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [items, setItems] = useState<ToastItem[]>([]);
const toast = useCallback((message: string, tone: ToastTone = "info") => {
const id = Date.now() + Math.random();
setItems((prev) => [...prev, { id, message, tone }]);
window.setTimeout(() => {
setItems((prev) => prev.filter((item) => item.id !== id));
}, 4200);
}, []);
const value = useMemo(() => ({ toast }), [toast]);
return (
<ToastContext.Provider value={value}>
{children}
<div className="pointer-events-none fixed bottom-6 left-1/2 z-[100] flex w-[min(92vw,420px)] -translate-x-1/2 flex-col gap-2">
{items.map((item) => (
<div
key={item.id}
className={`pointer-events-auto rounded-md px-4 py-3 text-sm shadow-lg ${
item.tone === "success"
? "bg-brand-navy text-white"
: item.tone === "error"
? "bg-[#9A0000] text-white"
: "bg-white text-[#232323] ring-1 ring-black/10"
}`}
role="status"
>
{item.message}
</div>
))}
</div>
</ToastContext.Provider>
);
}
export function useToast() {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error("useToast must be used within ToastProvider");
return ctx;
}
+134
View File
@@ -0,0 +1,134 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useAuth } from "@/components/AuthProvider";
export function UserMenu() {
const {
user,
loading,
label,
isAdmin,
loginUrl,
profileUrl,
businessUrl,
logout,
} = useAuth();
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const onDoc = (e: MouseEvent) => {
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onDoc);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDoc);
document.removeEventListener("keydown", onKey);
};
}, []);
if (loading) {
return (
<span className="inline-flex h-9 min-w-[4.5rem] items-center justify-center text-sm text-[#999]">
...
</span>
);
}
if (!user) {
return (
<a
href={loginUrl}
aria-label="ورود"
className="inline-flex items-center gap-2 rounded border border-[#ddd] px-3 py-1.5 text-sm text-[#333] transition hover:border-brand-navy hover:text-brand-navy"
>
<LoginIcon />
ورود
</a>
);
}
return (
<div ref={rootRef} className="relative">
<button
type="button"
className="inline-flex max-w-[200px] items-center gap-2 rounded border border-[#ddd] px-3 py-1.5 text-sm text-[#333] transition hover:border-brand-navy hover:text-brand-navy"
aria-expanded={open}
aria-haspopup="menu"
onClick={() => setOpen((v) => !v)}
>
<UserIcon />
<span className="truncate">{label}</span>
</button>
{open ? (
<div
role="menu"
className="absolute top-full left-0 z-50 mt-2 min-w-[180px] border border-[#eee] bg-white py-1 shadow-[0_12px_40px_rgba(0,0,0,0.12)]"
>
<a
role="menuitem"
href={profileUrl}
className="block px-4 py-2.5 text-[13px] text-[#333] transition hover:bg-[#f7f7f7]"
onClick={() => setOpen(false)}
>
پروفایل من
</a>
{isAdmin ? (
<a
role="menuitem"
href={businessUrl}
className="block px-4 py-2.5 text-[13px] text-[#333] transition hover:bg-[#f7f7f7]"
onClick={() => setOpen(false)}
>
مدیریت وبسایت
</a>
) : null}
<button
type="button"
role="menuitem"
className="block w-full px-4 py-2.5 text-start text-[13px] text-[#333] transition hover:bg-[#f7f7f7]"
onClick={() => {
setOpen(false);
logout();
}}
>
خروج
</button>
</div>
) : null}
</div>
);
}
function LoginIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden>
<path
d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4M10 17l5-5-5-5M15 12H3"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function UserIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden>
<circle cx="12" cy="8" r="3.5" stroke="currentColor" strokeWidth="1.6" />
<path
d="M5 19.5c1.8-3.2 4.2-4.5 7-4.5s5.2 1.3 7 4.5"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
);
}
+220
View File
@@ -13,6 +13,38 @@ export const MESHKEE_DOMAIN = "havaran.com";
export const MESHKEE_API_BASE = "https://api.meshkee.com/api/v1";
export const MESHKEE_STORAGE_BASE =
"https://meshkee-storage.sas.amin.parminstorage.ir";
export const CUSTOMER_URL = `https://customer.${MESHKEE_DOMAIN}`;
export const BUSINESS_URL = `https://business.${MESHKEE_DOMAIN}`;
export type MeshkeeUser = {
id?: string | number;
firstName?: string | null;
lastName?: string | null;
name?: string | null;
email?: string | null;
cellNumber?: string | null;
type?: string | null;
role?: string | null;
roles?: string[] | null;
isAdmin?: boolean;
isBusinessAdmin?: boolean;
isSuperAdmin?: boolean;
businesses?: Array<{
id?: string | number;
role?: string | null;
teamRole?: string | null;
isOwner?: boolean;
domain?: string | null;
}> | null;
};
export type ContactSubmissionBody = {
title: string;
name: string;
email?: string;
cellNumber?: string;
text: string;
};
type MeshkeeListResponse = {
items?: unknown[];
@@ -243,6 +275,146 @@ async function meshkeeGet<T>(path: string): Promise<T | null> {
}
}
export class ApiError extends Error {
status: number;
body: unknown;
constructor(status: number, body: unknown, message?: string) {
super(message || `API ${status}`);
this.status = status;
this.body = body;
}
}
async function meshkeeFetchJson<T>(
path: string,
options?: {
method?: string;
body?: unknown;
token?: string | null;
cache?: RequestCache;
},
): Promise<T> {
const headers: HeadersInit = { Accept: "application/json" };
if (options?.body !== undefined) {
headers["Content-Type"] = "application/json";
}
if (options?.token) {
headers.Authorization = `Bearer ${options.token}`;
}
const res = await fetch(`${MESHKEE_API_BASE}${path}`, {
method: options?.method || "GET",
headers,
body:
options?.body === undefined ? undefined : JSON.stringify(options.body),
cache: options?.cache,
});
const text = await res.text();
let data: unknown = null;
if (text) {
try {
data = JSON.parse(text);
} catch {
data = text;
}
}
if (!res.ok) {
const message =
typeof data === "object" && data && "message" in data
? Array.isArray((data as { message: unknown }).message)
? (data as { message: string[] }).message.join(", ")
: String((data as { message: unknown }).message)
: `Request failed (${res.status})`;
throw new ApiError(res.status, data, message);
}
return data as T;
}
export function displayName(user: MeshkeeUser | null | undefined) {
if (!user) return "کاربر";
const full = [user.firstName, user.lastName].filter(Boolean).join(" ").trim();
if (full) return full;
if (user.name?.trim()) return user.name.trim();
if (user.cellNumber) return user.cellNumber;
if (user.email) return user.email;
return "کاربر";
}
export function isWebsiteAdmin(
user: MeshkeeUser | null | undefined,
businessId?: string | number | null,
) {
if (!user) return false;
if (user.isAdmin || user.isBusinessAdmin || user.isSuperAdmin) return true;
const role = (user.role || user.type || "").toLowerCase();
if (
["admin", "owner", "business_admin", "business-admin", "staff", "super_admin"].includes(
role,
)
) {
return true;
}
if (
user.roles?.some((r) =>
["admin", "owner", "business_admin", "super_admin"].includes(
String(r).toLowerCase(),
),
)
) {
return true;
}
if (!user.businesses?.length) return false;
return user.businesses.some((b) => {
const br = String(b.role || b.teamRole || "").toLowerCase();
const isAdminRole =
Boolean(b.isOwner) || ["admin", "owner", "manager"].includes(br);
if (!businessId) return isAdminRole;
return String(b.id) === String(businessId) && isAdminRole;
});
}
export async function getTenant() {
return meshkeeFetchJson<{ id: string | number; nameFa?: string; name?: string }>(
`/tenants/${MESHKEE_DOMAIN}`,
{ cache: "no-store" },
);
}
export async function getMe(token: string) {
const data = await meshkeeFetchJson<{ user?: MeshkeeUser } & MeshkeeUser>(
"/auth/me",
{ token, cache: "no-store" },
);
return "user" in data && data.user ? data.user : (data as MeshkeeUser);
}
export async function refreshAuth(refreshToken: string) {
return meshkeeFetchJson<{
user?: MeshkeeUser;
accessToken: string;
refreshToken: string;
}>("/auth/refresh", {
method: "POST",
body: { refreshToken },
cache: "no-store",
});
}
export async function submitContact(body: ContactSubmissionBody) {
return meshkeeFetchJson<{ message?: string }>(
`/tenants/${MESHKEE_DOMAIN}/contact-submissions`,
{
method: "POST",
body,
cache: "no-store",
},
);
}
export async function listPortfolios(options?: {
page?: number;
pageSize?: number;
@@ -317,6 +489,30 @@ export async function getPortfolio(
};
}
export async function getPortfolioById(
id: string,
): Promise<{ item: PortfolioItem | null; source: "api" | "fallback" }> {
const data = await meshkeeGet<MeshkeePortfolioResponse | Record<string, unknown>>(
`/tenants/${MESHKEE_DOMAIN}/portfolios/by-id/${encodeURIComponent(id)}`,
);
const raw = asRecord(data)?.portfolio ?? data;
const fromApi = normalizePortfolio(raw);
if (fromApi && (fromApi.title || fromApi.image || fromApi.gallery.length)) {
return { item: fromApi, source: "api" };
}
const fallback =
portfoliosFallback.find((item) => item.id === id) ||
getPortfolioBySlug(id) ||
null;
return {
item: fallback ? normalizeFallbackPortfolio(fallback) : null,
source: "fallback",
};
}
function normalizeBlog(raw: unknown): BlogArticle | null {
const obj = asRecord(raw);
if (!obj) return null;
@@ -476,3 +672,27 @@ export async function getBlog(
source: "fallback",
};
}
export async function getBlogById(
id: string,
): Promise<{ item: BlogArticle | null; source: "api" | "fallback" }> {
const data = await meshkeeGet<MeshkeeBlogResponse | Record<string, unknown>>(
`/tenants/${MESHKEE_DOMAIN}/blogs/by-id/${encodeURIComponent(id)}`,
);
const raw = asRecord(data)?.blog ?? data;
const fromApi = normalizeBlog(raw);
if (fromApi && (fromApi.title || fromApi.contentHtml || fromApi.excerpt)) {
return { item: fromApi, source: "api" };
}
const fallback =
articlesFallback.find((item) => item.id === id) ||
getArticleBySlug(id) ||
null;
return {
item: fallback ? normalizeFallbackBlog(fallback) : null,
source: "fallback",
};
}
+123
View File
@@ -0,0 +1,123 @@
/** Shared auth cookies so storefront + customer.{domain} share one login. */
export const SHARED_ACCESS_TOKEN_KEY = "meshkee_customer_access_token";
export const SHARED_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 writeSharedAuthCookies(accessToken: string, refreshToken?: string) {
writeCookie(SHARED_ACCESS_TOKEN_KEY, accessToken);
if (refreshToken) writeCookie(SHARED_REFRESH_TOKEN_KEY, refreshToken);
}
export function clearSharedAuthCookies() {
writeCookie(SHARED_ACCESS_TOKEN_KEY, null);
writeCookie(SHARED_REFRESH_TOKEN_KEY, null);
}
export function readSharedAccessToken(): string | null {
return readCookie(SHARED_ACCESS_TOKEN_KEY);
}
export function readSharedRefreshToken(): string | null {
return readCookie(SHARED_REFRESH_TOKEN_KEY);
}
export function hydrateLocalTokensFromSharedCookies(
accessKey = SHARED_ACCESS_TOKEN_KEY,
refreshKey = SHARED_REFRESH_TOKEN_KEY,
): boolean {
if (typeof window === "undefined") return false;
if (localStorage.getItem(accessKey)) return false;
const access = readSharedAccessToken();
if (!access) return false;
localStorage.setItem(accessKey, access);
const refresh = readSharedRefreshToken();
if (refresh) localStorage.setItem(refreshKey, refresh);
return true;
}
+108
View File
@@ -0,0 +1,108 @@
{
"baseUrl": "https://www.havaran.com",
"staticPages": [
{
"path": "/",
"changefreq": "daily",
"priority": 1
},
{
"path": "/about",
"changefreq": "monthly",
"priority": 0.7
},
{
"path": "/articles",
"changefreq": "weekly",
"priority": 0.8
},
{
"path": "/catalog",
"changefreq": "monthly",
"priority": 0.7
},
{
"path": "/contact",
"changefreq": "monthly",
"priority": 0.7
},
{
"path": "/documents",
"changefreq": "monthly",
"priority": 0.7
},
{
"path": "/ims-policy",
"changefreq": "yearly",
"priority": 0.5
},
{
"path": "/portfolios",
"changefreq": "weekly",
"priority": 0.8
},
{
"path": "/products",
"changefreq": "weekly",
"priority": 0.9
},
{
"path": "/products/air-conditioner",
"changefreq": "monthly",
"priority": 0.5
},
{
"path": "/products/chiller",
"changefreq": "monthly",
"priority": 0.5
},
{
"path": "/products/f300",
"changefreq": "monthly",
"priority": 0.5
},
{
"path": "/products/fancoil",
"changefreq": "monthly",
"priority": 0.5
},
{
"path": "/products/havaran-fan",
"changefreq": "monthly",
"priority": 0.5
},
{
"path": "/solutions",
"changefreq": "weekly",
"priority": 0.9
},
{
"path": "/solutions/building-installations",
"changefreq": "monthly",
"priority": 0.5
},
{
"path": "/solutions/building-smoke-management",
"changefreq": "monthly",
"priority": 0.5
},
{
"path": "/solutions/hvac",
"changefreq": "monthly",
"priority": 0.5
},
{
"path": "/solutions/tunnels-and-subways",
"changefreq": "monthly",
"priority": 0.5
},
{
"path": "/solutions/ventilation-spaces",
"changefreq": "monthly",
"priority": 0.5
}
],
"templates": {
"blog": "/articles/{id}/{titleSlug}"
}
}
+15
View File
@@ -0,0 +1,15 @@
/** Public URL slug: keep Farsi letters, Latin, digits; spaces → -; strip other punctuation. */
export function slugifyFa(text: string, fallback = ""): string {
const slug = text
.trim()
.normalize("NFC")
.replace(/\s+/g, "-")
.replace(
/[^\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFFa-zA-Z0-9-]+/g,
"",
)
.replace(/-+/g, "-")
.replace(/^-+|-+$/g, "");
return slug || fallback;
}
+23
View File
@@ -0,0 +1,23 @@
import type { BlogArticle } from "@/data/articles";
import type { PortfolioItem } from "@/data/portfolios";
import { slugifyFa } from "@/lib/slugify";
export function blogTitleSlug(item: Pick<BlogArticle, "title">): string {
return slugifyFa(item.title, "article");
}
export function blogPath(item: Pick<BlogArticle, "id" | "title">): string {
return `/articles/${item.id}/${encodeURIComponent(blogTitleSlug(item))}`;
}
export function portfolioTitleSlug(
item: Pick<PortfolioItem, "title" | "titleEn">,
): string {
return slugifyFa(item.title || item.titleEn || "", "project");
}
export function portfolioPath(
item: Pick<PortfolioItem, "id" | "title" | "titleEn">,
): string {
return `/portfolios/${item.id}/${encodeURIComponent(portfolioTitleSlug(item))}`;
}