From 00126e5777831d1a580027b97567677dc63d3212 Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Wed, 26 Aug 2026 18:45:00 +0330 Subject: [PATCH] Connect Meshkee-managed imagery and publish sitemap metadata Co-authored-by: Cursor --- .cursor/rules/meshkee-static-images.mdc | 15 ++ CONTEXT.md | 43 +++++ app/about-us/page.tsx | 18 +- app/contact-us/page.tsx | 18 +- app/globals.css | 47 +++++- app/layout.tsx | 21 ++- app/lib/meshkee.ts | 56 +++++++ app/meshkee/sitemap-config.json/route.ts | 104 ++++++++++++ app/meshkee/static-image-slots/route.ts | 40 +++++ app/page.tsx | 202 ++++++++++++++++++++--- 10 files changed, 535 insertions(+), 29 deletions(-) create mode 100644 .cursor/rules/meshkee-static-images.mdc create mode 100644 CONTEXT.md create mode 100644 app/lib/meshkee.ts create mode 100644 app/meshkee/sitemap-config.json/route.ts create mode 100644 app/meshkee/static-image-slots/route.ts diff --git a/.cursor/rules/meshkee-static-images.mdc b/.cursor/rules/meshkee-static-images.mdc new file mode 100644 index 0000000..a6c31b7 --- /dev/null +++ b/.cursor/rules/meshkee-static-images.mdc @@ -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 ``, use localized titles for content and `alt`, and wrap the image in an `` 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. diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..80873bb --- /dev/null +++ b/CONTEXT.md @@ -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`. diff --git a/app/about-us/page.tsx b/app/about-us/page.tsx index f9a25eb..87f6534 100644 --- a/app/about-us/page.tsx +++ b/app/about-us/page.tsx @@ -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() { diff --git a/app/contact-us/page.tsx b/app/contact-us/page.tsx index 5d4ec74..0d8407f 100644 --- a/app/contact-us/page.tsx +++ b/app/contact-us/page.tsx @@ -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() { diff --git a/app/globals.css b/app/globals.css index 87daacb..f1544da 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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; } diff --git a/app/layout.tsx b/app/layout.tsx index 7c884e6..9001ffb 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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({ diff --git a/app/lib/meshkee.ts b/app/lib/meshkee.ts new file mode 100644 index 0000000..4461ad8 --- /dev/null +++ b/app/lib/meshkee.ts @@ -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(":", " / "); +} diff --git a/app/meshkee/sitemap-config.json/route.ts b/app/meshkee/sitemap-config.json/route.ts new file mode 100644 index 0000000..ae87890 --- /dev/null +++ b/app/meshkee/sitemap-config.json/route.ts @@ -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", + }, + }, + ); +} diff --git a/app/meshkee/static-image-slots/route.ts b/app/meshkee/static-image-slots/route.ts new file mode 100644 index 0000000..2433a88 --- /dev/null +++ b/app/meshkee/static-image-slots/route.ts @@ -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", + }, + }, + ); +} diff --git a/app/page.tsx b/app/page.tsx index 87823f2..fb250ae 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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([]); const searchInput = useRef(null); + const slotsByKey = useMemo( + () => new Map(staticImageSlots.map((slot) => [slot.key, slot])), + [staticImageSlots], + ); + + const activeHeroSlides = useMemo(() => { + 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() {
- {heroSlides.map((slide, index) => ( + {activeHeroSlides.map((slide, index) => (
+ className={`hero__slide ${index === activeHeroIndex ? "is-active" : ""}`} + key={`${slide.image}-${index}`} + style={{ aspectRatio: slide.aspectRatio }} + > + {slide.linkUrl ? ( + + {slide.title} + + ) : ( + {slide.title} + )} +
))}
-

{heroSlides[activeSlide].eyebrow}

-

{heroSlides[activeSlide].title}

-

{heroSlides[activeSlide].text}

+

{activeHero.eyebrow}

+

{activeHero.title}

+

{activeHero.text}

مشاهده محصولات @@ -321,7 +415,8 @@ export default function Home() {
- {heroSlides.map((slide, index) => ( + {activeHeroSlides.map((slide, index) => (