Connect Meshkee APIs and add catalog, blog, and content pages.
Wire user-products and blogs to the Website API, add about/contact/categories routes, and polish flat UI filters and page banners. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
e74e5ca45f
commit
8194b7292f
@@ -0,0 +1,185 @@
|
||||
import type { Metadata } from "next";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { SiteShell } from "@/components/SiteShell";
|
||||
import {
|
||||
formatCondition,
|
||||
formatPrice,
|
||||
getUserProductBySlugOrNull,
|
||||
productCategory,
|
||||
productTitle,
|
||||
} from "@/lib/api/user-products";
|
||||
import { site } from "@/data/home";
|
||||
|
||||
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> {
|
||||
const { slug } = await params;
|
||||
const product = await getUserProductBySlugOrNull(slug);
|
||||
if (!product) return { title: "محصول یافت نشد" };
|
||||
const title = productTitle(product);
|
||||
return {
|
||||
title,
|
||||
description: product.descriptionFa || product.description || title,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function UserProductDetailPage({ params }: PageProps) {
|
||||
const { slug } = await params;
|
||||
const product = await getUserProductBySlugOrNull(slug);
|
||||
if (!product) notFound();
|
||||
|
||||
const title = productTitle(product);
|
||||
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 city = product.cityNameFa || product.cityName || null;
|
||||
const country = product.countryNameFa || product.countryName || null;
|
||||
const description =
|
||||
product.descriptionFa || product.description || product.descriptionEn;
|
||||
|
||||
return (
|
||||
<SiteShell
|
||||
banner={{
|
||||
kicker: "Product Details",
|
||||
title,
|
||||
description: [category, condition].filter(Boolean).join(" · ") || undefined,
|
||||
}}
|
||||
>
|
||||
<article className="section">
|
||||
<div className="container-page">
|
||||
<div className="mb-6 text-sm text-muted">
|
||||
<Link href="/user-products" className="text-brand hover:underline">
|
||||
محصولات
|
||||
</Link>
|
||||
<span className="mx-2">/</span>
|
||||
<span>{title}</span>
|
||||
</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>
|
||||
|
||||
<div>
|
||||
{product.titleEn ? (
|
||||
<p className="font-industrial text-sm tracking-[0.14em] text-brand">
|
||||
{product.titleEn}
|
||||
</p>
|
||||
) : null}
|
||||
<h2 className="mt-2 text-3xl font-extrabold leading-tight text-brand-ink md:text-4xl">
|
||||
{title}
|
||||
</h2>
|
||||
{category ? (
|
||||
<p className="mt-3 text-sm font-bold text-muted">{category}</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-6 grid gap-3 sm:grid-cols-2">
|
||||
{condition ? (
|
||||
<div className="border border-brand/25 bg-brand/[0.06] px-4 py-3">
|
||||
<p className="font-industrial text-[0.65rem] tracking-[0.16em] text-brand">
|
||||
CONDITION
|
||||
</p>
|
||||
<p className="mt-1 text-base font-extrabold text-brand-ink">
|
||||
{condition}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{city || country ? (
|
||||
<div className="border border-line bg-[#f7fafc] px-4 py-3">
|
||||
<p className="font-industrial text-[0.65rem] tracking-[0.16em] text-steel">
|
||||
LOCATION
|
||||
</p>
|
||||
<p className="mt-1 text-base font-extrabold text-brand-ink">
|
||||
{[city, country].filter(Boolean).join("، ")}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{price ? (
|
||||
<p
|
||||
dir="ltr"
|
||||
className="font-industrial mt-6 text-left text-2xl font-extrabold tracking-wide text-brand"
|
||||
>
|
||||
{price}
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-6 text-base font-bold text-muted">
|
||||
قیمت با استعلام
|
||||
</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">
|
||||
<a href={`tel:${site.phone}`} className="btn-primary">
|
||||
تماس برای استعلام
|
||||
</a>
|
||||
<Link href="/user-products" className="btn-secondary">
|
||||
بازگشت به لیست
|
||||
</Link> </div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user