Connect Meshkee-managed imagery and publish sitemap metadata

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-26 18:45:00 +03:30
co-authored by Cursor
parent e664acb854
commit 00126e5777
10 changed files with 535 additions and 29 deletions
+17 -1
View File
@@ -18,7 +18,23 @@ const assetBase = "/assets";
export const metadata: Metadata = {
title: "درباره سامان اندیش | تجربه تخصصی صنعت سیم و کابل",
description:
"با تاریخچه، ارزش‌ها و توانمندی‌های تخصصی شرکت سامان اندیش در تأمین ماشین‌آلات صنعت سیم و کابل آشنا شوید.",
"با تاریخچه، ارزش‌ها و توانمندی‌های سامان اندیش در تأمین ماشین‌آلات، مواد اولیه و خدمات مهندسی صنعت سیم و کابل، از ارزیابی تا راه‌اندازی آشنا شوید.",
alternates: {
canonical: "/about-us",
},
openGraph: {
title: "درباره سامان اندیش | تجربه تخصصی صنعت سیم و کابل",
description:
"بیش از دو دهه تجربه در تأمین ماشین‌آلات و ارائه خدمات مهندسی برای خطوط تولید سیم و کابل.",
url: "/about-us",
type: "website",
images: [
{
url: "/assets/trEfzlNgmz.jpg",
alt: "ماشین‌آلات صنعت سیم و کابل سامان اندیش",
},
],
},
};
export default function AboutUsPage() {
+17 -1
View File
@@ -16,7 +16,23 @@ const assetBase = "/assets";
export const metadata: Metadata = {
title: "تماس با سامان اندیش | مشاوره ماشین‌آلات صنعتی",
description:
"برای مشاوره خرید ماشین‌آلات، خدمات فنی و تأمین مواد اولیه صنعت سیم و کابل با کارشناسان سامان اندیش تماس بگیرید.",
"برای مشاوره خرید ماشین‌آلات، دریافت خدمات فنی مهندسی یا تأمین مواد اولیه صنعت سیم و کابل، با کارشناسان تخصصی سامان اندیش در ارتباط باشید.",
alternates: {
canonical: "/contact-us",
},
openGraph: {
title: "تماس با سامان اندیش | مشاوره ماشین‌آلات صنعتی",
description:
"برای انتخاب تجهیزات و راهکار مناسب خط تولید سیم و کابل با کارشناسان سامان اندیش گفتگو کنید.",
url: "/contact-us",
type: "website",
images: [
{
url: "/assets/ZLxH2tZ4dR.jpg",
alt: "تماس با کارشناسان ماشین‌آلات صنعتی سامان اندیش",
},
],
},
};
export default function ContactUsPage() {
+43 -4
View File
@@ -326,8 +326,6 @@ button {
.hero__slide {
z-index: 0;
background-size: cover;
background-position: center;
opacity: 0;
transform: scale(1.05);
transition:
@@ -335,11 +333,32 @@ button {
transform 7s ease;
}
.hero__slide > img,
.hero__slide-link,
.hero__slide-link > img {
width: 100%;
height: 100%;
}
.hero__slide > img,
.hero__slide-link > img {
object-fit: cover;
}
.hero__slide-link {
display: block;
}
.hero__slide.is-active {
opacity: 1;
transform: scale(1);
}
.hero__overlay,
.hero__pattern {
pointer-events: none;
}
.hero__overlay {
z-index: 1;
background:
@@ -1092,12 +1111,26 @@ button {
min-height: 545px;
}
.about__visual > img {
.about__visual > img,
.about__main-image {
width: calc(100% - 85px);
height: 495px;
}
.about__visual > img,
.about__main-image > img {
object-fit: cover;
}
.about__main-image > img {
width: 100%;
height: 100%;
}
.about__main-image {
display: block;
}
.about__visual::before {
content: "";
position: absolute;
@@ -1128,6 +1161,11 @@ button {
object-fit: cover;
}
.about__small-image > a {
display: block;
height: 100%;
}
.experience-badge {
position: absolute;
right: 29px;
@@ -2015,7 +2053,8 @@ button {
min-height: 405px;
}
.about__visual > img {
.about__visual > img,
.about__main-image {
width: calc(100% - 38px);
height: 355px;
}
+20 -1
View File
@@ -2,9 +2,28 @@ import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
metadataBase: new URL("https://samanandish.com"),
title: "سامان اندیش | ماشین‌آلات صنعت سیم و کابل",
description:
"تامین ماشین‌آلات، مواد اولیه و خدمات فنی مهندسی صنعت سیم و کابل",
"سامان اندیش، تأمین‌کننده تخصصی ماشین‌آلات، مواد اولیه و خدمات فنی مهندسی صنعت سیم و کابل؛ همراه شما از انتخاب تجهیزات تا نصب و راه‌اندازی خط تولید.",
alternates: {
canonical: "/",
},
openGraph: {
title: "سامان اندیش | ماشین‌آلات صنعت سیم و کابل",
description:
"تأمین تخصصی ماشین‌آلات و راهکارهای فنی مهندسی خطوط تولید سیم و کابل، از انتخاب تجهیزات تا نصب و راه‌اندازی.",
url: "/",
siteName: "سامان اندیش",
locale: "fa_IR",
type: "website",
images: [
{
url: "/assets/i8SsUDDMLv.jpg",
alt: "ماشین‌آلات صنعت سیم و کابل سامان اندیش",
},
],
},
};
export default function RootLayout({
+56
View File
@@ -0,0 +1,56 @@
export type StaticImage = {
id: number | string;
url: string;
titleFa?: string | null;
titleEn?: string | null;
subtext?: string | null;
linkUrl?: string | null;
width?: number | null;
height?: number | null;
sortOrder?: number | null;
};
export type StaticImageSlot = {
key: string;
pageKey: string;
kind: "single" | "list";
label: string;
aspectRatio?: string | null;
recommendedWidth?: number | null;
itemCount?: number | null;
images: StaticImage[];
};
type StaticImageSlotsResponse = {
items?: StaticImageSlot[];
};
const API_BASE_URL =
process.env.NEXT_PUBLIC_MESHKEE_API_URL?.replace(/\/$/, "") ??
"https://api.meshkee.com/api/v1";
const WEBSITE_DOMAIN =
process.env.NEXT_PUBLIC_MESHKEE_DOMAIN ?? "samanandish.com";
export async function fetchHomeStaticImageSlots(signal?: AbortSignal) {
const endpoint = `${API_BASE_URL}/tenants/${encodeURIComponent(WEBSITE_DOMAIN)}/website/static-images?pageKey=home`;
const response = await fetch(endpoint, {
headers: { Accept: "application/json" },
signal,
});
if (!response.ok) {
throw new Error(`Static image request failed with ${response.status}`);
}
const payload = (await response.json()) as StaticImageSlotsResponse;
return Array.isArray(payload.items) ? payload.items : [];
}
export function cssAspectRatio(value?: string | null) {
if (!value || !/^\d+(?:\.\d+)?:\d+(?:\.\d+)?$/.test(value)) {
return undefined;
}
return value.replace(":", " / ");
}
+104
View File
@@ -0,0 +1,104 @@
import { readdirSync } from "node:fs";
import { join } from "node:path";
export const dynamic = "force-static";
export const runtime = "nodejs";
type StaticPage = {
path: string;
changefreq: "daily" | "weekly" | "monthly";
priority: number;
};
const EXCLUDED_SEGMENTS = new Set([
"account",
"admin",
"api",
"auth",
"cart",
"checkout",
"login",
"meshkee",
"register",
]);
function collectStaticPagePaths(
directory: string,
routeSegments: string[] = [],
): string[] {
const entries = readdirSync(directory, { withFileTypes: true });
const paths: string[] = [];
if (entries.some((entry) => entry.isFile() && /^page\.[jt]sx?$/.test(entry.name))) {
const path = routeSegments.length ? `/${routeSegments.join("/")}` : "/";
paths.push(path);
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const isRouteGroup = entry.name.startsWith("(") && entry.name.endsWith(")");
const isPrivateFolder = entry.name.startsWith("_");
const isParallelRoute = entry.name.startsWith("@");
const isDynamicRoute = entry.name.includes("[");
if (
isPrivateFolder ||
isParallelRoute ||
isDynamicRoute ||
EXCLUDED_SEGMENTS.has(entry.name)
) {
continue;
}
paths.push(
...collectStaticPagePaths(
join(directory, entry.name),
isRouteGroup ? routeSegments : [...routeSegments, entry.name],
),
);
}
return paths;
}
function toStaticPage(path: string): StaticPage {
if (path === "/") {
return { path, changefreq: "daily", priority: 1 };
}
if (path === "/about-us") {
return { path, changefreq: "monthly", priority: 0.6 };
}
if (path === "/contact-us") {
return { path, changefreq: "monthly", priority: 0.5 };
}
return { path, changefreq: "weekly", priority: 0.5 };
}
export function GET() {
const baseUrl = (
process.env.NEXT_PUBLIC_SITE_URL ||
process.env.SITE_URL ||
"https://samanandish.com"
).replace(/\/$/, "");
const staticPages = collectStaticPagePaths(join(process.cwd(), "app"))
.sort((first, second) => {
if (first === "/") return -1;
if (second === "/") return 1;
return first.localeCompare(second);
})
.map(toStaticPage);
return Response.json(
{ baseUrl, staticPages },
{
headers: {
"Cache-Control": "public, max-age=300, stale-while-revalidate=3600",
},
},
);
}
+40
View File
@@ -0,0 +1,40 @@
const slots = [
{
key: "slider",
label: "اسلایدر صفحه اصلی",
kind: "list",
pageKey: "home",
aspectRatio: "17:5",
itemCount: null,
recommendedWidth: 1920,
},
{
key: "three-categories",
label: "سه دسته‌بندی صفحه اصلی",
kind: "list",
pageKey: "home",
aspectRatio: "3:2",
itemCount: 3,
recommendedWidth: 900,
},
{
key: "about-us-images",
label: "تصاویر درباره ما در صفحه اصلی",
kind: "list",
pageKey: "home",
aspectRatio: "1:1",
itemCount: 2,
recommendedWidth: 900,
},
] as const;
export function GET() {
return Response.json(
{ slots },
{
headers: {
"Cache-Control": "public, max-age=300, stale-while-revalidate=3600",
},
},
);
}
+180 -22
View File
@@ -27,10 +27,24 @@ import {
} from "lucide-react";
import type { FormEvent } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
cssAspectRatio,
fetchHomeStaticImageSlots,
type StaticImageSlot,
} from "./lib/meshkee";
const assetBase = "/assets";
const heroSlides = [
type HeroSlide = {
image: string;
eyebrow: string;
title: string;
text: string;
linkUrl?: string;
aspectRatio?: string;
};
const heroSlides: HeroSlide[] = [
{
image: `${assetBase}/i8SsUDDMLv.jpg`,
eyebrow: "WIRE & CABLE MACHINERY",
@@ -164,15 +178,83 @@ export default function Home() {
const [activeSlide, setActiveSlide] = useState(0);
const [query, setQuery] = useState("");
const [searchOpen, setSearchOpen] = useState(false);
const [staticImageSlots, setStaticImageSlots] = useState<StaticImageSlot[]>([]);
const searchInput = useRef<HTMLInputElement>(null);
const slotsByKey = useMemo(
() => new Map(staticImageSlots.map((slot) => [slot.key, slot])),
[staticImageSlots],
);
const activeHeroSlides = useMemo<HeroSlide[]>(() => {
const slot = slotsByKey.get("slider");
if (!slot?.images.length) return heroSlides;
return slot.images.map((image, index) => {
const fallback = heroSlides[index % heroSlides.length];
return {
image: image.url || fallback.image,
eyebrow: image.titleEn || fallback.eyebrow,
title: image.titleFa || fallback.title,
text: image.subtext || fallback.text,
linkUrl: image.linkUrl || undefined,
aspectRatio: cssAspectRatio(slot.aspectRatio),
};
});
}, [slotsByKey]);
const activeCategories = useMemo(() => {
const slot = slotsByKey.get("three-categories");
return categories.map((fallback, index) => {
const image = slot?.images[index];
if (!image) {
return {
...fallback,
linkUrl: undefined,
aspectRatio: undefined,
};
}
return {
...fallback,
image: image.url || fallback.image,
title: image.titleFa || fallback.title,
en: image.titleEn || fallback.en,
count: image.subtext || fallback.count,
linkUrl: image.linkUrl || undefined,
aspectRatio: cssAspectRatio(slot?.aspectRatio),
};
});
}, [slotsByKey]);
const aboutImages = slotsByKey.get("about-us-images");
const aboutMainImage = aboutImages?.images[0];
const aboutSecondaryImage = aboutImages?.images[1];
const activeHeroIndex = activeSlide % activeHeroSlides.length;
const activeHero = activeHeroSlides[activeHeroIndex];
useEffect(() => {
const controller = new AbortController();
fetchHomeStaticImageSlots(controller.signal)
.then(setStaticImageSlots)
.catch(() => {
// Keep the local image fallbacks when the API is unavailable.
});
return () => controller.abort();
}, []);
useEffect(() => {
if (activeHeroSlides.length <= 1) return;
const timer = window.setInterval(
() => setActiveSlide((current) => (current + 1) % heroSlides.length),
() =>
setActiveSlide((current) => (current + 1) % activeHeroSlides.length),
6500,
);
return () => window.clearInterval(timer);
}, []);
}, [activeHeroSlides.length]);
const results = useMemo(() => {
const normalized = query.trim().toLowerCase();
@@ -281,20 +363,32 @@ export default function Home() {
</header>
<section className="hero">
{heroSlides.map((slide, index) => (
{activeHeroSlides.map((slide, index) => (
<div
className={`hero__slide ${index === activeSlide ? "is-active" : ""}`}
key={slide.image}
style={{ backgroundImage: `url("${slide.image}")` }}
/>
className={`hero__slide ${index === activeHeroIndex ? "is-active" : ""}`}
key={`${slide.image}-${index}`}
style={{ aspectRatio: slide.aspectRatio }}
>
{slide.linkUrl ? (
<a
className="hero__slide-link"
href={slide.linkUrl}
aria-label={slide.title}
>
<img src={slide.image} alt={slide.title} />
</a>
) : (
<img src={slide.image} alt={slide.title} />
)}
</div>
))}
<div className="hero__overlay" />
<div className="hero__pattern" />
<div className="container hero__content">
<div className="hero__copy">
<p className="eyebrow">{heroSlides[activeSlide].eyebrow}</p>
<h1>{heroSlides[activeSlide].title}</h1>
<p className="hero__description">{heroSlides[activeSlide].text}</p>
<p className="eyebrow">{activeHero.eyebrow}</p>
<h1>{activeHero.title}</h1>
<p className="hero__description">{activeHero.text}</p>
<div className="hero__buttons">
<a className="button button--red" href="#products">
مشاهده محصولات
@@ -321,7 +415,8 @@ export default function Home() {
<button
onClick={() =>
setActiveSlide(
(activeSlide - 1 + heroSlides.length) % heroSlides.length,
(activeHeroIndex - 1 + activeHeroSlides.length) %
activeHeroSlides.length,
)
}
aria-label="اسلاید قبلی"
@@ -329,17 +424,19 @@ export default function Home() {
<ChevronRight />
</button>
<div>
{heroSlides.map((slide, index) => (
{activeHeroSlides.map((slide, index) => (
<button
className={index === activeSlide ? "active" : ""}
key={slide.image}
className={index === activeHeroIndex ? "active" : ""}
key={`${slide.image}-${index}`}
onClick={() => setActiveSlide(index)}
aria-label={`اسلاید ${index + 1}`}
/>
))}
</div>
<button
onClick={() => setActiveSlide((activeSlide + 1) % heroSlides.length)}
onClick={() =>
setActiveSlide((activeHeroIndex + 1) % activeHeroSlides.length)
}
aria-label="اسلاید بعدی"
>
<ChevronLeft />
@@ -401,7 +498,7 @@ export default function Home() {
{results.length ? (
results.map((product) => (
<a href="#products" key={product.id}>
<img src={product.image} alt="" />
<img src={product.image} alt={product.title} />
<div>
<strong>{product.title}</strong>
<small dir="ltr">{product.en}</small>
@@ -428,8 +525,17 @@ export default function Home() {
title="سه مسیر، یک راهکار کامل صنعتی"
/>
<div className="category-grid">
{categories.map((category, index) => (
<a className="category-card" href="#" key={category.title}>
{activeCategories.map((category, index) => (
<a
className="category-card"
href={category.linkUrl || "#"}
key={`${category.title}-${index}`}
style={
category.aspectRatio
? { aspectRatio: category.aspectRatio, height: "auto" }
: undefined
}
>
<img src={category.image} alt={category.title} />
<div className="category-card__shade" />
<span className="category-card__number">0{index + 1}</span>
@@ -487,9 +593,61 @@ export default function Home() {
<section className="about" id="about">
<div className="container about__grid">
<div className="about__visual">
<img src={`${assetBase}/trEfzlNgmz.jpg`} alt="تجهیزات صنعتی سامان اندیش" />
<div className="about__small-image">
<img src={`${assetBase}/rhipZeVqzD.jpg`} alt="ماشین‌آلات صنعت کابل" />
{aboutMainImage?.linkUrl ? (
<a
className="about__main-image"
href={aboutMainImage.linkUrl}
style={{
aspectRatio: cssAspectRatio(aboutImages?.aspectRatio),
}}
>
<img
src={aboutMainImage.url}
alt={aboutMainImage.titleFa || "تجهیزات صنعتی سامان اندیش"}
/>
</a>
) : (
<img
src={aboutMainImage?.url || `${assetBase}/trEfzlNgmz.jpg`}
alt={aboutMainImage?.titleFa || "تجهیزات صنعتی سامان اندیش"}
style={
aboutMainImage
? {
aspectRatio: cssAspectRatio(aboutImages?.aspectRatio),
height: "auto",
}
: undefined
}
/>
)}
<div
className="about__small-image"
style={
aboutSecondaryImage
? {
aspectRatio: cssAspectRatio(aboutImages?.aspectRatio),
height: "auto",
}
: undefined
}
>
{aboutSecondaryImage?.linkUrl ? (
<a href={aboutSecondaryImage.linkUrl}>
<img
src={aboutSecondaryImage.url}
alt={aboutSecondaryImage.titleFa || "ماشین‌آلات صنعت کابل"}
/>
</a>
) : (
<img
src={
aboutSecondaryImage?.url || `${assetBase}/rhipZeVqzD.jpg`
}
alt={
aboutSecondaryImage?.titleFa || "ماشین‌آلات صنعت کابل"
}
/>
)}
</div>
<div className="experience-badge">
<strong>۲۵</strong>