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:
co-authored by
Cursor
parent
4e517fe3b2
commit
184d2f1ea4
+2
-2
@@ -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 />
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}`,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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] : [];
|
||||
}
|
||||
Reference in New Issue
Block a user