Add Meshkee product catalog navigation and details

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-26 19:42:35 +03:30
co-authored by Cursor
parent 00126e5777
commit 34ce4f6925
13 changed files with 1499 additions and 97 deletions
+8 -2
View File
@@ -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.
+17
View File
@@ -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 });
}
}
+23
View File
@@ -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 });
}
}
+126
View File
@@ -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<ProductCategory[]>([]);
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 = () => (
<>
<Link href="/products" onClick={onNavigate}>
همه ماشین‌آلات
<small>ALL PRODUCTS</small>
</Link>
{levelTwoCategories.map((category) => (
<Link
href={categoryPath(category)}
key={category.id}
onClick={onNavigate}
>
{category.nameFa || category.name}
<small dir="ltr">{category.name}</small>
</Link>
))}
</>
);
return (
<>
<nav className="desktop-nav" aria-label="ناوبری اصلی">
<Link className={active === "home" ? "active" : ""} href="/">
صفحه اصلی
</Link>
<div className="product-menu">
<Link
className={active === "products" ? "active" : ""}
href="/products"
>
محصولات
<ChevronDown size={14} />
</Link>
<div className="product-menu__dropdown">
<div>{renderCategoryLinks()}</div>
</div>
</div>
{navigationItems.slice(1).map((item) => (
<Link
className={item.key === active ? "active" : ""}
href={item.href}
key={item.label}
>
{item.label}
</Link>
))}
</nav>
{mobileOpen && (
<nav className="mobile-nav" aria-label="ناوبری موبایل">
<Link href="/" onClick={onNavigate}>
صفحه اصلی
<ChevronLeft size={17} />
</Link>
<details className="mobile-product-menu">
<summary>
محصولات
<ChevronDown size={17} />
</summary>
<div>{renderCategoryLinks()}</div>
</details>
{navigationItems.slice(1).map((item) => (
<Link href={item.href} key={item.label} onClick={onNavigate}>
{item.label}
<ChevronLeft size={17} />
</Link>
))}
</nav>
)}
</>
);
}
+8 -37
View File
@@ -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({
<Link className="logo" href="/" aria-label="سامان اندیش">
<img src={`${assetBase}/KN83Drb6oI.png`} alt="سامان اندیش" />
</Link>
<nav className="desktop-nav" aria-label="ناوبری اصلی">
{navItems.map((item) => (
<Link
className={item.key === active ? "active" : ""}
href={item.href}
key={item.label}
>
{item.label}
</Link>
))}
</nav>
<HeaderNavigation
active={active}
mobileOpen={mobileOpen}
onNavigate={() => setMobileOpen(false)}
/>
<div className="header__actions">
<Link className="icon-button" href="/#smart-search" aria-label="جستجو">
<Search size={22} />
@@ -93,20 +78,6 @@ export function InteriorHeader({
</button>
</div>
</div>
{mobileOpen && (
<nav className="mobile-nav">
{navItems.map((item) => (
<Link
href={item.href}
key={item.label}
onClick={() => setMobileOpen(false)}
>
{item.label}
<ChevronLeft size={17} />
</Link>
))}
</nav>
)}
</header>
</>
);
@@ -138,7 +109,7 @@ export function InteriorFooter() {
<div className="footer__links">
<h3>دسترسی سریع</h3>
<Link href="/">صفحه اصلی</Link>
<Link href="/#products">ماشین‌آلات</Link>
<Link href="/products">ماشین‌آلات</Link>
<Link href="/about-us">درباره ما</Link>
<Link href="/contact-us">تماس با ما</Link>
</div>
+41
View File
@@ -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 (
<article className="product-card">
<Link className="product-card__image" href={href}>
<img src={image} alt={title} />
<span>ماشین‌آلات</span>
<i>
<ArrowUpLeft />
</i>
</Link>
<div className="product-card__body">
<small>{category}</small>
<h3>
<Link href={href}>{title}</Link>
</h3>
<p dir="ltr">{product.title}</p>
<div>
<code>{product.categoryName || "SAMAN ANDISH"}</code>
<a href="tel:+982188530256">
استعلام قیمت
<Phone size={15} />
</a>
</div>
</div>
</article>
);
}
+497
View File
@@ -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;
}
+230 -4
View File
@@ -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<T>(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<StaticImageSlotsResponse>(
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<ProductCategoriesResponse>(
"/api/meshkee/categories",
signal,
);
return Array.isArray(localPayload.items) ? localPayload.items : [];
}
const payload = await getJson<ProductCategoriesResponse>(
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<ProductListResponse>(
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<ProductListResponse>(
`/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<ProductDetailResponse>(
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<Product, "id" | "nameFa" | "title">) {
return `/products/${product.id}/${slugifyPersianTitle(product.nameFa || product.title)}`;
}
export function categoryPath(
category: Pick<ProductCategory, "id" | "nameFa" | "name">,
) {
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;
+82 -54
View File
@@ -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<StaticImageSlot[]>([]);
const [displayProducts, setDisplayProducts] =
useState<DisplayProduct[]>(fallbackProducts);
const searchInput = useRef<HTMLInputElement>(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() {
<a className="logo" href="#" aria-label="سامان اندیش">
<img src={`${assetBase}/KN83Drb6oI.png`} alt="سامان اندیش" />
</a>
<nav className="desktop-nav" aria-label="ناوبری اصلی">
<a className="active" href="#">
صفحه اصلی
</a>
<a href="#products">
محصولات
<ChevronDown size={14} />
</a>
<a href="#services">خدمات</a>
<a href="#news">اخبار</a>
<a href="/about-us">درباره ما</a>
<a href="/contact-us">تماس با ما</a>
</nav>
<HeaderNavigation
active="home"
mobileOpen={mobileOpen}
onNavigate={() => setMobileOpen(false)}
/>
<div className="header__actions">
<button className="icon-button" onClick={focusSearch} aria-label="جستجو">
<Search size={22} />
@@ -343,23 +386,6 @@ export default function Home() {
</button>
</div>
</div>
{mobileOpen && (
<nav className="mobile-nav">
{[
["صفحه اصلی", "#"],
["محصولات", "#products"],
["خدمات", "#services"],
["اخبار", "#news"],
["درباره ما", "/about-us"],
["تماس با ما", "/contact-us"],
].map(([label, href]) => (
<a key={label} href={href} onClick={() => setMobileOpen(false)}>
{label}
<ChevronLeft size={17} />
</a>
))}
</nav>
)}
</header>
<section className="hero">
@@ -390,10 +416,10 @@ export default function Home() {
<h1>{activeHero.title}</h1>
<p className="hero__description">{activeHero.text}</p>
<div className="hero__buttons">
<a className="button button--red" href="#products">
<NextLink className="button button--red" href="/products">
مشاهده محصولات
<ArrowLeft size={19} />
</a>
</NextLink>
<a className="button button--glass" href="/about-us">
درباره سامان اندیش
</a>
@@ -497,7 +523,7 @@ export default function Home() {
</div>
{results.length ? (
results.map((product) => (
<a href="#products" key={product.id}>
<NextLink href={product.href} key={product.id}>
<img src={product.image} alt={product.title} />
<div>
<strong>{product.title}</strong>
@@ -505,7 +531,7 @@ export default function Home() {
</div>
<span>{product.category}</span>
<ChevronLeft size={18} />
</a>
</NextLink>
))
) : (
<div className="empty-result">
@@ -556,25 +582,27 @@ export default function Home() {
<section className="products section-gray" id="products">
<div className="container">
<div className="section-heading-row">
<SectionTitle eyebrow="SPECIAL MACHINERY" title="ماشین‌آلات منتخب" />
<a className="text-link" href="#">
<SectionTitle eyebrow="LATEST MACHINERY" title="جدیدترین ماشین‌آلات" />
<NextLink className="text-link" href="/products">
مشاهده همه محصولات
<ArrowLeft size={18} />
</a>
</NextLink>
</div>
<div className="product-grid">
{products.map((product) => (
{displayProducts.map((product) => (
<article className="product-card" key={product.id}>
<a className="product-card__image" href="#">
<NextLink className="product-card__image" href={product.href}>
<img src={product.image} alt={product.title} />
<span>فروش ویژه</span>
<span>جدیدترین</span>
<i>
<ArrowUpLeft />
</i>
</a>
</NextLink>
<div className="product-card__body">
<small>{product.category}</small>
<h3>{product.title}</h3>
<h3>
<NextLink href={product.href}>{product.title}</NextLink>
</h3>
<p dir="ltr">{product.en}</p>
<div>
<code>{product.code}</code>
@@ -791,7 +819,7 @@ export default function Home() {
<Camera />
</a>
<a href="#" aria-label="لینکدین">
<Link />
<LinkIcon />
</a>
<a href="#" aria-label="تلگرام">
<MessageCircle />
@@ -801,7 +829,7 @@ export default function Home() {
<div className="footer__links">
<h3>دسترسی سریع</h3>
<a href="#">صفحه اصلی</a>
<a href="#products">ماشین‌آلات</a>
<NextLink href="/products">ماشین‌آلات</NextLink>
<a href="#news">اخبار و مقالات</a>
<a href="/about-us">درباره ما</a>
<a href="/contact-us">تماس با ما</a>
@@ -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 (
<>
<div className="product-gallery">
<button
className="product-gallery__main"
type="button"
onClick={() => setLightboxOpen(true)}
aria-label={`نمایش بزرگ تصویر ${title}`}
>
<img src={activeImage} alt={title} />
<span>
<Maximize2 size={18} />
مشاهده تصویر
</span>
</button>
{images.length > 1 && (
<div className="product-gallery__thumbs">
{images.map((image, index) => (
<button
className={image === activeImage ? "active" : ""}
type="button"
key={`${image}-${index}`}
onClick={() => setActiveImage(image)}
aria-label={`تصویر ${index + 1} از ${title}`}
>
<img src={image} alt={`${title}، تصویر ${index + 1}`} />
</button>
))}
</div>
)}
</div>
{lightboxOpen && (
<div
className="product-lightbox"
role="dialog"
aria-modal="true"
aria-label={`گالری ${title}`}
onClick={() => setLightboxOpen(false)}
>
<button
type="button"
onClick={() => setLightboxOpen(false)}
aria-label="بستن گالری"
>
<X />
</button>
<img
src={activeImage}
alt={title}
onClick={(event) => event.stopPropagation()}
/>
</div>
)}
</>
);
}
+211
View File
@@ -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(/&nbsp;/g, " ")
.replace(/\s+/g, " ")
.trim();
}
export async function generateMetadata({
params,
}: ProductPageProps): Promise<Metadata> {
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 (
<main>
<InteriorHeader active="products" />
<div className="product-detail-breadcrumb">
<div className="container">
<Link href="/">صفحه اصلی</Link>
<i>/</i>
<Link href="/products">ماشین‌آلات</Link>
<i>/</i>
<Link href={categoryHref}>{categoryTitle}</Link>
<i>/</i>
<strong>{title}</strong>
</div>
</div>
<section className="product-detail">
<div className="container product-detail__grid">
<ProductGallery images={images} title={title} />
<div className="product-detail__info">
<span className="inner-eyebrow">INDUSTRIAL MACHINERY</span>
<Link className="product-detail__category" href={categoryHref}>
{categoryTitle}
</Link>
<h1>{title}</h1>
<p className="product-detail__english" dir="ltr">
{product.title}
</p>
<p className="product-detail__summary">{description}</p>
<div className="product-detail__features">
<span>
<CheckCircle2 />
ارزیابی فنی پیش از خرید
</span>
<span>
<CheckCircle2 />
پشتیبانی نصب و راه‌اندازی
</span>
</div>
<div className="product-detail__actions">
<a className="button button--red" href="tel:+982188530256">
<Phone size={18} />
استعلام قیمت
</a>
<Link className="button button--navy" href="/contact-us">
درخواست مشاوره
<ArrowLeft size={18} />
</Link>
</div>
</div>
</div>
</section>
<section className="product-description">
<div className="container product-description__grid">
<article>
<span>PRODUCT DESCRIPTION</span>
<h2>توضیحات محصول</h2>
<p>{description}</p>
</article>
<aside>
<h2>نیاز به اطلاعات بیشتر دارید؟</h2>
<p>
مشخصات فنی، وضعیت دستگاه و شرایط تأمین را مستقیماً از کارشناسان
سامان اندیش دریافت کنید.
</p>
<a href="tel:+982188530256" dir="ltr">
021 8853 0256
</a>
</aside>
</div>
</section>
{relatedProducts.length > 0 && (
<section className="related-products section-gray">
<div className="container">
<div className="product-listing__heading">
<div>
<span>RELATED PRODUCTS</span>
<h2>ماشین‌آلات مرتبط</h2>
</div>
</div>
<div className="product-grid">
{relatedProducts.map((relatedProduct) => (
<ProductCard key={relatedProduct.id} product={relatedProduct} />
))}
</div>
</div>
</section>
)}
<InteriorFooter />
</main>
);
}
+114
View File
@@ -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<Metadata> {
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 (
<main>
<InteriorHeader active="products" />
<section
className="inner-hero product-list-hero"
style={{ backgroundImage: 'url("/assets/s9zJRbKrhE.jpg")' }}
>
<div className="inner-hero__shade" />
<div className="container inner-hero__content">
<span dir="ltr">{category.name}</span>
<h1>{title}</h1>
<p>ماشین‌آلات و تجهیزات تخصصی این دسته‌بندی</p>
<div>
<Link href="/">صفحه اصلی</Link>
<i>/</i>
<Link href="/products">ماشین‌آلات</Link>
<i>/</i>
<strong>{title}</strong>
</div>
</div>
</section>
<section className="product-listing">
<div className="container">
<div className="product-listing__heading">
<div>
<span>CATEGORY PRODUCTS</span>
<h2>محصولات {title}</h2>
</div>
<p>{products.length} محصول</p>
</div>
{products.length ? (
<div className="product-grid">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
) : (
<div className="products-empty">
محصولی در این دسته‌بندی ثبت نشده است.
</div>
)}
</div>
</section>
<InteriorFooter />
</main>
);
}
+69
View File
@@ -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 (
<main>
<InteriorHeader active="products" />
<section
className="inner-hero product-list-hero"
style={{ backgroundImage: 'url("/assets/i8SsUDDMLv.jpg")' }}
>
<div className="inner-hero__shade" />
<div className="container inner-hero__content">
<span>INDUSTRIAL MACHINERY</span>
<h1>ماشین‌آلات سیم و کابل</h1>
<p>جدیدترین تجهیزات موجود برای خطوط تولید صنعتی</p>
<div>
<Link href="/">صفحه اصلی</Link>
<i>/</i>
<strong>ماشین‌آلات</strong>
</div>
</div>
</section>
<section className="product-listing">
<div className="container">
<div className="product-listing__heading">
<div>
<span>NEWEST PRODUCTS</span>
<h2>جدیدترین ماشین‌آلات</h2>
</div>
<p>{products.length} محصول اخیر</p>
</div>
{products.length ? (
<div className="product-grid">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
) : (
<div className="products-empty">
در حال حاضر محصولی برای نمایش در دسترس نیست.
</div>
)}
</div>
</section>
<InteriorFooter />
</main>
);
}