Add Meshkee product catalog navigation and details
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
00126e5777
commit
34ce4f6925
+8
-2
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user