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:
co-authored by
Cursor
parent
aaa3078145
commit
e4ca605e93
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 });
|
||||
}
|
||||
@@ -29,6 +29,7 @@ export default function Home() {
|
||||
<>
|
||||
<Header />
|
||||
<main>
|
||||
<h1 className="sr-only">رسام تجارت هواران | طراحی و تولید تجهیزات تهویه مطبوع</h1>
|
||||
<Hero />
|
||||
<Partners />
|
||||
<Products />
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
بیشتر بخوانید
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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))}`;
|
||||
}
|
||||
Reference in New Issue
Block a user