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.
|
- Persian RTL storefront for Saman Andish, built with Next.js 16, React 19, and TypeScript.
|
||||||
- Apex domain: `samanandish.com`.
|
- Apex domain: `samanandish.com`.
|
||||||
- Local design assets live in `public/assets`; local fonts live in `public/fonts`.
|
- 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
|
## Meshkee API
|
||||||
|
|
||||||
@@ -16,6 +16,9 @@
|
|||||||
- `NEXT_PUBLIC_MESHKEE_DOMAIN`
|
- `NEXT_PUBLIC_MESHKEE_DOMAIN`
|
||||||
- `app/lib/meshkee.ts` owns the public static-image contract and homepage fetch.
|
- `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 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
|
## 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.
|
- 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.
|
- 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, 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.
|
- 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.
|
- After deployment, use Business Dashboard → Website → Settings → Sync sitemap config.
|
||||||
|
|
||||||
@@ -40,4 +45,5 @@
|
|||||||
- Run `npm run lint`.
|
- Run `npm run lint`.
|
||||||
- Run `npm run build`.
|
- Run `npm run build`.
|
||||||
- Verify `/meshkee/static-image-slots` returns the three slot definitions.
|
- 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 {
|
import {
|
||||||
ArrowUpLeft,
|
ArrowUpLeft,
|
||||||
Camera,
|
Camera,
|
||||||
ChevronLeft,
|
|
||||||
CircleUserRound,
|
CircleUserRound,
|
||||||
Link as LinkIcon,
|
Link as LinkIcon,
|
||||||
Mail,
|
Mail,
|
||||||
@@ -17,22 +16,14 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import HeaderNavigation from "./HeaderNavigation";
|
||||||
|
|
||||||
const assetBase = "/assets";
|
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({
|
export function InteriorHeader({
|
||||||
active,
|
active,
|
||||||
}: {
|
}: {
|
||||||
active?: "about" | "contact";
|
active?: "products" | "about" | "contact";
|
||||||
}) {
|
}) {
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
|
||||||
@@ -62,17 +53,11 @@ export function InteriorHeader({
|
|||||||
<Link className="logo" href="/" aria-label="سامان اندیش">
|
<Link className="logo" href="/" aria-label="سامان اندیش">
|
||||||
<img src={`${assetBase}/KN83Drb6oI.png`} alt="سامان اندیش" />
|
<img src={`${assetBase}/KN83Drb6oI.png`} alt="سامان اندیش" />
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="desktop-nav" aria-label="ناوبری اصلی">
|
<HeaderNavigation
|
||||||
{navItems.map((item) => (
|
active={active}
|
||||||
<Link
|
mobileOpen={mobileOpen}
|
||||||
className={item.key === active ? "active" : ""}
|
onNavigate={() => setMobileOpen(false)}
|
||||||
href={item.href}
|
/>
|
||||||
key={item.label}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
<div className="header__actions">
|
<div className="header__actions">
|
||||||
<Link className="icon-button" href="/#smart-search" aria-label="جستجو">
|
<Link className="icon-button" href="/#smart-search" aria-label="جستجو">
|
||||||
<Search size={22} />
|
<Search size={22} />
|
||||||
@@ -93,20 +78,6 @@ export function InteriorHeader({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</header>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -138,7 +109,7 @@ export function InteriorFooter() {
|
|||||||
<div className="footer__links">
|
<div className="footer__links">
|
||||||
<h3>دسترسی سریع</h3>
|
<h3>دسترسی سریع</h3>
|
||||||
<Link href="/">صفحه اصلی</Link>
|
<Link href="/">صفحه اصلی</Link>
|
||||||
<Link href="/#products">ماشینآلات</Link>
|
<Link href="/products">ماشینآلات</Link>
|
||||||
<Link href="/about-us">درباره ما</Link>
|
<Link href="/about-us">درباره ما</Link>
|
||||||
<Link href="/contact-us">تماس با ما</Link>
|
<Link href="/contact-us">تماس با ما</Link>
|
||||||
</div>
|
</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%;
|
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 {
|
.header__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2981,11 +3096,335 @@ button {
|
|||||||
color: var(--red);
|
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) {
|
@media (max-width: 920px) {
|
||||||
.inner-hero {
|
.inner-hero {
|
||||||
min-height: 340px;
|
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,
|
.about-intro__grid,
|
||||||
.why-us__grid,
|
.why-us__grid,
|
||||||
.contact-main__grid {
|
.contact-main__grid {
|
||||||
@@ -3030,6 +3469,64 @@ button {
|
|||||||
min-height: 310px;
|
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 {
|
.inner-hero__content {
|
||||||
padding-block: 55px;
|
padding-block: 55px;
|
||||||
}
|
}
|
||||||
|
|||||||
+230
-4
@@ -21,10 +21,66 @@ export type StaticImageSlot = {
|
|||||||
images: StaticImage[];
|
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 = {
|
type StaticImageSlotsResponse = {
|
||||||
items?: StaticImageSlot[];
|
items?: StaticImageSlot[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ProductCategoriesResponse = {
|
||||||
|
items?: ProductCategory[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProductListResponse = {
|
||||||
|
items?: Product[];
|
||||||
|
total?: number;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
};
|
||||||
|
|
||||||
const API_BASE_URL =
|
const API_BASE_URL =
|
||||||
process.env.NEXT_PUBLIC_MESHKEE_API_URL?.replace(/\/$/, "") ??
|
process.env.NEXT_PUBLIC_MESHKEE_API_URL?.replace(/\/$/, "") ??
|
||||||
"https://api.meshkee.com/api/v1";
|
"https://api.meshkee.com/api/v1";
|
||||||
@@ -32,21 +88,191 @@ const API_BASE_URL =
|
|||||||
const WEBSITE_DOMAIN =
|
const WEBSITE_DOMAIN =
|
||||||
process.env.NEXT_PUBLIC_MESHKEE_DOMAIN ?? "samanandish.com";
|
process.env.NEXT_PUBLIC_MESHKEE_DOMAIN ?? "samanandish.com";
|
||||||
|
|
||||||
export async function fetchHomeStaticImageSlots(signal?: AbortSignal) {
|
function tenantEndpoint(path: string) {
|
||||||
const endpoint = `${API_BASE_URL}/tenants/${encodeURIComponent(WEBSITE_DOMAIN)}/website/static-images?pageKey=home`;
|
return `${API_BASE_URL}/tenants/${encodeURIComponent(WEBSITE_DOMAIN)}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getJson<T>(endpoint: string, signal?: AbortSignal) {
|
||||||
const response = await fetch(endpoint, {
|
const response = await fetch(endpoint, {
|
||||||
headers: { Accept: "application/json" },
|
headers: { Accept: "application/json" },
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
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 : [];
|
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) {
|
export function cssAspectRatio(value?: string | null) {
|
||||||
if (!value || !/^\d+(?:\.\d+)?:\d+(?:\.\d+)?$/.test(value)) {
|
if (!value || !/^\d+(?:\.\d+)?:\d+(?:\.\d+)?$/.test(value)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
+82
-54
@@ -5,14 +5,13 @@ import {
|
|||||||
ArrowUpLeft,
|
ArrowUpLeft,
|
||||||
BriefcaseBusiness,
|
BriefcaseBusiness,
|
||||||
Camera,
|
Camera,
|
||||||
ChevronDown,
|
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
CircleUserRound,
|
CircleUserRound,
|
||||||
FlaskConical,
|
FlaskConical,
|
||||||
Gauge,
|
Gauge,
|
||||||
Globe2,
|
Globe2,
|
||||||
Link,
|
Link as LinkIcon,
|
||||||
Mail,
|
Mail,
|
||||||
MapPin,
|
MapPin,
|
||||||
Menu,
|
Menu,
|
||||||
@@ -25,13 +24,18 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import NextLink from "next/link";
|
||||||
import type { FormEvent } from "react";
|
import type { FormEvent } from "react";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
cssAspectRatio,
|
cssAspectRatio,
|
||||||
fetchHomeStaticImageSlots,
|
fetchHomeStaticImageSlots,
|
||||||
|
fetchLatestProducts,
|
||||||
|
productPath,
|
||||||
|
type Product,
|
||||||
type StaticImageSlot,
|
type StaticImageSlot,
|
||||||
} from "./lib/meshkee";
|
} from "./lib/meshkee";
|
||||||
|
import HeaderNavigation from "./components/HeaderNavigation";
|
||||||
|
|
||||||
const assetBase = "/assets";
|
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: "دستگاه کوادینگ ۶۰۰ میلیمتر",
|
title: "دستگاه کوادینگ ۶۰۰ میلیمتر",
|
||||||
en: "600mm Quadding Machine | QUA-21 | LESMO",
|
en: "600mm Quadding Machine | QUA-21 | LESMO",
|
||||||
category: "دستگاه تابنده",
|
category: "دستگاه تابنده",
|
||||||
image: `${assetBase}/product-quadding.jpg`,
|
image: `${assetBase}/product-quadding.jpg`,
|
||||||
code: "LESMO / QUA-21",
|
code: "LESMO / QUA-21",
|
||||||
|
href: "#products",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: "fallback-2",
|
||||||
title: "بریدر ۱۶ کریر",
|
title: "بریدر ۱۶ کریر",
|
||||||
en: "16 Carriers Braider | 16N4 | SPIRKA",
|
en: "16 Carriers Braider | 16N4 | SPIRKA",
|
||||||
category: "دستگاه بافنده",
|
category: "دستگاه بافنده",
|
||||||
image: `${assetBase}/product-braider.jpg`,
|
image: `${assetBase}/product-braider.jpg`,
|
||||||
code: "SPIRKA / 16N4",
|
code: "SPIRKA / 16N4",
|
||||||
|
href: "#products",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 3,
|
id: "fallback-3",
|
||||||
title: "خط کشش ۰.۵–۰.۱۵ میلیمتر",
|
title: "خط کشش ۰.۵–۰.۱۵ میلیمتر",
|
||||||
en: "0.15–0.5 SQmm Drawing Line | TRB3 | SAMP",
|
en: "0.15–0.5 SQmm Drawing Line | TRB3 | SAMP",
|
||||||
category: "دستگاه کشش",
|
category: "دستگاه کشش",
|
||||||
image: `${assetBase}/product-drawing-line.jpg`,
|
image: `${assetBase}/product-drawing-line.jpg`,
|
||||||
code: "SAMP / TRB3",
|
code: "SAMP / TRB3",
|
||||||
|
href: "#products",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 4,
|
id: "fallback-4",
|
||||||
title: "پیآف ۶۰۰ میلیمتر",
|
title: "پیآف ۶۰۰ میلیمتر",
|
||||||
en: "600mm Pay Off | TAP D-0800 | KDM",
|
en: "600mm Pay Off | TAP D-0800 | KDM",
|
||||||
category: "دستگاه پیآف",
|
category: "دستگاه پیآف",
|
||||||
image: `${assetBase}/product-payoff.jpg`,
|
image: `${assetBase}/product-payoff.jpg`,
|
||||||
code: "KDM / TAP D-0800",
|
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 = [
|
const news = [
|
||||||
{
|
{
|
||||||
title: "شرکت مهندسی، مشاوره و بازرگانی HService",
|
title: "شرکت مهندسی، مشاوره و بازرگانی HService",
|
||||||
@@ -179,6 +220,8 @@ export default function Home() {
|
|||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [searchOpen, setSearchOpen] = useState(false);
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
const [staticImageSlots, setStaticImageSlots] = useState<StaticImageSlot[]>([]);
|
const [staticImageSlots, setStaticImageSlots] = useState<StaticImageSlot[]>([]);
|
||||||
|
const [displayProducts, setDisplayProducts] =
|
||||||
|
useState<DisplayProduct[]>(fallbackProducts);
|
||||||
const searchInput = useRef<HTMLInputElement>(null);
|
const searchInput = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const slotsByKey = useMemo(
|
const slotsByKey = useMemo(
|
||||||
@@ -242,6 +285,14 @@ export default function Home() {
|
|||||||
// Keep the local image fallbacks when the API is unavailable.
|
// 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();
|
return () => controller.abort();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -258,14 +309,14 @@ export default function Home() {
|
|||||||
|
|
||||||
const results = useMemo(() => {
|
const results = useMemo(() => {
|
||||||
const normalized = query.trim().toLowerCase();
|
const normalized = query.trim().toLowerCase();
|
||||||
if (!normalized) return products.slice(0, 3);
|
if (!normalized) return displayProducts.slice(0, 3);
|
||||||
return products.filter((product) =>
|
return displayProducts.filter((product) =>
|
||||||
[product.title, product.en, product.category, product.code]
|
[product.title, product.en, product.category, product.code]
|
||||||
.join(" ")
|
.join(" ")
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.includes(normalized),
|
.includes(normalized),
|
||||||
);
|
);
|
||||||
}, [query]);
|
}, [displayProducts, query]);
|
||||||
|
|
||||||
const focusSearch = () => {
|
const focusSearch = () => {
|
||||||
document.getElementById("smart-search")?.scrollIntoView({
|
document.getElementById("smart-search")?.scrollIntoView({
|
||||||
@@ -310,19 +361,11 @@ export default function Home() {
|
|||||||
<a className="logo" href="#" aria-label="سامان اندیش">
|
<a className="logo" href="#" aria-label="سامان اندیش">
|
||||||
<img src={`${assetBase}/KN83Drb6oI.png`} alt="سامان اندیش" />
|
<img src={`${assetBase}/KN83Drb6oI.png`} alt="سامان اندیش" />
|
||||||
</a>
|
</a>
|
||||||
<nav className="desktop-nav" aria-label="ناوبری اصلی">
|
<HeaderNavigation
|
||||||
<a className="active" href="#">
|
active="home"
|
||||||
صفحه اصلی
|
mobileOpen={mobileOpen}
|
||||||
</a>
|
onNavigate={() => setMobileOpen(false)}
|
||||||
<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>
|
|
||||||
<div className="header__actions">
|
<div className="header__actions">
|
||||||
<button className="icon-button" onClick={focusSearch} aria-label="جستجو">
|
<button className="icon-button" onClick={focusSearch} aria-label="جستجو">
|
||||||
<Search size={22} />
|
<Search size={22} />
|
||||||
@@ -343,23 +386,6 @@ export default function Home() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</header>
|
||||||
|
|
||||||
<section className="hero">
|
<section className="hero">
|
||||||
@@ -390,10 +416,10 @@ export default function Home() {
|
|||||||
<h1>{activeHero.title}</h1>
|
<h1>{activeHero.title}</h1>
|
||||||
<p className="hero__description">{activeHero.text}</p>
|
<p className="hero__description">{activeHero.text}</p>
|
||||||
<div className="hero__buttons">
|
<div className="hero__buttons">
|
||||||
<a className="button button--red" href="#products">
|
<NextLink className="button button--red" href="/products">
|
||||||
مشاهده محصولات
|
مشاهده محصولات
|
||||||
<ArrowLeft size={19} />
|
<ArrowLeft size={19} />
|
||||||
</a>
|
</NextLink>
|
||||||
<a className="button button--glass" href="/about-us">
|
<a className="button button--glass" href="/about-us">
|
||||||
درباره سامان اندیش
|
درباره سامان اندیش
|
||||||
</a>
|
</a>
|
||||||
@@ -497,7 +523,7 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
{results.length ? (
|
{results.length ? (
|
||||||
results.map((product) => (
|
results.map((product) => (
|
||||||
<a href="#products" key={product.id}>
|
<NextLink href={product.href} key={product.id}>
|
||||||
<img src={product.image} alt={product.title} />
|
<img src={product.image} alt={product.title} />
|
||||||
<div>
|
<div>
|
||||||
<strong>{product.title}</strong>
|
<strong>{product.title}</strong>
|
||||||
@@ -505,7 +531,7 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
<span>{product.category}</span>
|
<span>{product.category}</span>
|
||||||
<ChevronLeft size={18} />
|
<ChevronLeft size={18} />
|
||||||
</a>
|
</NextLink>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="empty-result">
|
<div className="empty-result">
|
||||||
@@ -556,25 +582,27 @@ export default function Home() {
|
|||||||
<section className="products section-gray" id="products">
|
<section className="products section-gray" id="products">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<div className="section-heading-row">
|
<div className="section-heading-row">
|
||||||
<SectionTitle eyebrow="SPECIAL MACHINERY" title="ماشینآلات منتخب" />
|
<SectionTitle eyebrow="LATEST MACHINERY" title="جدیدترین ماشینآلات" />
|
||||||
<a className="text-link" href="#">
|
<NextLink className="text-link" href="/products">
|
||||||
مشاهده همه محصولات
|
مشاهده همه محصولات
|
||||||
<ArrowLeft size={18} />
|
<ArrowLeft size={18} />
|
||||||
</a>
|
</NextLink>
|
||||||
</div>
|
</div>
|
||||||
<div className="product-grid">
|
<div className="product-grid">
|
||||||
{products.map((product) => (
|
{displayProducts.map((product) => (
|
||||||
<article className="product-card" key={product.id}>
|
<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} />
|
<img src={product.image} alt={product.title} />
|
||||||
<span>فروش ویژه</span>
|
<span>جدیدترین</span>
|
||||||
<i>
|
<i>
|
||||||
<ArrowUpLeft />
|
<ArrowUpLeft />
|
||||||
</i>
|
</i>
|
||||||
</a>
|
</NextLink>
|
||||||
<div className="product-card__body">
|
<div className="product-card__body">
|
||||||
<small>{product.category}</small>
|
<small>{product.category}</small>
|
||||||
<h3>{product.title}</h3>
|
<h3>
|
||||||
|
<NextLink href={product.href}>{product.title}</NextLink>
|
||||||
|
</h3>
|
||||||
<p dir="ltr">{product.en}</p>
|
<p dir="ltr">{product.en}</p>
|
||||||
<div>
|
<div>
|
||||||
<code>{product.code}</code>
|
<code>{product.code}</code>
|
||||||
@@ -791,7 +819,7 @@ export default function Home() {
|
|||||||
<Camera />
|
<Camera />
|
||||||
</a>
|
</a>
|
||||||
<a href="#" aria-label="لینکدین">
|
<a href="#" aria-label="لینکدین">
|
||||||
<Link />
|
<LinkIcon />
|
||||||
</a>
|
</a>
|
||||||
<a href="#" aria-label="تلگرام">
|
<a href="#" aria-label="تلگرام">
|
||||||
<MessageCircle />
|
<MessageCircle />
|
||||||
@@ -801,7 +829,7 @@ export default function Home() {
|
|||||||
<div className="footer__links">
|
<div className="footer__links">
|
||||||
<h3>دسترسی سریع</h3>
|
<h3>دسترسی سریع</h3>
|
||||||
<a href="#">صفحه اصلی</a>
|
<a href="#">صفحه اصلی</a>
|
||||||
<a href="#products">ماشینآلات</a>
|
<NextLink href="/products">ماشینآلات</NextLink>
|
||||||
<a href="#news">اخبار و مقالات</a>
|
<a href="#news">اخبار و مقالات</a>
|
||||||
<a href="/about-us">درباره ما</a>
|
<a href="/about-us">درباره ما</a>
|
||||||
<a href="/contact-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