Enhance product detail page and header with API-driven navigation.

Add gallery lightbox, tabs, marketplace CTAs, and similar products on product pages; load the business logo and level-2 category dropdown in the header.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-19 20:38:51 +03:30
co-authored by Cursor
parent 4e517fe3b2
commit 184d2f1ea4
11 changed files with 856 additions and 102 deletions
+2 -2
View File
@@ -2,7 +2,7 @@ import { AboutCta } from "@/components/AboutCta";
import { Articles } from "@/components/Articles";
import { Categories } from "@/components/Categories";
import { Footer } from "@/components/Footer";
import { Header } from "@/components/Header";
import { HeaderContainer } from "@/components/HeaderContainer";
import { Hero } from "@/components/Hero";
import { Products } from "@/components/Products";
import { SellCta } from "@/components/SellCta";
@@ -17,7 +17,7 @@ export default async function Home() {
return (
<>
<Header />
<HeaderContainer />
<main>
<Hero />
<Categories />
+49 -62
View File
@@ -1,31 +1,26 @@
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import { notFound } from "next/navigation";
import { ProductGallery } from "@/components/ProductGallery";
import { ProductMarketplaceCtas } from "@/components/ProductMarketplaceCtas";
import { ProductTabs } from "@/components/ProductTabs";
import { Products } from "@/components/Products";
import { SiteShell } from "@/components/SiteShell";
import { site } from "@/data/home";
import {
formatCondition,
formatPrice,
getUserProductBySlugOrNull,
listUserProducts,
productCategory,
productTitle,
} from "@/lib/api/user-products";
import { site } from "@/data/home";
import { resolveProductGallery } from "@/lib/user-product-gallery";
type PageProps = {
params: Promise<{ slug: string }>;
};
function resolveGallery(product: {
images?: { url: string }[] | null;
imageUrl?: string | null;
}) {
const fromImages =
product.images?.map((img) => img.url).filter(Boolean) ?? [];
if (fromImages.length > 0) return fromImages;
return product.imageUrl ? [product.imageUrl] : [];
}
export async function generateMetadata({
params,
}: PageProps): Promise<Metadata> {
@@ -48,13 +43,23 @@ export default async function UserProductDetailPage({ params }: PageProps) {
const category = productCategory(product);
const condition = formatCondition(product.condition);
const price = formatPrice(product.price, product.priceCurrency);
const gallery = resolveGallery(product);
const hero = gallery[0] ?? null;
const gallery = resolveProductGallery(product);
const city = product.cityNameFa || product.cityName || null;
const country = product.countryNameFa || product.countryName || null;
const description =
product.descriptionFa || product.description || product.descriptionEn;
const similarResult = product.categoryId
? await listUserProducts({
categoryId: product.categoryId,
pageSize: 5,
})
: { items: [], total: 0 };
const similarProducts = similarResult.items
.filter((item) => item.id !== product.id)
.slice(0, 4);
return (
<SiteShell
banner={{
@@ -74,44 +79,7 @@ export default async function UserProductDetailPage({ params }: PageProps) {
</div>
<div className="grid gap-8 lg:grid-cols-[1.1fr_0.9fr]">
<div>
<div className="relative aspect-[4/3] overflow-hidden border border-line bg-[#e8eef4]">
{hero ? (
<Image
src={hero}
alt={title}
fill
unoptimized
className="object-cover"
sizes="(max-width: 1024px) 100vw, 55vw"
priority
/>
) : (
<div className="absolute inset-0 grid place-items-center bg-[linear-gradient(135deg,#d7e0ea,#eef3f8)] text-sm text-muted">
بدون تصویر
</div>
)}
</div>
{gallery.length > 1 ? (
<div className="mt-3 grid grid-cols-4 gap-2">
{gallery.slice(0, 8).map((url) => (
<div
key={url}
className="relative aspect-square overflow-hidden border border-line bg-[#e8eef4]"
>
<Image
src={url}
alt=""
fill
unoptimized
className="object-cover"
sizes="120px"
/>
</div>
))}
</div>
) : null}
</div>
<ProductGallery images={gallery} alt={title} />
<div>
{product.titleEn ? (
@@ -163,21 +131,40 @@ export default async function UserProductDetailPage({ params }: PageProps) {
</p>
)}
{description ? (
<p className="mt-6 whitespace-pre-line text-base leading-8 text-muted">
{description}
</p>
) : null}
<div className="mt-8 flex flex-wrap gap-3">
<div className="mt-8 flex justify-end">
<a href={`tel:${site.phone}`} className="btn-primary">
تماس برای استعلام
</a>
<Link href="/user-products" className="btn-secondary">
بازگشت به لیست
</Link> </div>
</div>
</div>
</div>
<ProductTabs
description={description}
technicalNotes={product.technicalNotes}
deliveryNotes={product.deliveryNotes}
technicalValues={product.technicalValues}
/>
{similarProducts.length > 0 ? (
<Products
embedded
items={similarProducts}
total={similarResult.total}
kicker="Similar Products"
title="محصولات مشابه"
description="سایر ماشین‌آلات موجود در همین دسته‌بندی که ممکن است برای شما مناسب باشند."
showMoreLink={false}
titleHref={
product.categoryId
? `/user-products?categoryId=${product.categoryId}`
: "/user-products"
}
moreLabel="مشاهده همه در این دسته"
/>
) : null}
<ProductMarketplaceCtas />
</div>
</article>
</SiteShell>
+217 -26
View File
@@ -1,15 +1,165 @@
"use client";
import { useEffect, useState } from "react";
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
import { navItems, site } from "@/data/home";
export type NavProductCategory = {
id: string;
label: string;
href: string;
};
type HeaderProps = {
forceSolid?: boolean;
productCategories?: NavProductCategory[];
logoUrl?: string | null;
};
export function Header({ forceSolid = false }: HeaderProps) {
function ChevronDown({ open }: { open?: boolean }) {
return (
<svg
aria-hidden
viewBox="0 0 24 24"
className={`h-3.5 w-3.5 transition ${open ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M6 9l6 6 6-6" />
</svg>
);
}
function ProductsNavDropdown({
categories,
onNavigate,
variant = "desktop",
}: {
categories: NavProductCategory[];
onNavigate?: () => void;
variant?: "desktop" | "mobile";
}) {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (variant !== "desktop" || !open) return;
const onPointerDown = (event: MouseEvent) => {
if (!containerRef.current?.contains(event.target as Node)) {
setOpen(false);
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("mousedown", onPointerDown);
document.removeEventListener("keydown", onKeyDown);
};
}, [open, variant]);
if (variant === "mobile") {
return (
<div className="border-b border-white/10 pb-1">
<button
type="button"
className="flex w-full items-center justify-between px-2 py-3 !text-white/90"
aria-expanded={open}
onClick={() => setOpen((value) => !value)}
>
محصولات
<ChevronDown open={open} />
</button>
{open ? (
<div className="flex flex-col gap-1 pb-2 pr-3">
<a
href="/user-products"
className="rounded px-2 py-2 text-sm !text-white/80 transition hover:bg-white/10 hover:!text-white"
onClick={onNavigate}
>
همه محصولات
</a>
{categories.map((category) => (
<a
key={category.id}
href={category.href}
className="rounded px-2 py-2 text-sm !text-white/80 transition hover:bg-white/10 hover:!text-white"
onClick={onNavigate}
>
{category.label}
</a>
))}
</div>
) : null}
</div>
);
}
return (
<div
ref={containerRef}
className="relative"
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
>
<button
type="button"
className="flex items-center gap-1 px-3 py-2 text-sm !text-white/85 transition hover:!text-white"
aria-expanded={open}
aria-haspopup="menu"
onClick={() => setOpen((value) => !value)}
>
محصولات
<ChevronDown open={open} />
</button>
{open ? (
<div
role="menu"
className="absolute end-0 top-full z-50 mt-1 min-w-[15rem] border border-white/10 bg-brand-ink py-2 shadow-xl"
>
<a
href="/user-products"
role="menuitem"
className="block px-4 py-2.5 text-sm !text-white/85 transition hover:bg-white/10 hover:!text-white"
>
همه محصولات
</a>
{categories.length > 0 ? (
<div className="my-1 border-t border-white/10" />
) : null}
{categories.map((category) => (
<a
key={category.id}
href={category.href}
role="menuitem"
className="block px-4 py-2.5 text-sm !text-white/85 transition hover:bg-white/10 hover:!text-white"
>
{category.label}
</a>
))}
</div>
) : null}
</div>
);
}
export function Header({
forceSolid = false,
productCategories = [],
logoUrl = null,
}: HeaderProps) {
const [open, setOpen] = useState(false);
const [scrolled, setScrolled] = useState(forceSolid);
const hasProductDropdown = productCategories.length > 0;
useEffect(() => {
if (forceSolid) {
@@ -29,6 +179,52 @@ export function Header({ forceSolid = false }: HeaderProps) {
};
}, [open]);
const renderNavItem = (item: (typeof navItems)[number]) => {
if (item.href === "/user-products" && hasProductDropdown) {
return (
<ProductsNavDropdown
key={item.label}
categories={productCategories}
variant="desktop"
/>
);
}
return (
<a
key={item.label}
href={item.href}
className="px-3 py-2 text-sm !text-white/85 transition hover:!text-white"
>
{item.label}
</a>
);
};
const renderMobileNavItem = (item: (typeof navItems)[number]) => {
if (item.href === "/user-products" && hasProductDropdown) {
return (
<ProductsNavDropdown
key={item.label}
categories={productCategories}
variant="mobile"
onNavigate={() => setOpen(false)}
/>
);
}
return (
<a
key={item.label}
href={item.href}
className="px-2 py-3 !text-white/90"
onClick={() => setOpen(false)}
>
{item.label}
</a>
);
};
return (
<header
className={`fixed inset-x-0 top-0 z-50 transition-colors duration-300 ${
@@ -38,10 +234,22 @@ export function Header({ forceSolid = false }: HeaderProps) {
}`}
>
<div className="container-page flex items-center justify-between gap-4 py-4 text-white">
<a href="/" className="group flex items-center gap-3 !text-white">
<span className="grid h-10 w-10 place-items-center border border-brand bg-brand/20 font-industrial text-lg font-bold tracking-wide text-brand transition group-hover:bg-brand group-hover:!text-white">
M
</span>
<a href="/" className="group flex items-center gap-2 !text-white">
{logoUrl ? (
<Image
src={logoUrl}
alt={site.name}
width={295}
height={295}
unoptimized
className="h-10 w-10 shrink-0 object-contain"
priority
/>
) : (
<span className="grid h-10 w-10 shrink-0 place-items-center border border-brand bg-brand/20 font-industrial text-lg font-bold tracking-wide text-brand transition group-hover:bg-brand group-hover:!text-white">
M
</span>
)}
<span className="leading-none">
<span className="font-industrial block text-xl font-bold tracking-[0.14em] !text-white">
{site.nameEn}
@@ -51,15 +259,7 @@ export function Header({ forceSolid = false }: HeaderProps) {
</a>
<nav className="hidden items-center gap-1 lg:flex">
{navItems.map((item) => (
<a
key={item.label}
href={item.href}
className="px-3 py-2 text-sm !text-white/85 transition hover:!text-white"
>
{item.label}
</a>
))}
{navItems.map(renderNavItem)}
</nav>
<div className="hidden items-center gap-4 lg:flex">
@@ -84,7 +284,7 @@ export function Header({ forceSolid = false }: HeaderProps) {
type="button"
aria-label="منو"
className="grid h-10 w-10 place-items-center border border-white/25 !text-white lg:hidden"
onClick={() => setOpen((v) => !v)}
onClick={() => setOpen((value) => !value)}
>
<span className="sr-only">منو</span>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
@@ -108,16 +308,7 @@ export function Header({ forceSolid = false }: HeaderProps) {
{open ? (
<div className="border-t border-white/10 bg-brand-ink lg:hidden">
<nav className="container-page flex flex-col gap-1 py-4">
{navItems.map((item) => (
<a
key={item.label}
href={item.href}
className="px-2 py-3 !text-white/90"
onClick={() => setOpen(false)}
>
{item.label}
</a>
))}
{navItems.map(renderMobileNavItem)}
<a
href={`tel:${site.phone}`}
className="latin-digits mt-2 px-2 py-3 !text-brand"
+22
View File
@@ -0,0 +1,22 @@
import { getBusinessInfo } from "@/lib/api/business";
import { getLevel2ProductCategories } from "@/lib/api/categories";
import { Header } from "@/components/Header";
type HeaderContainerProps = {
forceSolid?: boolean;
};
export async function HeaderContainer({ forceSolid = false }: HeaderContainerProps) {
const [productCategories, businessInfo] = await Promise.all([
getLevel2ProductCategories(),
getBusinessInfo(),
]);
return (
<Header
forceSolid={forceSolid}
productCategories={productCategories}
logoUrl={businessInfo?.logoUrl ?? null}
/>
);
}
+177
View File
@@ -0,0 +1,177 @@
"use client";
import Image from "next/image";
import { useCallback, useEffect, useState } from "react";
type ProductGalleryProps = {
images: string[];
alt: string;
};
export function ProductGallery({ images, alt }: ProductGalleryProps) {
const [activeIndex, setActiveIndex] = useState(0);
const [lightboxOpen, setLightboxOpen] = useState(false);
const activeImage = images[activeIndex] ?? null;
const goTo = useCallback(
(index: number) => {
if (images.length === 0) return;
setActiveIndex((index + images.length) % images.length);
},
[images.length],
);
const closeLightbox = useCallback(() => setLightboxOpen(false), []);
useEffect(() => {
if (!lightboxOpen) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") closeLightbox();
if (event.key === "ArrowLeft") goTo(activeIndex + 1);
if (event.key === "ArrowRight") goTo(activeIndex - 1);
};
document.body.style.overflow = "hidden";
window.addEventListener("keydown", onKeyDown);
return () => {
document.body.style.overflow = "";
window.removeEventListener("keydown", onKeyDown);
};
}, [activeIndex, closeLightbox, goTo, lightboxOpen]);
if (!activeImage) {
return (
<div className="space-y-3">
<div className="relative aspect-[4/3] overflow-hidden border border-line bg-[#e8eef4]">
<div className="absolute inset-0 grid place-items-center bg-[linear-gradient(135deg,#d7e0ea,#eef3f8)] text-sm text-muted">
بدون تصویر
</div>
</div>
</div>
);
}
return (
<div className="space-y-3">
<button
type="button"
onClick={() => setLightboxOpen(true)}
className="group relative block aspect-[4/3] w-full cursor-zoom-in overflow-hidden border border-line bg-[#e8eef4] text-left"
aria-label="بزرگ‌نمایی تصویر"
>
<Image
src={activeImage}
alt={alt}
fill
unoptimized
className="object-cover transition duration-500 group-hover:scale-[1.08]"
sizes="(max-width: 1024px) 100vw, 55vw"
priority
/>
<span className="pointer-events-none absolute bottom-3 left-3 bg-brand-ink/80 px-2.5 py-1 text-xs font-bold text-white opacity-0 transition group-hover:opacity-100">
کلیک برای بزرگ‌نمایی
</span>
</button>
{images.length > 1 ? (
<div className="grid grid-cols-4 gap-2 sm:grid-cols-5">
{images.slice(0, 10).map((url, index) => {
const isActive = index === activeIndex;
return (
<button
key={`${url}-${index}`}
type="button"
onClick={() => setActiveIndex(index)}
className={`group relative aspect-square overflow-hidden border bg-[#e8eef4] transition ${
isActive
? "border-brand ring-2 ring-brand/30"
: "border-line hover:border-brand/50"
}`}
aria-label={`تصویر ${index + 1}`}
aria-current={isActive}
>
<Image
src={url}
alt=""
fill
unoptimized
className="object-cover transition duration-500 group-hover:scale-110"
sizes="120px"
/>
</button>
);
})}
</div>
) : null}
{lightboxOpen ? (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-brand-ink/92 p-4 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-label="گالری تصاویر"
onClick={closeLightbox}
>
<button
type="button"
onClick={closeLightbox}
className="absolute left-4 top-4 z-10 grid h-10 w-10 place-items-center rounded-full bg-white/10 text-2xl text-white transition hover:bg-white/20"
aria-label="بستن"
>
×
</button>
{images.length > 1 ? (
<>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
goTo(activeIndex - 1);
}}
className="absolute right-4 top-1/2 z-10 grid h-12 w-12 -translate-y-1/2 place-items-center rounded-full bg-white/10 text-2xl text-white transition hover:bg-white/20"
aria-label="تصویر قبلی"
>
›
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
goTo(activeIndex + 1);
}}
className="absolute left-4 top-1/2 z-10 grid h-12 w-12 -translate-y-1/2 place-items-center rounded-full bg-white/10 text-2xl text-white transition hover:bg-white/20"
aria-label="تصویر بعدی"
>
‹
</button>
</>
) : null}
<div
className="relative max-h-[85vh] w-full max-w-5xl"
onClick={(event) => event.stopPropagation()}
>
<div className="relative aspect-[4/3] w-full overflow-hidden">
<Image
src={activeImage}
alt={alt}
fill
unoptimized
className="object-contain"
sizes="100vw"
/>
</div>
{images.length > 1 ? (
<p className="mt-4 text-center text-sm text-white/70">
{activeIndex + 1} / {images.length}
</p>
) : null}
</div>
</div>
) : null}
</div>
);
}
+72
View File
@@ -0,0 +1,72 @@
import Link from "next/link";
import { site } from "@/data/home";
export function ProductMarketplaceCtas() {
return (
<div className="mt-12 space-y-4">
<section className="relative overflow-hidden border border-line bg-surface">
<div className="absolute -left-16 top-1/2 h-40 w-40 -translate-y-1/2 rounded-full bg-brand/10 blur-2xl" />
<div className="absolute -right-10 -top-10 h-32 w-32 rounded-full bg-brand/5 blur-xl" />
<div className="relative flex flex-col gap-6 p-6 md:flex-row md:items-center md:justify-between md:p-8">
<div className="max-w-2xl">
<p className="font-industrial text-xs tracking-[0.18em] text-brand">
SELL YOUR MACHINE
</p>
<h3 className="mt-2 text-xl font-extrabold text-brand-ink md:text-2xl">
ماشین برای فروش دارید؟
</h3>
<p className="mt-2 text-sm leading-7 text-muted md:text-base">
دستگاه نو یا کارکرده خود را در ماشینیفای ثبت کنید و مستقیم به
خریداران صنعتی واقعی معرفی کنید.
</p>
</div>
<a
href={site.sellStartUrl}
target="_blank"
rel="noopener noreferrer"
className="group btn-primary shrink-0 self-start"
>
ثبت آگهی فروش
<span
aria-hidden
className="font-industrial text-lg transition-transform duration-300 group-hover:-translate-x-1"
>
←
</span>
</a>
</div>
</section>
<section className="relative overflow-hidden bg-brand-ink text-white">
<div className="absolute inset-0 bg-[linear-gradient(120deg,rgba(0,109,212,0.35)_0%,transparent_55%)]" />
<div className="absolute bottom-0 left-0 h-24 w-24 rounded-full bg-brand/20 blur-2xl" />
<div className="relative flex flex-col gap-6 p-6 md:flex-row md:items-center md:justify-between md:p-8">
<div className="max-w-2xl">
<p className="font-industrial text-xs tracking-[0.18em] text-[#7ec2ff]">
LOOKING FOR A MACHINE
</p>
<h3 className="mt-2 text-xl font-extrabold md:text-2xl">
دنبال ماشین خاصی می‌گردید؟
</h3>
<p className="mt-2 text-sm leading-7 text-white/75 md:text-base">
نیاز خود را به تیم ماشینیفای اعلام کنید؛ ما مناسب‌ترین گزینه‌ها
را از میان موجودی فروشندگان پیدا می‌کنیم.
</p>
</div>
<div className="flex shrink-0 flex-wrap gap-3 self-start">
<Link href="/contact" className="btn-primary !bg-white !text-brand-ink hover:!bg-white/90">
درخواست جستجو
</Link>
<a href={`tel:${site.phone}`} className="btn-ghost !min-h-[3rem]">
{site.phoneDisplay}
</a>
</div>
</div>
</section>
</div>
);
}
+213
View File
@@ -0,0 +1,213 @@
"use client";
import { useMemo, useState, type ReactNode } from "react";
import { site } from "@/data/home";
type ProductTabsProps = {
description?: string | null;
technicalNotes?: string | null;
deliveryNotes?: string | null;
technicalValues?: unknown;
};
type TabId = "description" | "technical" | "comments";
const tabMeta: Record<
TabId,
{ label: string; labelEn: string }
> = {
description: { label: "توضیحات", labelEn: "Description" },
technical: { label: "مشخصات فنی", labelEn: "Technical Info" },
comments: { label: "نظرات", labelEn: "Comments" },
};
function renderTechnicalValues(value: unknown): ReactNode {
if (value === null || value === undefined) return null;
if (typeof value === "string") {
return (
<p className="whitespace-pre-line text-base leading-8 text-muted">
{value}
</p>
);
}
if (Array.isArray(value)) {
if (value.length === 0) return null;
return (
<ul className="space-y-2 text-base leading-8 text-muted">
{value.map((item, index) => (
<li key={index} className="flex gap-2">
<span className="text-brand">•</span>
<span>{typeof item === "object" ? JSON.stringify(item) : String(item)}</span>
</li>
))}
</ul>
);
}
if (typeof value === "object") {
const entries = Object.entries(value as Record<string, unknown>);
if (entries.length === 0) return null;
return (
<div className="overflow-x-auto">
<table className="w-full min-w-[20rem] border-collapse text-sm">
<tbody>
{entries.map(([key, val]) => (
<tr key={key} className="border-b border-line">
<th className="px-4 py-3 text-right font-bold text-brand-ink">
{key}
</th>
<td className="px-4 py-3 text-muted">
{val === null || val === undefined ? "—" : String(val)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
return <p className="text-base leading-8 text-muted">{String(value)}</p>;
}
export function ProductTabs({
description,
technicalNotes,
deliveryNotes,
technicalValues,
}: ProductTabsProps) {
const tabs = useMemo(() => {
const items: { id: TabId; label: string; labelEn: string }[] = [];
if (description?.trim()) {
items.push({ id: "description", ...tabMeta.description });
}
const hasTechnical =
Boolean(technicalNotes?.trim()) ||
Boolean(deliveryNotes?.trim()) ||
(technicalValues !== null &&
technicalValues !== undefined &&
!(typeof technicalValues === "object" &&
!Array.isArray(technicalValues) &&
Object.keys(technicalValues as object).length === 0) &&
!(Array.isArray(technicalValues) && technicalValues.length === 0));
if (hasTechnical) {
items.push({ id: "technical", ...tabMeta.technical });
}
items.push({ id: "comments", ...tabMeta.comments });
return items;
}, [deliveryNotes, description, technicalNotes, technicalValues]);
const [activeTab, setActiveTab] = useState<TabId>(tabs[0]?.id ?? "comments");
if (tabs.length === 0) return null;
return (
<div className="mt-12 border border-line bg-surface">
<div
role="tablist"
aria-label="جزئیات محصول"
className="flex flex-wrap justify-center gap-1 border-b border-line px-2"
>
{tabs.map((tab) => {
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={isActive}
onClick={() => setActiveTab(tab.id)}
className={`relative flex flex-col items-center px-3 py-2 transition ${
isActive
? "text-brand"
: "text-muted hover:text-brand-ink"
}`}
>
<span className="text-sm font-extrabold">{tab.label}</span>
<span
className={`mt-0.5 font-industrial text-[0.58rem] tracking-[0.16em] uppercase ${
isActive ? "text-brand/70" : "text-steel"
}`}
>
{tab.labelEn}
</span>
{isActive ? (
<span className="absolute inset-x-0 bottom-0 h-0.5 bg-brand" />
) : null}
</button>
);
})}
</div>
<div className="p-4 md:p-5">
{activeTab === "description" && description ? (
<div role="tabpanel">
<p className="whitespace-pre-line text-base leading-8 text-muted">
{description}
</p>
</div>
) : null}
{activeTab === "technical" ? (
<div role="tabpanel" className="space-y-6">
{technicalNotes ? (
<div>
<h3 className="font-industrial text-xs tracking-[0.16em] text-brand">
TECHNICAL NOTES
</h3>
<p className="mt-3 whitespace-pre-line text-base leading-8 text-muted">
{technicalNotes}
</p>
</div>
) : null}
{renderTechnicalValues(technicalValues)}
{deliveryNotes ? (
<div>
<h3 className="font-industrial text-xs tracking-[0.16em] text-brand">
DELIVERY
</h3>
<p className="mt-3 whitespace-pre-line text-base leading-8 text-muted">
{deliveryNotes}
</p>
</div>
) : null}
</div>
) : null}
{activeTab === "comments" ? (
<div role="tabpanel" className="text-center">
<div className="mx-auto max-w-md py-3">
<p className="font-industrial text-sm tracking-[0.16em] text-brand">
COMMENTS
</p>
<h3 className="mt-3 text-xl font-extrabold text-brand-ink">
برای ثبت نظر وارد حساب کاربری شوید
</h3>
<p className="mt-3 text-sm leading-7 text-muted">
پس از ورود می‌توانید تجربه خود از این محصول را با سایر خریداران
به اشتراک بگذارید.
</p>
<a
href={site.customerLoginUrl}
target="_blank"
rel="noopener noreferrer"
className="btn-primary mt-6 inline-flex"
>
ورود / ثبت‌نام
</a>
</div>
</div>
) : null}
</div>
</div>
);
}
+65 -10
View File
@@ -14,6 +14,13 @@ type ProductsProps = {
query?: string;
showHeader?: boolean;
showMoreLink?: boolean;
kicker?: string;
title?: string;
description?: string;
moreHref?: string;
moreLabel?: string;
titleHref?: string;
embedded?: boolean;
};
export function Products({
@@ -22,18 +29,56 @@ export function Products({
query,
showHeader = true,
showMoreLink = true,
kicker = "Stock Machines",
title = "محصولات موجود",
description,
moreHref = "/user-products",
moreLabel,
titleHref,
embedded = false,
}: ProductsProps) {
return (
<section id="products" className={showHeader ? "section" : undefined}>
<div className={showHeader ? "container-page" : undefined}>
const defaultDescription =
query
? `نتایج جستجو برای «${query}»`
: "منتخب ماشین‌آلات سنگین و صنعتی از فروشگاه ماشینیفای؛ برای جزئیات و قیمت، استعلام بگیرید.";
const content = (
<div className={embedded ? undefined : showHeader ? "container-page" : undefined}>
{showHeader ? (
<div className="section-head">
<p className="section-kicker">Stock Machines</p>
<h2 className="section-title">محصولات موجود</h2>
<p className="section-kicker">{kicker}</p>
{titleHref ? (
<div
dir="ltr"
className="flex w-full items-center justify-between gap-3"
>
<Link
href={titleHref}
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-brand text-brand transition hover:bg-brand hover:text-white"
aria-label={moreLabel ?? "مشاهده همه"}
>
<svg
aria-hidden
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M15 18 9 12l6-6" />
</svg>
</Link>
<h2 dir="rtl" className="section-title !mb-0 text-right">
{title}
</h2>
</div>
) : (
<h2 className="section-title">{title}</h2>
)}
<p className="section-desc">
{query
? `نتایج جستجو برای «${query}»`
: "منتخب ماشین‌آلات سنگین و صنعتی از فروشگاه ماشینیفای؛ برای جزئیات و قیمت، استعلام بگیرید."}
{description ?? defaultDescription}
</p>
</div>
) : query ? (
@@ -126,13 +171,23 @@ export function Products({
{showMoreLink ? (
<div className="mt-8 text-center">
<Link href="/user-products" className="btn-primary">
{total > items.length ? "محصولات بیشتر" : "همه محصولات"}
<Link href={moreHref} className="btn-primary">
{moreLabel ??
(total > items.length ? "محصولات بیشتر" : "همه محصولات")}
</Link>
</div>
) : null} </>
)}
</div>
);
if (embedded) {
return <div className="mt-12">{content}</div>;
}
return (
<section id="products" className={showHeader ? "section" : undefined}>
{content}
</section>
);
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { Footer } from "@/components/Footer";
import { Header } from "@/components/Header";
import { HeaderContainer } from "@/components/HeaderContainer";
import { PageBanner } from "@/components/PageBanner";
type SiteShellProps = {
@@ -19,7 +19,7 @@ export function SiteShell({
}: SiteShellProps) {
return (
<>
<Header forceSolid={solidHeader} />
<HeaderContainer forceSolid={solidHeader} />
<main className={solidHeader ? "pt-[4.5rem]" : undefined}>
{banner ? (
<PageBanner
+28
View File
@@ -48,3 +48,31 @@ export function findCategoryById(categories: Category[], id?: string | null) {
if (!id) return null;
return categories.find((item) => item.id === id) ?? null;
}
export function listRootProductCategories(categories: Category[]) {
return buildCategoryTree(categories).filter((category) => category.isActive);
}
export function listLevel2ProductCategories(categories: Category[]) {
return listRootProductCategories(categories).flatMap((root) =>
root.children.filter((category) => category.isActive),
);
}
export async function getRootProductCategories() {
const categories = await listCategories("product");
return listRootProductCategories(categories).map((category) => ({
id: category.id,
label: categoryLabel(category),
href: `/user-products?categoryId=${encodeURIComponent(category.id)}`,
}));
}
export async function getLevel2ProductCategories() {
const categories = await listCategories("product");
return listLevel2ProductCategories(categories).map((category) => ({
id: category.id,
label: categoryLabel(category),
href: `/user-products?categoryId=${encodeURIComponent(category.id)}`,
}));
}
+9
View File
@@ -0,0 +1,9 @@
export function resolveProductGallery(product: {
images?: { url: string }[] | null;
imageUrl?: string | null;
}) {
const fromImages =
product.images?.map((img) => img.url).filter(Boolean) ?? [];
if (fromImages.length > 0) return fromImages;
return product.imageUrl ? [product.imageUrl] : [];
}