Connect Meshkee-managed imagery and publish sitemap metadata
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
e664acb854
commit
00126e5777
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
description: Keep Meshkee static-image slots and their storefront catalog synchronized
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Meshkee Static Images
|
||||||
|
|
||||||
|
- Use the canonical contract at `https://api.meshkee.com/docs/website`; do not invent upload or dashboard APIs.
|
||||||
|
- Fetch static-image slots once per page from `/api/v1/tenants/{domain}/website/static-images?pageKey={pageKey}`.
|
||||||
|
- Match placeholders by `slot.key`. Use every image for `kind: "list"` and only the first image for `kind: "single"`.
|
||||||
|
- Render `image.url` in an `<img>`, use localized titles for content and `alt`, and wrap the image in an `<a>` when `linkUrl` is present.
|
||||||
|
- Keep local placeholder images when a slot is empty or the API request fails.
|
||||||
|
- Keep `app/meshkee/static-image-slots/route.ts` synchronized with every static-image placeholder.
|
||||||
|
- Whenever a placeholder's rendered dimensions or crop ratio change, update that slot's `aspectRatio` and `recommendedWidth` in the catalog.
|
||||||
|
- Update `CONTEXT.md` when a prompt changes routes, API integrations, slot keys, or other project architecture.
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
# Saman Andish Website Context
|
||||||
|
|
||||||
|
## Project
|
||||||
|
|
||||||
|
- Persian RTL storefront for Saman Andish, built with Next.js 16, React 19, and TypeScript.
|
||||||
|
- Apex domain: `samanandish.com`.
|
||||||
|
- Local design assets live in `public/assets`; local fonts live in `public/fonts`.
|
||||||
|
- Public routes currently include `/`, `/about-us`, and `/contact-us`.
|
||||||
|
|
||||||
|
## Meshkee API
|
||||||
|
|
||||||
|
- Canonical documentation: `https://api.meshkee.com/docs/website`
|
||||||
|
- API base: `https://api.meshkee.com/api/v1`
|
||||||
|
- Optional client configuration:
|
||||||
|
- `NEXT_PUBLIC_MESHKEE_API_URL`
|
||||||
|
- `NEXT_PUBLIC_MESHKEE_DOMAIN`
|
||||||
|
- `app/lib/meshkee.ts` owns the public static-image contract and homepage fetch.
|
||||||
|
- The homepage fetches `pageKey=home` once, matches slots by key, and keeps local images whenever the API is unavailable or a slot is empty.
|
||||||
|
|
||||||
|
## Homepage Static-Image Slots
|
||||||
|
|
||||||
|
- `slider`: unbounded list, `17:5`, recommended width 1920.
|
||||||
|
- `three-categories`: fixed list of 3, `3:2`, recommended width 900.
|
||||||
|
- `about-us-images`: fixed list of 2, `1:1`, recommended width 900.
|
||||||
|
- The dashboard-importable catalog is served by `GET /meshkee/static-image-slots`.
|
||||||
|
- If a rendered image size or crop ratio changes, update the corresponding catalog entry in `app/meshkee/static-image-slots/route.ts`.
|
||||||
|
|
||||||
|
## Sitemap and SEO
|
||||||
|
|
||||||
|
- Do not add `public/sitemap.xml`, `public/robots.txt`, `app/sitemap.ts`, or `app/robots.ts`; nginx proxies these URLs to Meshkee.
|
||||||
|
- `GET /meshkee/sitemap-config.json` is force-generated during the production build by scanning public static `app/**/page.*` routes.
|
||||||
|
- Auth, account, admin, cart, checkout, dynamic, and Meshkee integration routes are excluded from the static-page config.
|
||||||
|
- The sitemap base URL uses `NEXT_PUBLIC_SITE_URL`, then `SITE_URL`, with `https://samanandish.com` as the fallback.
|
||||||
|
- Product, category, blog, and portfolio detail pages must use Meshkee canonical `{id}/{Persian-title-slug}` URLs when those routes are implemented.
|
||||||
|
- Every crawlable page must have unique metadata, exactly one H1, structured H2/H3 headings, and meaningful image alt text.
|
||||||
|
- After deployment, use Business Dashboard → Website → Settings → Sync sitemap config.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Run `npm run lint`.
|
||||||
|
- Run `npm run build`.
|
||||||
|
- Verify `/meshkee/static-image-slots` returns the three slot definitions.
|
||||||
|
- Verify `/meshkee/sitemap-config.json` lists `/`, `/about-us`, and `/contact-us`.
|
||||||
+17
-1
@@ -18,7 +18,23 @@ const assetBase = "/assets";
|
|||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "درباره سامان اندیش | تجربه تخصصی صنعت سیم و کابل",
|
title: "درباره سامان اندیش | تجربه تخصصی صنعت سیم و کابل",
|
||||||
description:
|
description:
|
||||||
"با تاریخچه، ارزشها و توانمندیهای تخصصی شرکت سامان اندیش در تأمین ماشینآلات صنعت سیم و کابل آشنا شوید.",
|
"با تاریخچه، ارزشها و توانمندیهای سامان اندیش در تأمین ماشینآلات، مواد اولیه و خدمات مهندسی صنعت سیم و کابل، از ارزیابی تا راهاندازی آشنا شوید.",
|
||||||
|
alternates: {
|
||||||
|
canonical: "/about-us",
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
title: "درباره سامان اندیش | تجربه تخصصی صنعت سیم و کابل",
|
||||||
|
description:
|
||||||
|
"بیش از دو دهه تجربه در تأمین ماشینآلات و ارائه خدمات مهندسی برای خطوط تولید سیم و کابل.",
|
||||||
|
url: "/about-us",
|
||||||
|
type: "website",
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: "/assets/trEfzlNgmz.jpg",
|
||||||
|
alt: "ماشینآلات صنعت سیم و کابل سامان اندیش",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AboutUsPage() {
|
export default function AboutUsPage() {
|
||||||
|
|||||||
+17
-1
@@ -16,7 +16,23 @@ const assetBase = "/assets";
|
|||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "تماس با سامان اندیش | مشاوره ماشینآلات صنعتی",
|
title: "تماس با سامان اندیش | مشاوره ماشینآلات صنعتی",
|
||||||
description:
|
description:
|
||||||
"برای مشاوره خرید ماشینآلات، خدمات فنی و تأمین مواد اولیه صنعت سیم و کابل با کارشناسان سامان اندیش تماس بگیرید.",
|
"برای مشاوره خرید ماشینآلات، دریافت خدمات فنی مهندسی یا تأمین مواد اولیه صنعت سیم و کابل، با کارشناسان تخصصی سامان اندیش در ارتباط باشید.",
|
||||||
|
alternates: {
|
||||||
|
canonical: "/contact-us",
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
title: "تماس با سامان اندیش | مشاوره ماشینآلات صنعتی",
|
||||||
|
description:
|
||||||
|
"برای انتخاب تجهیزات و راهکار مناسب خط تولید سیم و کابل با کارشناسان سامان اندیش گفتگو کنید.",
|
||||||
|
url: "/contact-us",
|
||||||
|
type: "website",
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: "/assets/ZLxH2tZ4dR.jpg",
|
||||||
|
alt: "تماس با کارشناسان ماشینآلات صنعتی سامان اندیش",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ContactUsPage() {
|
export default function ContactUsPage() {
|
||||||
|
|||||||
+43
-4
@@ -326,8 +326,6 @@ button {
|
|||||||
|
|
||||||
.hero__slide {
|
.hero__slide {
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
background-size: cover;
|
|
||||||
background-position: center;
|
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
transition:
|
transition:
|
||||||
@@ -335,11 +333,32 @@ button {
|
|||||||
transform 7s ease;
|
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 {
|
.hero__slide.is-active {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero__overlay,
|
||||||
|
.hero__pattern {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.hero__overlay {
|
.hero__overlay {
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
background:
|
background:
|
||||||
@@ -1092,12 +1111,26 @@ button {
|
|||||||
min-height: 545px;
|
min-height: 545px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.about__visual > img {
|
.about__visual > img,
|
||||||
|
.about__main-image {
|
||||||
width: calc(100% - 85px);
|
width: calc(100% - 85px);
|
||||||
height: 495px;
|
height: 495px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about__visual > img,
|
||||||
|
.about__main-image > img {
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.about__main-image > img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about__main-image {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.about__visual::before {
|
.about__visual::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -1128,6 +1161,11 @@ button {
|
|||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.about__small-image > a {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.experience-badge {
|
.experience-badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 29px;
|
right: 29px;
|
||||||
@@ -2015,7 +2053,8 @@ button {
|
|||||||
min-height: 405px;
|
min-height: 405px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.about__visual > img {
|
.about__visual > img,
|
||||||
|
.about__main-image {
|
||||||
width: calc(100% - 38px);
|
width: calc(100% - 38px);
|
||||||
height: 355px;
|
height: 355px;
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-1
@@ -2,9 +2,28 @@ import type { Metadata } from "next";
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
|
metadataBase: new URL("https://samanandish.com"),
|
||||||
title: "سامان اندیش | ماشینآلات صنعت سیم و کابل",
|
title: "سامان اندیش | ماشینآلات صنعت سیم و کابل",
|
||||||
description:
|
description:
|
||||||
"تامین ماشینآلات، مواد اولیه و خدمات فنی مهندسی صنعت سیم و کابل",
|
"سامان اندیش، تأمینکننده تخصصی ماشینآلات، مواد اولیه و خدمات فنی مهندسی صنعت سیم و کابل؛ همراه شما از انتخاب تجهیزات تا نصب و راهاندازی خط تولید.",
|
||||||
|
alternates: {
|
||||||
|
canonical: "/",
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
title: "سامان اندیش | ماشینآلات صنعت سیم و کابل",
|
||||||
|
description:
|
||||||
|
"تأمین تخصصی ماشینآلات و راهکارهای فنی مهندسی خطوط تولید سیم و کابل، از انتخاب تجهیزات تا نصب و راهاندازی.",
|
||||||
|
url: "/",
|
||||||
|
siteName: "سامان اندیش",
|
||||||
|
locale: "fa_IR",
|
||||||
|
type: "website",
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: "/assets/i8SsUDDMLv.jpg",
|
||||||
|
alt: "ماشینآلات صنعت سیم و کابل سامان اندیش",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|||||||
@@ -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(":", " / ");
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -27,10 +27,24 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { FormEvent } from "react";
|
import type { FormEvent } from "react";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
cssAspectRatio,
|
||||||
|
fetchHomeStaticImageSlots,
|
||||||
|
type StaticImageSlot,
|
||||||
|
} from "./lib/meshkee";
|
||||||
|
|
||||||
const assetBase = "/assets";
|
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`,
|
image: `${assetBase}/i8SsUDDMLv.jpg`,
|
||||||
eyebrow: "WIRE & CABLE MACHINERY",
|
eyebrow: "WIRE & CABLE MACHINERY",
|
||||||
@@ -164,15 +178,83 @@ export default function Home() {
|
|||||||
const [activeSlide, setActiveSlide] = useState(0);
|
const [activeSlide, setActiveSlide] = useState(0);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [searchOpen, setSearchOpen] = useState(false);
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
|
const [staticImageSlots, setStaticImageSlots] = useState<StaticImageSlot[]>([]);
|
||||||
const searchInput = useRef<HTMLInputElement>(null);
|
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(() => {
|
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(
|
const timer = window.setInterval(
|
||||||
() => setActiveSlide((current) => (current + 1) % heroSlides.length),
|
() =>
|
||||||
|
setActiveSlide((current) => (current + 1) % activeHeroSlides.length),
|
||||||
6500,
|
6500,
|
||||||
);
|
);
|
||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, []);
|
}, [activeHeroSlides.length]);
|
||||||
|
|
||||||
const results = useMemo(() => {
|
const results = useMemo(() => {
|
||||||
const normalized = query.trim().toLowerCase();
|
const normalized = query.trim().toLowerCase();
|
||||||
@@ -281,20 +363,32 @@ export default function Home() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="hero">
|
<section className="hero">
|
||||||
{heroSlides.map((slide, index) => (
|
{activeHeroSlides.map((slide, index) => (
|
||||||
<div
|
<div
|
||||||
className={`hero__slide ${index === activeSlide ? "is-active" : ""}`}
|
className={`hero__slide ${index === activeHeroIndex ? "is-active" : ""}`}
|
||||||
key={slide.image}
|
key={`${slide.image}-${index}`}
|
||||||
style={{ backgroundImage: `url("${slide.image}")` }}
|
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__overlay" />
|
||||||
<div className="hero__pattern" />
|
<div className="hero__pattern" />
|
||||||
<div className="container hero__content">
|
<div className="container hero__content">
|
||||||
<div className="hero__copy">
|
<div className="hero__copy">
|
||||||
<p className="eyebrow">{heroSlides[activeSlide].eyebrow}</p>
|
<p className="eyebrow">{activeHero.eyebrow}</p>
|
||||||
<h1>{heroSlides[activeSlide].title}</h1>
|
<h1>{activeHero.title}</h1>
|
||||||
<p className="hero__description">{heroSlides[activeSlide].text}</p>
|
<p className="hero__description">{activeHero.text}</p>
|
||||||
<div className="hero__buttons">
|
<div className="hero__buttons">
|
||||||
<a className="button button--red" href="#products">
|
<a className="button button--red" href="#products">
|
||||||
مشاهده محصولات
|
مشاهده محصولات
|
||||||
@@ -321,7 +415,8 @@ export default function Home() {
|
|||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setActiveSlide(
|
setActiveSlide(
|
||||||
(activeSlide - 1 + heroSlides.length) % heroSlides.length,
|
(activeHeroIndex - 1 + activeHeroSlides.length) %
|
||||||
|
activeHeroSlides.length,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
aria-label="اسلاید قبلی"
|
aria-label="اسلاید قبلی"
|
||||||
@@ -329,17 +424,19 @@ export default function Home() {
|
|||||||
<ChevronRight />
|
<ChevronRight />
|
||||||
</button>
|
</button>
|
||||||
<div>
|
<div>
|
||||||
{heroSlides.map((slide, index) => (
|
{activeHeroSlides.map((slide, index) => (
|
||||||
<button
|
<button
|
||||||
className={index === activeSlide ? "active" : ""}
|
className={index === activeHeroIndex ? "active" : ""}
|
||||||
key={slide.image}
|
key={`${slide.image}-${index}`}
|
||||||
onClick={() => setActiveSlide(index)}
|
onClick={() => setActiveSlide(index)}
|
||||||
aria-label={`اسلاید ${index + 1}`}
|
aria-label={`اسلاید ${index + 1}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveSlide((activeSlide + 1) % heroSlides.length)}
|
onClick={() =>
|
||||||
|
setActiveSlide((activeHeroIndex + 1) % activeHeroSlides.length)
|
||||||
|
}
|
||||||
aria-label="اسلاید بعدی"
|
aria-label="اسلاید بعدی"
|
||||||
>
|
>
|
||||||
<ChevronLeft />
|
<ChevronLeft />
|
||||||
@@ -401,7 +498,7 @@ export default function Home() {
|
|||||||
{results.length ? (
|
{results.length ? (
|
||||||
results.map((product) => (
|
results.map((product) => (
|
||||||
<a href="#products" key={product.id}>
|
<a href="#products" key={product.id}>
|
||||||
<img src={product.image} alt="" />
|
<img src={product.image} alt={product.title} />
|
||||||
<div>
|
<div>
|
||||||
<strong>{product.title}</strong>
|
<strong>{product.title}</strong>
|
||||||
<small dir="ltr">{product.en}</small>
|
<small dir="ltr">{product.en}</small>
|
||||||
@@ -428,8 +525,17 @@ export default function Home() {
|
|||||||
title="سه مسیر، یک راهکار کامل صنعتی"
|
title="سه مسیر، یک راهکار کامل صنعتی"
|
||||||
/>
|
/>
|
||||||
<div className="category-grid">
|
<div className="category-grid">
|
||||||
{categories.map((category, index) => (
|
{activeCategories.map((category, index) => (
|
||||||
<a className="category-card" href="#" key={category.title}>
|
<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} />
|
<img src={category.image} alt={category.title} />
|
||||||
<div className="category-card__shade" />
|
<div className="category-card__shade" />
|
||||||
<span className="category-card__number">0{index + 1}</span>
|
<span className="category-card__number">0{index + 1}</span>
|
||||||
@@ -487,9 +593,61 @@ export default function Home() {
|
|||||||
<section className="about" id="about">
|
<section className="about" id="about">
|
||||||
<div className="container about__grid">
|
<div className="container about__grid">
|
||||||
<div className="about__visual">
|
<div className="about__visual">
|
||||||
<img src={`${assetBase}/trEfzlNgmz.jpg`} alt="تجهیزات صنعتی سامان اندیش" />
|
{aboutMainImage?.linkUrl ? (
|
||||||
<div className="about__small-image">
|
<a
|
||||||
<img src={`${assetBase}/rhipZeVqzD.jpg`} alt="ماشینآلات صنعت کابل" />
|
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>
|
||||||
<div className="experience-badge">
|
<div className="experience-badge">
|
||||||
<strong>۲۵</strong>
|
<strong>۲۵</strong>
|
||||||
|
|||||||
Reference in New Issue
Block a user