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
+2
View File
@@ -3,6 +3,8 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"prebuild": "node scripts/generate-sitemap-config.mjs",
"generate:sitemap-config": "node scripts/generate-sitemap-config.mjs",
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+119
View File
@@ -0,0 +1,119 @@
/**
* Scans app page routes and writes src/lib/sitemap-config.generated.json
* for GET /meshkee/sitemap-config.json (Meshkee sitemap sync).
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const appDir = path.join(root, "src", "app");
const outFile = path.join(root, "src", "lib", "sitemap-config.generated.json");
const EXCLUDE_SEGMENTS = new Set([
"api",
"meshkee",
"admin",
"auth",
"login",
"checkout",
"cart",
"account",
"customer",
// Legacy aliases that only redirect
"blogs",
"projects",
"article-details",
"portfolio-details",
]);
const PRIORITY = {
"/": { changefreq: "daily", priority: 1.0 },
"/products": { changefreq: "weekly", priority: 0.9 },
"/solutions": { changefreq: "weekly", priority: 0.9 },
"/portfolios": { changefreq: "weekly", priority: 0.8 },
"/articles": { changefreq: "weekly", priority: 0.8 },
"/documents": { changefreq: "monthly", priority: 0.7 },
"/catalog": { changefreq: "monthly", priority: 0.7 },
"/about": { changefreq: "monthly", priority: 0.7 },
"/contact": { changefreq: "monthly", priority: 0.7 },
"/ims-policy": { changefreq: "yearly", priority: 0.5 },
};
function websiteDomain() {
const fromEnv = (
process.env.NEXT_PUBLIC_SITE_URL ||
process.env.NEXT_PUBLIC_WEBSITE_DOMAIN ||
process.env.WEBSITE_DOMAIN ||
"www.havaran.com"
)
.replace(/^https?:\/\//, "")
.replace(/\/+$/, "");
return fromEnv || "www.havaran.com";
}
function walkPages(dir, segments = []) {
/** @type {string[]} */
const pages = [];
if (!fs.existsSync(dir)) return pages;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (EXCLUDE_SEGMENTS.has(entry.name)) continue;
if (/^\[.+\]$/.test(entry.name) || /^\(.+\)$/.test(entry.name)) {
if (/^\(.+\)$/.test(entry.name)) {
pages.push(...walkPages(full, segments));
}
continue;
}
pages.push(...walkPages(full, [...segments, entry.name]));
continue;
}
if (
entry.name === "page.tsx" ||
entry.name === "page.ts" ||
entry.name === "page.jsx" ||
entry.name === "page.js"
) {
const routePath = segments.length ? `/${segments.join("/")}` : "/";
pages.push(routePath);
}
}
return pages;
}
function main() {
const paths = [...new Set(walkPages(appDir))].sort((a, b) => {
if (a === "/") return -1;
if (b === "/") return 1;
return a.localeCompare(b);
});
const staticPages = paths.map((routePath) => {
const meta = PRIORITY[routePath] ?? { changefreq: "monthly", priority: 0.5 };
return { path: routePath, ...meta };
});
const config = {
baseUrl: `https://${websiteDomain()}`,
staticPages,
// Blog list lives at /articles (not Meshkee default /blog).
// Portfolio uses Meshkee default /portfolios/{id}/{titleFaSlug} — omit template.
templates: {
blog: "/articles/{id}/{titleSlug}",
},
};
fs.mkdirSync(path.dirname(outFile), { recursive: true });
fs.writeFileSync(outFile, `${JSON.stringify(config, null, 2)}\n`, "utf8");
console.log(
`Wrote ${path.relative(root, outFile)} (${staticPages.length} static pages)`,
);
}
main();
+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 = { type PageProps = {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@@ -6,5 +8,11 @@ type PageProps = {
export default async function ArticleDetailsAliasPage({ params }: PageProps) { export default async function ArticleDetailsAliasPage({ params }: PageProps) {
const { id } = await params; 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-face {
font-family: "iran-yekan"; 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-weight: 400;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
@@ -10,7 +10,7 @@
@font-face { @font-face {
font-family: "iran-yekan"; 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-weight: 500;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
@@ -18,7 +18,7 @@
@font-face { @font-face {
font-family: "iran-yekan"; 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-weight: 700;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
@@ -26,12 +26,44 @@
@font-face { @font-face {
font-family: "iran-yekan"; 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-weight: 800;
font-style: normal; font-style: normal;
font-display: swap; 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 { :root {
--background: #ffffff; --background: #ffffff;
--foreground: #232323; --foreground: #232323;
@@ -56,6 +88,7 @@
--color-brand-navy: var(--brand-navy); --color-brand-navy: var(--brand-navy);
--color-accent-green: var(--accent-green); --color-accent-green: var(--accent-green);
--font-sans: "iran-yekan", Tahoma, sans-serif; --font-sans: "iran-yekan", Tahoma, sans-serif;
--font-en: "Montserrat", Arial, sans-serif;
} }
* { * {
@@ -82,6 +115,23 @@ body {
unicode-bidi: isolate; 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 { a {
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
@@ -121,6 +171,7 @@ img {
.section-head p { .section-head p {
margin: 0; margin: 0;
color: #9a9a9a; color: #9a9a9a;
font-family: "Montserrat", Arial, sans-serif;
font-size: 15px; font-size: 15px;
letter-spacing: 2px; letter-spacing: 2px;
text-transform: uppercase; text-transform: uppercase;
+8 -2
View File
@@ -1,5 +1,7 @@
import type { Metadata, Viewport } from "next"; import type { Metadata, Viewport } from "next";
import { AuthProvider } from "@/components/AuthProvider";
import { LatinDigitsEnhancer } from "@/components/LatinDigits"; import { LatinDigitsEnhancer } from "@/components/LatinDigits";
import { ToastProvider } from "@/components/ToastProvider";
import { import {
DEFAULT_DESCRIPTION, DEFAULT_DESCRIPTION,
DEFAULT_OG_IMAGE, DEFAULT_OG_IMAGE,
@@ -95,8 +97,12 @@ export default function RootLayout({
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/> />
{children} <AuthProvider>
<LatinDigitsEnhancer /> <ToastProvider>
{children}
<LatinDigitsEnhancer />
</ToastProvider>
</AuthProvider>
</body> </body>
</html> </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 /> <Header />
<main> <main>
<h1 className="sr-only">رسام تجارت هواران | طراحی و تولید تجهیزات تهویه مطبوع</h1>
<Hero /> <Hero />
<Partners /> <Partners />
<Products /> <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 = { type PageProps = {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@@ -6,5 +8,11 @@ type PageProps = {
export default async function PortfolioDetailsAliasPage({ params }: PageProps) { export default async function PortfolioDetailsAliasPage({ params }: PageProps) {
const { id } = await params; 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"> <section id="about" className="section">
<div className="container-page"> <div className="container-page">
<div className="mx-auto max-w-3xl border-x border-[#ddd] px-6 py-2 text-center sm:px-12"> <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} {about.title}
</h1> </h2>
<p className="mb-4 leading-8 text-[#666]">{about.paragraphs[0]}</p> <p className="mb-4 leading-8 text-[#666]">{about.paragraphs[0]}</p>
<p className="mb-6 leading-8 text-[#666]">{about.paragraphs[1]}</p> <p className="mb-6 leading-8 text-[#666]">{about.paragraphs[1]}</p>
<a <a
+13 -4
View File
@@ -17,10 +17,19 @@ function formatDate(date: string) {
export function ArticleDetailView({ item }: { item: BlogArticle }) { export function ArticleDetailView({ item }: { item: BlogArticle }) {
const image = resolveMediaUrl(item.image); const image = resolveMediaUrl(item.image);
const contentHtml = (item.contentHtml || "").replace( const contentHtml = (item.contentHtml || "")
/\bsrc=["']([^"']+)["']/gi, .replace(
(_full, src: string) => `src="${resolveMediaUrl(src)}"`, /\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 ( return (
<section className="section"> <section className="section">
+37 -36
View File
@@ -1,5 +1,6 @@
import Image from "next/image"; import Image from "next/image";
import { listBlogs } from "@/lib/meshkee"; import { listBlogs } from "@/lib/meshkee";
import { blogPath } from "@/lib/urls";
export async function Articles() { export async function Articles() {
const { items } = await listBlogs({ page: 1, pageSize: 6, type: "blog" }); const { items } = await listBlogs({ page: 1, pageSize: 6, type: "blog" });
@@ -14,44 +15,44 @@ export async function Articles() {
</div> </div>
<div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3"> <div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
{articles.map((article) => ( {articles.map((article) => {
<article const href = blogPath(article);
key={article.id} return (
className="card-shadow overflow-hidden bg-white" <article
> key={article.id}
<a className="card-shadow overflow-hidden bg-white"
href={`/articles/${article.slug}`}
className="block overflow-hidden bg-[#f3f3f3]"
> >
{article.image ? ( <a href={href} className="block overflow-hidden bg-[#f3f3f3]">
<Image {article.image ? (
src={article.image} <Image
alt={article.title} src={article.image}
width={800} alt={article.title}
height={600} width={800}
unoptimized height={600}
className="h-auto w-full object-contain" unoptimized
sizes="(max-width: 768px) 100vw, 33vw" className="h-auto w-full object-contain"
/> sizes="(max-width: 768px) 100vw, 33vw"
) : null} />
</a> ) : null}
<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> </a>
</div> <div className="p-4">
</article> <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>
<div className="mt-8 text-center"> <div className="mt-8 text-center">
+5 -6
View File
@@ -1,6 +1,7 @@
import Image from "next/image"; import Image from "next/image";
import type { BlogArticle } from "@/data/articles"; import type { BlogArticle } from "@/data/articles";
import { resolveMediaUrl } from "@/lib/meshkee"; import { resolveMediaUrl } from "@/lib/meshkee";
import { blogPath } from "@/lib/urls";
function formatDate(date: string) { function formatDate(date: string) {
if (!date) return ""; 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"> <div className="grid gap-7 sm:grid-cols-2 xl:grid-cols-3">
{items.map((item) => { {items.map((item) => {
const image = resolveMediaUrl(item.image); const image = resolveMediaUrl(item.image);
const href = blogPath(item);
return ( return (
<article <article
key={item.id} 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)]" className="article-card flex h-full flex-col overflow-hidden bg-white shadow-[0_0_10px_rgba(0,0,0,0.05)]"
> >
<a <a href={href} className="block overflow-hidden bg-[#f3f3f3]">
href={`/articles/${item.slug}`}
className="block overflow-hidden bg-[#f3f3f3]"
>
{image ? ( {image ? (
<Image <Image
src={image} src={image}
@@ -57,7 +56,7 @@ export function ArticlesGrid({ items }: { items: BlogArticle[] }) {
</p> </p>
) : null} ) : null}
<h2 className="mb-3 text-[15px] font-bold leading-7 text-[#232323]"> <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} {item.title}
</a> </a>
</h2> </h2>
@@ -66,7 +65,7 @@ export function ArticlesGrid({ items }: { items: BlogArticle[] }) {
</p> </p>
<div className="mt-auto border-t border-[#eee] pt-3"> <div className="mt-auto border-t border-[#eee] pt-3">
<a <a
href={`/articles/${item.slug}`} href={href}
className="inline-flex items-center gap-1 text-[13px] font-medium text-[#232323] hover:text-brand" 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 { Footer } from "@/components/Footer";
import { Header } from "@/components/Header"; import { Header } from "@/components/Header";
import { PageHero } from "@/components/PageShared"; import { PageHero } from "@/components/PageShared";
import { useToast } from "@/components/ToastProvider";
import { contactPage } from "@/data/company"; import { contactPage } from "@/data/company";
import { site } from "@/data/home"; import { site } from "@/data/home";
import { ApiError, submitContact } from "@/lib/meshkee";
function IconShell({ children }: { children: React.ReactNode }) { function IconShell({ children }: { children: React.ReactNode }) {
return ( return (
@@ -87,11 +89,42 @@ function PhoneIcon() {
} }
export function ContactUsView() { export function ContactUsView() {
const { toast } = useToast();
const [sent, setSent] = useState(false); 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(); 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 ( return (
@@ -203,14 +236,17 @@ export function ContactUsView() {
placeholder="متن پیام" placeholder="متن پیام"
className="w-full resize-y border border-[#ddd] bg-white px-4 py-3 text-sm outline-none focus:border-brand-navy" 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"> <button type="submit" className="btn-more" disabled={pending}>
{contactPage.submitLabel} {pending ? "در حال ارسال..." : contactPage.submitLabel}
</button> </button>
{sent ? ( {sent ? (
<p className="text-sm text-accent-green"> <p className="text-sm text-accent-green">
پیام شما ثبت شد. به زودی با شما تماس می‌گیریم. پیام شما ثبت شد. به زودی با شما تماس می‌گیریم.
</p> </p>
) : null} ) : null}
{error ? (
<p className="text-sm text-[#9A0000]">{error}</p>
) : null}
</form> </form>
</div> </div>
+17 -27
View File
@@ -2,6 +2,7 @@
import Image from "next/image"; import Image from "next/image";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { UserMenu } from "@/components/UserMenu";
import { navItems, site } from "@/data/home"; import { navItems, site } from "@/data/home";
export function Header() { export function Header() {
@@ -93,35 +94,24 @@ export function Header() {
<circle cx="17.5" cy="6.5" r="1" fill="currentColor" /> <circle cx="17.5" cy="6.5" r="1" fill="currentColor" />
</svg> </svg>
</a> </a>
<a <UserMenu />
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>
</div> </div>
<button <div className="flex items-center gap-2 lg:hidden">
type="button" <UserMenu />
className="inline-flex h-10 w-10 items-center justify-center border border-[#ddd] lg:hidden" <button
aria-label="منو" type="button"
onClick={() => setOpen((v) => !v)} className="inline-flex h-10 w-10 items-center justify-center border border-[#ddd]"
> aria-label="منو"
<span className="flex w-5 flex-col gap-1.5"> onClick={() => setOpen((v) => !v)}
<span className="h-0.5 w-full bg-[#333]" /> >
<span className="h-0.5 w-full bg-[#333]" /> <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> <span className="h-0.5 w-full bg-[#333]" />
</button> <span className="h-0.5 w-full bg-[#333]" />
</span>
</button>
</div>
</div> </div>
{open ? ( {open ? (
+5 -3
View File
@@ -26,7 +26,7 @@ export function PageHero({ title, titleEn, breadcrumb }: PageHeroProps) {
{title} {title}
</h1> </h1>
{titleEn ? ( {titleEn ? (
<p className="text-sm tracking-[2px] text-[#9a9a9a] uppercase"> <p className="title-en text-sm tracking-[2px] text-[#9a9a9a] uppercase">
{titleEn} {titleEn}
</p> </p>
) : null} ) : null}
@@ -66,7 +66,7 @@ export function CatalogCard({
<h2 className="mb-1 text-[16px] font-bold text-[#232323]"> <h2 className="mb-1 text-[16px] font-bold text-[#232323]">
<a href={href}>{title}</a> <a href={href}>{title}</a>
</h2> </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} {titleEn}
</p> </p>
<p className="mb-5 line-clamp-3 flex-1 text-sm leading-7 text-[#666]"> <p className="mb-5 line-clamp-3 flex-1 text-sm leading-7 text-[#666]">
@@ -123,7 +123,9 @@ export function ContentSection({
{title} {title}
</h2> </h2>
{titleEn ? ( {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} ) : null}
<div className="space-y-3 text-sm leading-8 text-[#666]">{children}</div> <div className="space-y-3 text-sm leading-8 text-[#666]">{children}</div>
</div> </div>
+1 -1
View File
@@ -52,7 +52,7 @@ export function PortfolioDetailView({ item }: { item: PortfolioItem }) {
</p> </p>
) : null} ) : null}
{item.titleEn ? ( {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} {item.titleEn}
</p> </p>
) : null} ) : null}
+5 -2
View File
@@ -1,6 +1,7 @@
import Image from "next/image"; import Image from "next/image";
import type { PortfolioItem } from "@/data/portfolios"; import type { PortfolioItem } from "@/data/portfolios";
import { resolveMediaUrl } from "@/lib/meshkee"; import { resolveMediaUrl } from "@/lib/meshkee";
import { portfolioPath } from "@/lib/urls";
export function PortfolioGrid({ items }: { items: PortfolioItem[] }) { export function PortfolioGrid({ items }: { items: PortfolioItem[] }) {
if (!items.length) { if (!items.length) {
@@ -18,7 +19,7 @@ export function PortfolioGrid({ items }: { items: PortfolioItem[] }) {
return ( return (
<a <a
key={item.id} key={item.id}
href={`/portfolios/${item.slug}`} href={portfolioPath(item)}
className="portfolio-card group" className="portfolio-card group"
> >
<div className="relative aspect-[4/3] overflow-hidden bg-[#f0f0f0]"> <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-overlay" />
<div className="portfolio-card-text"> <div className="portfolio-card-text">
<h2>{item.title}</h2> <h2>{item.title}</h2>
{item.titleEn ? <p>{item.titleEn}</p> : null} {item.titleEn ? (
<p className="title-en">{item.titleEn}</p>
) : null}
</div> </div>
</div> </div>
</a> </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_API_BASE = "https://api.meshkee.com/api/v1";
export const MESHKEE_STORAGE_BASE = export const MESHKEE_STORAGE_BASE =
"https://meshkee-storage.sas.amin.parminstorage.ir"; "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 = { type MeshkeeListResponse = {
items?: unknown[]; 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?: { export async function listPortfolios(options?: {
page?: number; page?: number;
pageSize?: 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 { function normalizeBlog(raw: unknown): BlogArticle | null {
const obj = asRecord(raw); const obj = asRecord(raw);
if (!obj) return null; if (!obj) return null;
@@ -476,3 +672,27 @@ export async function getBlog(
source: "fallback", 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))}`;
}