diff --git a/package.json b/package.json
index 78056bf..f212c8e 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,8 @@
"version": "0.1.0",
"private": true,
"scripts": {
+ "prebuild": "node scripts/generate-sitemap-config.mjs",
+ "generate:sitemap-config": "node scripts/generate-sitemap-config.mjs",
"dev": "next dev",
"build": "next build",
"start": "next start",
diff --git a/public/fonts/iran-yekan-black.ttf b/public/fonts/iran-yekan-black.ttf
new file mode 100644
index 0000000..ec2da33
Binary files /dev/null and b/public/fonts/iran-yekan-black.ttf differ
diff --git a/public/fonts/iran-yekan-bold.ttf b/public/fonts/iran-yekan-bold.ttf
new file mode 100644
index 0000000..6d98764
Binary files /dev/null and b/public/fonts/iran-yekan-bold.ttf differ
diff --git a/public/fonts/iran-yekan-extrabold.ttf b/public/fonts/iran-yekan-extrabold.ttf
new file mode 100644
index 0000000..a2810c5
Binary files /dev/null and b/public/fonts/iran-yekan-extrabold.ttf differ
diff --git a/public/fonts/iran-yekan-light.ttf b/public/fonts/iran-yekan-light.ttf
new file mode 100644
index 0000000..b6114ee
Binary files /dev/null and b/public/fonts/iran-yekan-light.ttf differ
diff --git a/public/fonts/iran-yekan-medium.ttf b/public/fonts/iran-yekan-medium.ttf
new file mode 100644
index 0000000..cc9d9b4
Binary files /dev/null and b/public/fonts/iran-yekan-medium.ttf differ
diff --git a/public/fonts/iran-yekan-regular.ttf b/public/fonts/iran-yekan-regular.ttf
new file mode 100644
index 0000000..c662408
Binary files /dev/null and b/public/fonts/iran-yekan-regular.ttf differ
diff --git a/public/fonts/iran-yekan-thin.ttf b/public/fonts/iran-yekan-thin.ttf
new file mode 100644
index 0000000..93a53a1
Binary files /dev/null and b/public/fonts/iran-yekan-thin.ttf differ
diff --git a/public/fonts/montserrat-bold.ttf b/public/fonts/montserrat-bold.ttf
new file mode 100644
index 0000000..9a425b9
Binary files /dev/null and b/public/fonts/montserrat-bold.ttf differ
diff --git a/public/fonts/montserrat-medium.ttf b/public/fonts/montserrat-medium.ttf
new file mode 100644
index 0000000..db5b1af
Binary files /dev/null and b/public/fonts/montserrat-medium.ttf differ
diff --git a/public/fonts/montserrat-regular.ttf b/public/fonts/montserrat-regular.ttf
new file mode 100644
index 0000000..2a2b2aa
Binary files /dev/null and b/public/fonts/montserrat-regular.ttf differ
diff --git a/public/fonts/montserrat-semibold.ttf b/public/fonts/montserrat-semibold.ttf
new file mode 100644
index 0000000..0ecc667
Binary files /dev/null and b/public/fonts/montserrat-semibold.ttf differ
diff --git a/scripts/generate-sitemap-config.mjs b/scripts/generate-sitemap-config.mjs
new file mode 100644
index 0000000..445c84d
--- /dev/null
+++ b/scripts/generate-sitemap-config.mjs
@@ -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();
diff --git a/src/app/article-details/[id]/page.tsx b/src/app/article-details/[id]/page.tsx
index 54606f5..b563ba3 100644
--- a/src/app/article-details/[id]/page.tsx
+++ b/src/app/article-details/[id]/page.tsx
@@ -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");
}
diff --git a/src/app/articles/[id]/[slug]/page.tsx b/src/app/articles/[id]/[slug]/page.tsx
new file mode 100644
index 0000000..8430bd1
--- /dev/null
+++ b/src/app/articles/[id]/[slug]/page.tsx
@@ -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 (
+ <>
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/app/articles/[id]/page.tsx b/src/app/articles/[id]/page.tsx
new file mode 100644
index 0000000..dae4b0b
--- /dev/null
+++ b/src/app/articles/[id]/page.tsx
@@ -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");
+}
diff --git a/src/app/articles/[slug]/page.tsx b/src/app/articles/[slug]/page.tsx
deleted file mode 100644
index d7294eb..0000000
--- a/src/app/articles/[slug]/page.tsx
+++ /dev/null
@@ -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 (
- <>
-
-
-
-
-
-
- >
- );
-}
diff --git a/src/app/globals.css b/src/app/globals.css
index 74b423a..cc517e3 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -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;
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index c843943..279e589 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -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}
-
+
+
+ {children}
+
+
+