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
@@ -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.
@@ -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();
|
||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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-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;
|
||||||
|
|||||||
@@ -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) }}
|
||||||
/>
|
/>
|
||||||
|
<AuthProvider>
|
||||||
|
<ToastProvider>
|
||||||
{children}
|
{children}
|
||||||
<LatinDigitsEnhancer />
|
<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 });
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ export default function Home() {
|
|||||||
<>
|
<>
|
||||||
<Header />
|
<Header />
|
||||||
<main>
|
<main>
|
||||||
|
<h1 className="sr-only">رسام تجارت هواران | طراحی و تولید تجهیزات تهویه مطبوع</h1>
|
||||||
<Hero />
|
<Hero />
|
||||||
<Partners />
|
<Partners />
|
||||||
<Products />
|
<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 = {
|
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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
<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
|
||||||
|
|||||||
@@ -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 || "")
|
||||||
|
.replace(
|
||||||
/\bsrc=["']([^"']+)["']/gi,
|
/\bsrc=["']([^"']+)["']/gi,
|
||||||
(_full, src: string) => `src="${resolveMediaUrl(src)}"`,
|
(_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">
|
||||||
|
|||||||
@@ -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,15 +15,14 @@ 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) => {
|
||||||
|
const href = blogPath(article);
|
||||||
|
return (
|
||||||
<article
|
<article
|
||||||
key={article.id}
|
key={article.id}
|
||||||
className="card-shadow overflow-hidden bg-white"
|
className="card-shadow overflow-hidden bg-white"
|
||||||
>
|
>
|
||||||
<a
|
<a href={href} className="block overflow-hidden bg-[#f3f3f3]">
|
||||||
href={`/articles/${article.slug}`}
|
|
||||||
className="block overflow-hidden bg-[#f3f3f3]"
|
|
||||||
>
|
|
||||||
{article.image ? (
|
{article.image ? (
|
||||||
<Image
|
<Image
|
||||||
src={article.image}
|
src={article.image}
|
||||||
@@ -37,13 +37,13 @@ export async function Articles() {
|
|||||||
</a>
|
</a>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<h3 className="mb-3 min-h-14 text-[15px] font-bold leading-7 text-[#232323]">
|
<h3 className="mb-3 min-h-14 text-[15px] font-bold leading-7 text-[#232323]">
|
||||||
<a href={`/articles/${article.slug}`}>{article.title}</a>
|
<a href={href}>{article.title}</a>
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mb-4 line-clamp-3 text-sm leading-7 text-[#777]">
|
<p className="mb-4 line-clamp-3 text-sm leading-7 text-[#777]">
|
||||||
{article.excerpt}
|
{article.excerpt}
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
href={`/articles/${article.slug}`}
|
href={href}
|
||||||
className="inline-flex items-center gap-1 text-sm font-bold text-[#232323]"
|
className="inline-flex items-center gap-1 text-sm font-bold text-[#232323]"
|
||||||
>
|
>
|
||||||
بیشتر بخوانید
|
بیشتر بخوانید
|
||||||
@@ -51,7 +51,8 @@ export async function Articles() {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 text-center">
|
<div className="mt-8 text-center">
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
بیشتر بخوانید
|
بیشتر بخوانید
|
||||||
|
|||||||
@@ -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 { 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();
|
||||||
|
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);
|
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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,26 +94,14 @@ 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>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 lg:hidden">
|
||||||
|
<UserMenu />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex h-10 w-10 items-center justify-center border border-[#ddd] lg:hidden"
|
className="inline-flex h-10 w-10 items-center justify-center border border-[#ddd]"
|
||||||
aria-label="منو"
|
aria-label="منو"
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
>
|
>
|
||||||
@@ -123,6 +112,7 @@ export function Header() {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{open ? (
|
{open ? (
|
||||||
<div className="border-t border-[#eee] bg-white lg:hidden">
|
<div className="border-t border-[#eee] bg-white lg:hidden">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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_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",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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