diff --git a/CONTEXT.md b/CONTEXT.md index 80873bb..e06de88 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -5,7 +5,7 @@ - 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`. +- Public routes include `/`, `/about-us`, `/contact-us`, `/products`, canonical product details, and canonical product-category listings. ## Meshkee API @@ -16,6 +16,9 @@ - `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. +- The same library owns public product/category types, level-two category selection, Persian URL slugs, latest-product fetching, and detail lookups. +- The header product dropdown skips root categories and lists only level-two product categories. +- The homepage latest-machinery section resolves the newest published Meshkee products and links cards to canonical details. ## Homepage Static-Image Slots @@ -32,6 +35,8 @@ - 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. +- Product details use `/products/{id}/{nameFaSlug}` and resolve with `GET /tenants/samanandish.com/products/by-id/{id}`. +- Category listings use `/products/category/{id}/{nameFaSlug}` and resolve with the category-by-id endpoint before filtering products by `categoryId`. - 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. @@ -40,4 +45,5 @@ - 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`. +- Verify `/meshkee/sitemap-config.json` lists `/`, `/about-us`, `/contact-us`, and `/products`. +- Verify the header dropdown shows only level-two categories and product cards open canonical detail URLs. diff --git a/app/api/meshkee/categories/route.ts b/app/api/meshkee/categories/route.ts new file mode 100644 index 0000000..741ce9a --- /dev/null +++ b/app/api/meshkee/categories/route.ts @@ -0,0 +1,17 @@ +import { fetchProductCategories } from "../../../lib/meshkee"; + +export async function GET() { + try { + const items = await fetchProductCategories(); + return Response.json( + { items }, + { + headers: { + "Cache-Control": "public, max-age=300, stale-while-revalidate=3600", + }, + }, + ); + } catch { + return Response.json({ items: [] }, { status: 502 }); + } +} diff --git a/app/api/meshkee/latest-products/route.ts b/app/api/meshkee/latest-products/route.ts new file mode 100644 index 0000000..c2ad565 --- /dev/null +++ b/app/api/meshkee/latest-products/route.ts @@ -0,0 +1,23 @@ +import type { NextRequest } from "next/server"; +import { fetchLatestProducts } from "../../../lib/meshkee"; + +export async function GET(request: NextRequest) { + const requestedCount = Number(request.nextUrl.searchParams.get("count")); + const count = Number.isInteger(requestedCount) + ? Math.min(Math.max(requestedCount, 1), 24) + : 4; + + try { + const items = await fetchLatestProducts(count); + return Response.json( + { items }, + { + headers: { + "Cache-Control": "public, max-age=300, stale-while-revalidate=3600", + }, + }, + ); + } catch { + return Response.json({ items: [] }, { status: 502 }); + } +} diff --git a/app/components/HeaderNavigation.tsx b/app/components/HeaderNavigation.tsx new file mode 100644 index 0000000..24719ce --- /dev/null +++ b/app/components/HeaderNavigation.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { ChevronDown, ChevronLeft } from "lucide-react"; +import Link from "next/link"; +import { useEffect, useMemo, useState } from "react"; +import { + categoryPath, + fetchProductCategories, + getLevelTwoProductCategories, + type ProductCategory, +} from "../lib/meshkee"; + +type ActivePage = "home" | "products" | "about" | "contact"; + +const navigationItems: { + label: string; + href: string; + key?: ActivePage; +}[] = [ + { label: "صفحه اصلی", href: "/", key: "home" }, + { label: "خدمات", href: "/#services" }, + { label: "اخبار", href: "/#news" }, + { label: "درباره ما", href: "/about-us", key: "about" }, + { label: "تماس با ما", href: "/contact-us", key: "contact" }, +]; + +export default function HeaderNavigation({ + active, + mobileOpen, + onNavigate, +}: { + active?: ActivePage; + mobileOpen: boolean; + onNavigate?: () => void; +}) { + const [categories, setCategories] = useState([]); + + useEffect(() => { + const controller = new AbortController(); + + fetchProductCategories(controller.signal) + .then(setCategories) + .catch(() => { + // Keep the all-products link when categories cannot be loaded. + }); + + return () => controller.abort(); + }, []); + + const levelTwoCategories = useMemo( + () => getLevelTwoProductCategories(categories), + [categories], + ); + + const renderCategoryLinks = () => ( + <> + + همه ماشین‌آلات + ALL PRODUCTS + + {levelTwoCategories.map((category) => ( + + {category.nameFa || category.name} + {category.name} + + ))} + + ); + + return ( + <> + + + {mobileOpen && ( + + )} + + ); +} diff --git a/app/components/InteriorShell.tsx b/app/components/InteriorShell.tsx index eb06ad8..999dc73 100644 --- a/app/components/InteriorShell.tsx +++ b/app/components/InteriorShell.tsx @@ -3,7 +3,6 @@ import { ArrowUpLeft, Camera, - ChevronLeft, CircleUserRound, Link as LinkIcon, Mail, @@ -17,22 +16,14 @@ import { } from "lucide-react"; import Link from "next/link"; import { useState } from "react"; +import HeaderNavigation from "./HeaderNavigation"; const assetBase = "/assets"; -const navItems = [ - { label: "صفحه اصلی", href: "/" }, - { label: "محصولات", href: "/#products" }, - { label: "خدمات", href: "/#services" }, - { label: "اخبار", href: "/#news" }, - { label: "درباره ما", href: "/about-us", key: "about" }, - { label: "تماس با ما", href: "/contact-us", key: "contact" }, -]; - export function InteriorHeader({ active, }: { - active?: "about" | "contact"; + active?: "products" | "about" | "contact"; }) { const [mobileOpen, setMobileOpen] = useState(false); @@ -62,17 +53,11 @@ export function InteriorHeader({ سامان اندیش - + setMobileOpen(false)} + />
@@ -93,20 +78,6 @@ export function InteriorHeader({
- {mobileOpen && ( - - )} ); @@ -138,7 +109,7 @@ export function InteriorFooter() {

دسترسی سریع

صفحه اصلی - ماشین‌آلات + ماشین‌آلات درباره ما تماس با ما
diff --git a/app/components/ProductCard.tsx b/app/components/ProductCard.tsx new file mode 100644 index 0000000..f6fd802 --- /dev/null +++ b/app/components/ProductCard.tsx @@ -0,0 +1,41 @@ +import { ArrowUpLeft, Phone } from "lucide-react"; +import Link from "next/link"; +import { productPath, type Product } from "../lib/meshkee"; + +export default function ProductCard({ product }: { product: Product }) { + const href = productPath(product); + const title = product.nameFa || product.title; + const category = + product.categoryNameFa || product.categoryName || "ماشین‌آلات صنعتی"; + const image = + product.thumbnailUrl || + product.thumbnail || + product.image || + "/assets/product-quadding.jpg"; + + return ( +
+ + {title} + ماشین‌آلات + + + + +
+ {category} +

+ {title} +

+

{product.title}

+
+ {product.categoryName || "SAMAN ANDISH"} + + استعلام قیمت + + +
+
+
+ ); +} diff --git a/app/globals.css b/app/globals.css index f1544da..6153b0e 100644 --- a/app/globals.css +++ b/app/globals.css @@ -251,6 +251,121 @@ button { width: 100%; } +.product-menu { + position: relative; + display: flex; + align-items: stretch; +} + +.product-menu > a { + height: 100%; +} + +.product-menu__dropdown { + position: absolute; + top: calc(100% - 1px); + right: -110px; + width: min(680px, 78vw); + padding-top: 12px; + opacity: 0; + visibility: hidden; + pointer-events: none; + transform: translateY(8px); + transition: + opacity 0.2s ease, + transform 0.2s ease, + visibility 0.2s ease; +} + +.product-menu__dropdown > div { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1px; + padding: 12px; + background: #edf0f3; + border-top: 3px solid var(--red); + box-shadow: 0 22px 45px rgba(3, 25, 47, 0.18); +} + +.product-menu__dropdown a { + min-height: 62px; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: 2px; + padding: 10px 14px; + background: var(--white); + white-space: normal; +} + +.product-menu__dropdown a::after { + display: none; +} + +.product-menu__dropdown a:hover { + color: var(--red); + background: #fff8f9; +} + +.product-menu__dropdown small, +.mobile-product-menu small { + color: #9299a0; + font-family: montserrat, sans-serif; + font-size: 7px; + font-weight: 600; + letter-spacing: 0.35px; +} + +.product-menu:hover .product-menu__dropdown, +.product-menu:focus-within .product-menu__dropdown { + opacity: 1; + visibility: visible; + pointer-events: auto; + transform: translateY(0); +} + +.mobile-product-menu { + border-bottom: 1px solid var(--line); +} + +.mobile-product-menu summary { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 5px; + font-size: 13px; + cursor: pointer; + list-style: none; +} + +.mobile-product-menu summary::-webkit-details-marker { + display: none; +} + +.mobile-product-menu[open] summary svg { + transform: rotate(180deg); +} + +.mobile-product-menu > div { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1px; + padding: 5px 0 10px; + background: #edf0f3; +} + +.mobile-product-menu > div a { + min-height: 52px; + align-items: flex-start; + flex-direction: column; + justify-content: center; + gap: 2px; + padding: 8px 10px; + background: var(--white); + border: 0; +} + .header__actions { display: flex; align-items: center; @@ -2981,11 +3096,335 @@ button { color: var(--red); } +.product-card__body h3 a { + color: inherit; +} + +.product-list-hero { + min-height: 345px; +} + +.product-listing, +.related-products { + padding: 105px 0; +} + +.product-listing__heading { + display: flex; + align-items: end; + justify-content: space-between; + gap: 30px; + margin-bottom: 38px; +} + +.product-listing__heading span { + color: var(--red); + font-family: montserrat, sans-serif; + font-size: 9px; + font-weight: 700; + letter-spacing: 3px; +} + +.product-listing__heading h2 { + margin: 5px 0 0; + color: var(--navy); + font-size: 31px; +} + +.product-listing__heading > p { + margin: 0; + color: var(--muted); + font-size: 12px; +} + +.products-empty { + padding: 65px 25px; + color: var(--muted); + background: var(--gray); + border: 1px solid var(--line); + text-align: center; +} + +.product-detail-breadcrumb { + padding: 16px 0; + color: var(--muted); + background: var(--gray); + border-bottom: 1px solid var(--line); + font-size: 10px; +} + +.product-detail-breadcrumb .container { + display: flex; + align-items: center; + gap: 9px; + overflow: hidden; + white-space: nowrap; +} + +.product-detail-breadcrumb a:hover { + color: var(--red); +} + +.product-detail-breadcrumb i { + color: var(--red); + font-style: normal; +} + +.product-detail-breadcrumb strong { + overflow: hidden; + color: var(--navy); + font-weight: 500; + text-overflow: ellipsis; +} + +.product-detail { + padding: 90px 0 105px; +} + +.product-detail__grid { + display: grid; + grid-template-columns: 1.05fr 0.95fr; + align-items: start; + gap: 75px; +} + +.product-gallery__main { + position: relative; + width: 100%; + height: 535px; + display: block; + overflow: hidden; + padding: 0; + background: #eef0f2; + border: 1px solid var(--line); + cursor: zoom-in; +} + +.product-gallery__main img { + width: 100%; + height: 100%; + object-fit: contain; + transition: transform 0.6s ease; +} + +.product-gallery__main:hover img { + transform: scale(1.12); +} + +.product-gallery__main > span { + position: absolute; + left: 15px; + bottom: 15px; + display: flex; + align-items: center; + gap: 7px; + padding: 9px 12px; + color: var(--navy); + background: rgba(255, 255, 255, 0.92); + font-size: 9px; + font-weight: 700; +} + +.product-gallery__thumbs { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 9px; + margin-top: 10px; +} + +.product-gallery__thumbs button { + height: 92px; + padding: 0; + overflow: hidden; + background: #eef0f2; + border: 2px solid transparent; + cursor: pointer; +} + +.product-gallery__thumbs button.active { + border-color: var(--red); +} + +.product-gallery__thumbs img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.product-lightbox { + position: fixed; + inset: 0; + z-index: 200; + display: grid; + place-items: center; + padding: 45px; + background: rgba(2, 15, 29, 0.92); +} + +.product-lightbox > button { + position: absolute; + top: 24px; + left: 24px; + width: 45px; + height: 45px; + display: grid; + place-items: center; + color: var(--white); + background: var(--red); + border: 0; + cursor: pointer; +} + +.product-lightbox > img { + max-width: min(1100px, 92vw); + max-height: 86vh; + object-fit: contain; +} + +.product-detail__info { + padding-top: 20px; +} + +.product-detail__category { + display: inline-flex; + margin-top: 18px; + color: var(--red); + font-size: 11px; + font-weight: 700; +} + +.product-detail__info h1 { + margin: 8px 0 4px; + color: var(--navy); + font-size: clamp(29px, 3vw, 42px); + line-height: 1.55; +} + +.product-detail__english { + margin: 0 0 25px; + color: #838b93; + font-family: montserrat, sans-serif; + font-size: 11px; + line-height: 1.8; + text-align: right; +} + +.product-detail__summary { + margin: 0; + padding-top: 22px; + color: var(--muted); + border-top: 1px solid var(--line); + font-size: 14px; + line-height: 2.1; +} + +.product-detail__features { + display: grid; + gap: 10px; + margin: 25px 0 30px; +} + +.product-detail__features span { + display: flex; + align-items: center; + gap: 9px; + color: var(--navy); + font-size: 12px; + font-weight: 600; +} + +.product-detail__features svg { + width: 18px; + color: var(--red); +} + +.product-detail__actions { + display: flex; + gap: 10px; +} + +.product-description { + padding: 90px 0; + background: var(--gray); +} + +.product-description__grid { + display: grid; + grid-template-columns: 1fr 360px; + gap: 55px; +} + +.product-description article, +.product-description aside { + padding: 35px; + background: var(--white); + border: 1px solid var(--line); +} + +.product-description article > span { + color: var(--red); + font-family: montserrat, sans-serif; + font-size: 8px; + font-weight: 700; + letter-spacing: 3px; +} + +.product-description h2 { + margin: 7px 0 14px; + color: var(--navy); + font-size: 23px; +} + +.product-description p { + margin: 0; + color: var(--muted); + font-size: 13px; + line-height: 2.1; +} + +.product-description aside { + color: var(--white); + background: var(--navy); + border: 0; +} + +.product-description aside h2, +.product-description aside p { + color: inherit; +} + +.product-description aside p { + color: rgba(255, 255, 255, 0.68); +} + +.product-description aside a { + display: inline-flex; + margin-top: 22px; + color: #ff9dac; + font-family: montserrat, sans-serif; + font-size: 18px; + font-weight: 700; +} + @media (max-width: 920px) { .inner-hero { min-height: 340px; } + .product-detail__grid, + .product-description__grid { + grid-template-columns: 1fr; + gap: 45px; + } + + .product-gallery { + max-width: 700px; + } + + .product-description aside { + max-width: 520px; + } + .about-intro__grid, .why-us__grid, .contact-main__grid { @@ -3030,6 +3469,64 @@ button { min-height: 310px; } + .product-listing, + .related-products { + padding: 75px 0; + } + + .product-listing__heading { + align-items: flex-start; + flex-direction: column; + gap: 10px; + margin-bottom: 28px; + } + + .product-listing__heading h2 { + font-size: 26px; + } + + .product-detail { + padding: 60px 0 75px; + } + + .product-gallery__main { + height: 350px; + } + + .product-gallery__thumbs { + grid-template-columns: repeat(4, 1fr); + } + + .product-gallery__thumbs button { + height: 72px; + } + + .product-detail__info { + padding-top: 0; + } + + .product-detail__info h1 { + font-size: 28px; + } + + .product-detail__actions { + align-items: stretch; + flex-direction: column; + } + + .product-description { + padding: 65px 0; + } + + .product-description article, + .product-description aside { + padding: 25px; + } + + .product-lightbox { + padding: 20px; + } + .inner-hero__content { padding-block: 55px; } diff --git a/app/lib/meshkee.ts b/app/lib/meshkee.ts index 4461ad8..74734ae 100644 --- a/app/lib/meshkee.ts +++ b/app/lib/meshkee.ts @@ -21,10 +21,66 @@ export type StaticImageSlot = { images: StaticImage[]; }; +export type ProductCategory = { + id: string; + parentId?: string | null; + name: string; + nameFa?: string | null; + slug: string; + description?: string | null; + sortOrder?: number | null; + isActive?: boolean; +}; + +export type ProductMedia = { + mediaId?: string; + url: string; +}; + +export type Product = { + id: string; + title: string; + nameFa?: string | null; + summary?: string | null; + descriptionHtml?: string | null; + categoryId?: string | null; + categoryName?: string | null; + categoryNameFa?: string | null; + brand?: { name?: string | null; nameFa?: string | null } | string | null; + thumbnailUrl?: string | null; + image?: string | null; + thumbnail?: string | null; + images?: ProductMedia[]; + commentCount?: number | null; + createdAt?: string | null; + updatedAt?: string | null; + store?: { + inStock?: boolean; + minPrice?: number | string | null; + maxPrice?: number | string | null; + } | null; +}; + +export type ProductDetailResponse = { + product: Product; + relatedProducts?: Product[]; +}; + type StaticImageSlotsResponse = { items?: StaticImageSlot[]; }; +type ProductCategoriesResponse = { + items?: ProductCategory[]; +}; + +type ProductListResponse = { + items?: Product[]; + total?: number; + page?: number; + pageSize?: number; +}; + const API_BASE_URL = process.env.NEXT_PUBLIC_MESHKEE_API_URL?.replace(/\/$/, "") ?? "https://api.meshkee.com/api/v1"; @@ -32,21 +88,191 @@ const API_BASE_URL = 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`; +function tenantEndpoint(path: string) { + return `${API_BASE_URL}/tenants/${encodeURIComponent(WEBSITE_DOMAIN)}${path}`; +} + +async function getJson(endpoint: string, signal?: AbortSignal) { const response = await fetch(endpoint, { headers: { Accept: "application/json" }, signal, }); if (!response.ok) { - throw new Error(`Static image request failed with ${response.status}`); + throw new Error(`Meshkee request failed with ${response.status}`); } - const payload = (await response.json()) as StaticImageSlotsResponse; + return (await response.json()) as T; +} + +export async function fetchHomeStaticImageSlots(signal?: AbortSignal) { + const payload = await getJson( + tenantEndpoint("/website/static-images?pageKey=home"), + signal, + ); return Array.isArray(payload.items) ? payload.items : []; } +export async function fetchProductCategories(signal?: AbortSignal) { + if (typeof window !== "undefined") { + const localPayload = await getJson( + "/api/meshkee/categories", + signal, + ); + return Array.isArray(localPayload.items) ? localPayload.items : []; + } + + const payload = await getJson( + tenantEndpoint("/categories?entityType=product"), + signal, + ); + return Array.isArray(payload.items) ? payload.items : []; +} + +export function getLevelTwoProductCategories(categories: ProductCategory[]) { + const rootIds = new Set( + categories + .filter((category) => !category.parentId) + .map((category) => category.id), + ); + + return categories + .filter( + (category) => + Boolean(category.parentId) && + rootIds.has(category.parentId as string) && + category.isActive !== false, + ) + .sort( + (first, second) => + (first.sortOrder ?? 0) - (second.sortOrder ?? 0) || + (first.nameFa || first.name).localeCompare(second.nameFa || second.name), + ); +} + +async function fetchProductPage( + page: number, + pageSize: number, + signal?: AbortSignal, + categoryId?: string, +) { + const query = new URLSearchParams({ + page: String(page), + pageSize: String(pageSize), + }); + if (categoryId) query.set("categoryId", categoryId); + + const payload = await getJson( + tenantEndpoint(`/products?${query.toString()}`), + signal, + ); + + return { + items: Array.isArray(payload.items) ? payload.items : [], + total: Number(payload.total) || 0, + }; +} + +export async function fetchLatestProducts( + count = 4, + signal?: AbortSignal, + categoryId?: string, +) { + if (typeof window !== "undefined" && !categoryId) { + const localPayload = await getJson( + `/api/meshkee/latest-products?count=${encodeURIComponent(count)}`, + signal, + ); + return Array.isArray(localPayload.items) ? localPayload.items : []; + } + + const firstPage = await fetchProductPage(1, count, signal, categoryId); + if (firstPage.total <= count) { + return [...firstPage.items] + .sort( + (first, second) => + Date.parse(second.createdAt || "") - Date.parse(first.createdAt || ""), + ) + .slice(0, count); + } + + const firstDate = Date.parse(firstPage.items[0]?.createdAt || ""); + const lastDate = Date.parse( + firstPage.items[firstPage.items.length - 1]?.createdAt || "", + ); + if (Number.isFinite(firstDate) && firstDate >= lastDate) { + return firstPage.items.slice(0, count); + } + + const lastPageNumber = Math.ceil(firstPage.total / count); + const lastPage = await fetchProductPage( + lastPageNumber, + count, + signal, + categoryId, + ); + let latestItems = lastPage.items; + + if (latestItems.length < count && lastPageNumber > 1) { + const previousPage = await fetchProductPage( + lastPageNumber - 1, + count, + signal, + categoryId, + ); + latestItems = [...previousPage.items, ...latestItems]; + } + + return latestItems + .sort( + (first, second) => + Date.parse(second.createdAt || "") - Date.parse(first.createdAt || ""), + ) + .slice(0, count); +} + +export async function fetchProductById( + productId: string, + signal?: AbortSignal, +) { + return getJson( + tenantEndpoint(`/products/by-id/${encodeURIComponent(productId)}`), + signal, + ); +} + +export async function fetchCategoryById( + categoryId: string, + signal?: AbortSignal, +) { + const payload = await getJson<{ category?: ProductCategory }>( + tenantEndpoint( + `/categories/by-id/${encodeURIComponent(categoryId)}?entityType=product`, + ), + signal, + ); + return payload.category; +} + +export function slugifyPersianTitle(value: string) { + return value + .normalize("NFKC") + .replace(/[^\p{L}\p{N}\s-]/gu, "") + .trim() + .replace(/\s+/g, "-") + .replace(/-+/g, "-"); +} + +export function productPath(product: Pick) { + return `/products/${product.id}/${slugifyPersianTitle(product.nameFa || product.title)}`; +} + +export function categoryPath( + category: Pick, +) { + return `/products/category/${category.id}/${slugifyPersianTitle(category.nameFa || category.name)}`; +} + export function cssAspectRatio(value?: string | null) { if (!value || !/^\d+(?:\.\d+)?:\d+(?:\.\d+)?$/.test(value)) { return undefined; diff --git a/app/page.tsx b/app/page.tsx index fb250ae..fec1750 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -5,14 +5,13 @@ import { ArrowUpLeft, BriefcaseBusiness, Camera, - ChevronDown, ChevronLeft, ChevronRight, CircleUserRound, FlaskConical, Gauge, Globe2, - Link, + Link as LinkIcon, Mail, MapPin, Menu, @@ -25,13 +24,18 @@ import { Sparkles, X, } from "lucide-react"; +import NextLink from "next/link"; import type { FormEvent } from "react"; import { useEffect, useMemo, useRef, useState } from "react"; import { cssAspectRatio, fetchHomeStaticImageSlots, + fetchLatestProducts, + productPath, + type Product, type StaticImageSlot, } from "./lib/meshkee"; +import HeaderNavigation from "./components/HeaderNavigation"; const assetBase = "/assets"; @@ -86,41 +90,78 @@ const categories = [ }, ]; -const products = [ +type DisplayProduct = { + id: string; + title: string; + en: string; + category: string; + image: string; + code: string; + href: string; +}; + +const fallbackProducts: DisplayProduct[] = [ { - id: 1, + id: "fallback-1", title: "دستگاه کوادینگ ۶۰۰ میلی‌متر", en: "600mm Quadding Machine | QUA-21 | LESMO", category: "دستگاه تابنده", image: `${assetBase}/product-quadding.jpg`, code: "LESMO / QUA-21", + href: "#products", }, { - id: 2, + id: "fallback-2", title: "بریدر ۱۶ کریر", en: "16 Carriers Braider | 16N4 | SPIRKA", category: "دستگاه بافنده", image: `${assetBase}/product-braider.jpg`, code: "SPIRKA / 16N4", + href: "#products", }, { - id: 3, + id: "fallback-3", title: "خط کشش ۰.۵–۰.۱۵ میلی‌متر", en: "0.15–0.5 SQmm Drawing Line | TRB3 | SAMP", category: "دستگاه کشش", image: `${assetBase}/product-drawing-line.jpg`, code: "SAMP / TRB3", + href: "#products", }, { - id: 4, + id: "fallback-4", title: "پی‌آف ۶۰۰ میلی‌متر", en: "600mm Pay Off | TAP D-0800 | KDM", category: "دستگاه پی‌آف", image: `${assetBase}/product-payoff.jpg`, code: "KDM / TAP D-0800", + href: "#products", }, ]; +function toDisplayProduct(product: Product, index: number): DisplayProduct { + const fallback = fallbackProducts[index % fallbackProducts.length]; + const brand = + typeof product.brand === "string" + ? product.brand + : product.brand?.nameFa || product.brand?.name; + + return { + id: product.id, + title: product.nameFa || product.title, + en: product.title, + category: + product.categoryNameFa || product.categoryName || "ماشین‌آلات صنعتی", + image: + product.thumbnailUrl || + product.thumbnail || + product.image || + fallback.image, + code: brand || product.categoryName || "SAMAN ANDISH", + href: productPath(product), + }; +} + const news = [ { title: "شرکت مهندسی، مشاوره و بازرگانی HService", @@ -179,6 +220,8 @@ export default function Home() { const [query, setQuery] = useState(""); const [searchOpen, setSearchOpen] = useState(false); const [staticImageSlots, setStaticImageSlots] = useState([]); + const [displayProducts, setDisplayProducts] = + useState(fallbackProducts); const searchInput = useRef(null); const slotsByKey = useMemo( @@ -242,6 +285,14 @@ export default function Home() { // Keep the local image fallbacks when the API is unavailable. }); + fetchLatestProducts(4, controller.signal) + .then((items) => { + if (items.length) setDisplayProducts(items.map(toDisplayProduct)); + }) + .catch(() => { + // Keep the local product fallbacks when the API is unavailable. + }); + return () => controller.abort(); }, []); @@ -258,14 +309,14 @@ export default function Home() { const results = useMemo(() => { const normalized = query.trim().toLowerCase(); - if (!normalized) return products.slice(0, 3); - return products.filter((product) => + if (!normalized) return displayProducts.slice(0, 3); + return displayProducts.filter((product) => [product.title, product.en, product.category, product.code] .join(" ") .toLowerCase() .includes(normalized), ); - }, [query]); + }, [displayProducts, query]); const focusSearch = () => { document.getElementById("smart-search")?.scrollIntoView({ @@ -310,19 +361,11 @@ export default function Home() { سامان اندیش - + setMobileOpen(false)} + />
- {mobileOpen && ( - - )}
@@ -390,10 +416,10 @@ export default function Home() {

{activeHero.title}

{activeHero.text}

- + مشاهده محصولات - + درباره سامان اندیش @@ -497,7 +523,7 @@ export default function Home() {
{results.length ? ( results.map((product) => ( - + {product.title}
{product.title} @@ -505,7 +531,7 @@ export default function Home() {
{product.category} -
+ )) ) : (
@@ -556,25 +582,27 @@ export default function Home() {
- {products.map((product) => ( + {displayProducts.map((product) => (
- + {product.title} - فروش ویژه + جدیدترین - +
{product.category} -

{product.title}

+

+ {product.title} +

{product.en}

{product.code} @@ -791,7 +819,7 @@ export default function Home() { - + @@ -801,7 +829,7 @@ export default function Home() {

دسترسی سریع

صفحه اصلی - ماشین‌آلات + ماشین‌آلات اخبار و مقالات درباره ما تماس با ما diff --git a/app/products/[id]/[slug]/ProductGallery.tsx b/app/products/[id]/[slug]/ProductGallery.tsx new file mode 100644 index 0000000..4e0e7f3 --- /dev/null +++ b/app/products/[id]/[slug]/ProductGallery.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { Maximize2, X } from "lucide-react"; +import { useState } from "react"; + +export default function ProductGallery({ + images, + title, +}: { + images: string[]; + title: string; +}) { + const [activeImage, setActiveImage] = useState(images[0]); + const [lightboxOpen, setLightboxOpen] = useState(false); + + return ( + <> +
+ + + {images.length > 1 && ( +
+ {images.map((image, index) => ( + + ))} +
+ )} +
+ + {lightboxOpen && ( +
setLightboxOpen(false)} + > + + {title} event.stopPropagation()} + /> +
+ )} + + ); +} diff --git a/app/products/[id]/[slug]/page.tsx b/app/products/[id]/[slug]/page.tsx new file mode 100644 index 0000000..35d4625 --- /dev/null +++ b/app/products/[id]/[slug]/page.tsx @@ -0,0 +1,211 @@ +import type { Metadata } from "next"; +import { ArrowLeft, CheckCircle2, Phone } from "lucide-react"; +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; +import { cache } from "react"; +import ProductCard from "../../../components/ProductCard"; +import { + InteriorFooter, + InteriorHeader, +} from "../../../components/InteriorShell"; +import { + categoryPath, + fetchProductById, + productPath, +} from "../../../lib/meshkee"; +import ProductGallery from "./ProductGallery"; + +export const dynamic = "force-dynamic"; + +type ProductPageProps = { + params: Promise<{ id: string; slug: string }>; +}; + +const getProduct = cache(async (id: string) => fetchProductById(id)); + +function plainText(value?: string | null) { + return (value || "") + .replace(/<[^>]*>/g, " ") + .replace(/ /g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +export async function generateMetadata({ + params, +}: ProductPageProps): Promise { + const { id } = await params; + const data = await getProduct(id).catch(() => undefined); + + if (!data?.product) { + return { + title: "محصول یافت نشد | سامان اندیش", + }; + } + + const product = data.product; + const title = product.nameFa || product.title; + const description = + product.summary || + plainText(product.descriptionHtml) || + `مشخصات و تصاویر ${title} را مشاهده کنید و برای دریافت اطلاعات فنی و استعلام قیمت با کارشناسان سامان اندیش تماس بگیرید.`; + const image = product.thumbnailUrl || product.image || product.thumbnail; + + return { + title: `${title} | سامان اندیش`, + description: description.slice(0, 160), + alternates: { + canonical: productPath(product), + }, + openGraph: { + title: `${title} | سامان اندیش`, + description: description.slice(0, 160), + url: productPath(product), + type: "website", + images: image ? [{ url: image, alt: title }] : undefined, + }, + }; +} + +export default async function ProductDetailPage({ + params, +}: ProductPageProps) { + const { id, slug } = await params; + const data = await getProduct(id).catch(() => undefined); + + if (!data?.product) notFound(); + + const product = data.product; + const canonicalPath = productPath(product); + const requestedSlug = decodeURIComponent(slug); + if (!canonicalPath.endsWith(`/${requestedSlug}`)) { + redirect(encodeURI(canonicalPath)); + } + + const title = product.nameFa || product.title; + const categoryTitle = + product.categoryNameFa || product.categoryName || "ماشین‌آلات صنعتی"; + const categoryHref = + product.categoryId && (product.categoryNameFa || product.categoryName) + ? categoryPath({ + id: product.categoryId, + nameFa: product.categoryNameFa, + name: product.categoryName || categoryTitle, + }) + : "/products"; + const images = Array.from( + new Set( + [ + product.thumbnailUrl, + product.image, + product.thumbnail, + ...(product.images || []).map((image) => image.url), + ].filter((image): image is string => Boolean(image)), + ), + ); + if (!images.length) images.push("/assets/product-quadding.jpg"); + + const description = + plainText(product.descriptionHtml) || + product.summary || + "برای دریافت توضیحات فنی کامل این دستگاه با کارشناسان سامان اندیش تماس بگیرید."; + const relatedProducts = (data.relatedProducts || []).slice(0, 4); + + return ( +
+ + +
+
+ صفحه اصلی + / + ماشین‌آلات + / + {categoryTitle} + / + {title} +
+
+ +
+
+ + +
+ INDUSTRIAL MACHINERY + + {categoryTitle} + +

{title}

+

+ {product.title} +

+

{description}

+ +
+ + + ارزیابی فنی پیش از خرید + + + + پشتیبانی نصب و راه‌اندازی + +
+ +
+ + + استعلام قیمت + + + درخواست مشاوره + + +
+
+
+
+ +
+
+
+ PRODUCT DESCRIPTION +

توضیحات محصول

+

{description}

+
+ +
+
+ + {relatedProducts.length > 0 && ( +
+
+
+
+ RELATED PRODUCTS +

ماشین‌آلات مرتبط

+
+
+
+ {relatedProducts.map((relatedProduct) => ( + + ))} +
+
+
+ )} + + +
+ ); +} diff --git a/app/products/category/[id]/[slug]/page.tsx b/app/products/category/[id]/[slug]/page.tsx new file mode 100644 index 0000000..4484da3 --- /dev/null +++ b/app/products/category/[id]/[slug]/page.tsx @@ -0,0 +1,114 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; +import ProductCard from "../../../../components/ProductCard"; +import { + InteriorFooter, + InteriorHeader, +} from "../../../../components/InteriorShell"; +import { + categoryPath, + fetchCategoryById, + fetchLatestProducts, +} from "../../../../lib/meshkee"; + +export const dynamic = "force-dynamic"; + +type CategoryPageProps = { + params: Promise<{ id: string; slug: string }>; +}; + +export async function generateMetadata({ + params, +}: CategoryPageProps): Promise { + const { id } = await params; + const category = await fetchCategoryById(id).catch(() => undefined); + + if (!category) { + return { + title: "دسته‌بندی یافت نشد | سامان اندیش", + }; + } + + const title = category.nameFa || category.name; + return { + title: `${title} | ماشین‌آلات سامان اندیش`, + description: + category.description || + `جدیدترین محصولات و ماشین‌آلات دسته ${title} را مشاهده کنید و برای دریافت مشخصات فنی، مشاوره تخصصی و استعلام قیمت با سامان اندیش تماس بگیرید.`, + alternates: { + canonical: categoryPath(category), + }, + }; +} + +export default async function ProductCategoryPage({ + params, +}: CategoryPageProps) { + const { id, slug } = await params; + const [category, products] = await Promise.all([ + fetchCategoryById(id).catch(() => undefined), + fetchLatestProducts(48, undefined, id).catch(() => []), + ]); + + if (!category) notFound(); + + const canonicalPath = categoryPath(category); + const requestedSlug = decodeURIComponent(slug); + if (!canonicalPath.endsWith(`/${requestedSlug}`)) { + redirect(encodeURI(canonicalPath)); + } + + const title = category.nameFa || category.name; + + return ( +
+ + +
+
+
+ {category.name} +

{title}

+

ماشین‌آلات و تجهیزات تخصصی این دسته‌بندی

+
+ صفحه اصلی + / + ماشین‌آلات + / + {title} +
+
+
+ +
+
+
+
+ CATEGORY PRODUCTS +

محصولات {title}

+
+

{products.length} محصول

+
+ + {products.length ? ( +
+ {products.map((product) => ( + + ))} +
+ ) : ( +
+ محصولی در این دسته‌بندی ثبت نشده است. +
+ )} +
+
+ + +
+ ); +} diff --git a/app/products/page.tsx b/app/products/page.tsx new file mode 100644 index 0000000..9abc860 --- /dev/null +++ b/app/products/page.tsx @@ -0,0 +1,69 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import ProductCard from "../components/ProductCard"; +import { InteriorFooter, InteriorHeader } from "../components/InteriorShell"; +import { fetchLatestProducts } from "../lib/meshkee"; + +export const dynamic = "force-dynamic"; + +export const metadata: Metadata = { + title: "ماشین‌آلات صنعت سیم و کابل | سامان اندیش", + description: + "جدیدترین ماشین‌آلات و تجهیزات خطوط تولید سیم و کابل سامان اندیش را مشاهده کنید و برای انتخاب، ارزیابی فنی و استعلام قیمت با کارشناسان ما در ارتباط باشید.", + alternates: { + canonical: "/products", + }, +}; + +export default async function ProductsPage() { + const products = await fetchLatestProducts(24).catch(() => []); + + return ( +
+ + +
+
+
+ INDUSTRIAL MACHINERY +

ماشین‌آلات سیم و کابل

+

جدیدترین تجهیزات موجود برای خطوط تولید صنعتی

+
+ صفحه اصلی + / + ماشین‌آلات +
+
+
+ +
+
+
+
+ NEWEST PRODUCTS +

جدیدترین ماشین‌آلات

+
+

{products.length} محصول اخیر

+
+ + {products.length ? ( +
+ {products.map((product) => ( + + ))} +
+ ) : ( +
+ در حال حاضر محصولی برای نمایش در دسترس نیست. +
+ )} +
+
+ + +
+ ); +}