mirror of
https://git.meshkee.com/Meshkee-Websites/mashinify.git
synced 2026-08-11 20:29:34 +04:30
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
+8
-1
@@ -1,7 +1,14 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{ protocol: "https", hostname: "**.parspack.net" },
|
||||
{ protocol: "https", hostname: "cdn.meshkee.com" },
|
||||
{ protocol: "https", hostname: "**.meshkee.com" },
|
||||
{ protocol: "https", hostname: "api.meshkee.com" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { Metadata } from "next";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { SiteShell } from "@/components/SiteShell";
|
||||
import { getBusinessInfo } from "@/lib/api/business";
|
||||
import { site, stats } from "@/data/home";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "درباره ما",
|
||||
description: site.description,
|
||||
};
|
||||
|
||||
export default async function AboutPage() {
|
||||
const info = await getBusinessInfo();
|
||||
const name = info?.nameFa || info?.name || site.name;
|
||||
const about =
|
||||
info?.about?.trim() ||
|
||||
`${site.description} ماشینیفای بستری برای خرید و فروش امن ماشینآلات صنعتی است؛ با تمرکز روی شفافیت، پشتیبانی تخصصی و دسترسی سریع به موجودی نو و کارکرده.`;
|
||||
const vision =
|
||||
info?.vision?.trim() ||
|
||||
"ایجاد قابلاعتمادترین بازار ماشینآلات صنعتی برای تولیدکنندگان، کارگاهها و فروشندگان در ایران.";
|
||||
|
||||
return (
|
||||
<SiteShell
|
||||
banner={{
|
||||
kicker: "About Us",
|
||||
title: "درباره ما",
|
||||
description: `آشنایی با ${name} و مسیر فعالیت ما در بازار ماشینآلات صنعتی.`,
|
||||
}}
|
||||
>
|
||||
<section className="section">
|
||||
<div className="container-page grid gap-10 lg:grid-cols-2 lg:items-center">
|
||||
<div className="relative min-h-[20rem] overflow-hidden border border-line bg-[#e8eef4] md:min-h-[26rem]">
|
||||
<Image
|
||||
src="/images/cta/about.jpg"
|
||||
alt={name}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 1024px) 100vw, 50vw"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="section-kicker">Who We Are</p>
|
||||
<h2 className="section-title mt-2">{name}</h2>
|
||||
<p className="mt-4 text-base leading-8 text-muted">{about}</p>
|
||||
<p className="mt-4 text-base leading-8 text-muted">{vision}</p>
|
||||
|
||||
<div className="mt-8 grid grid-cols-3 gap-3 border-y border-line py-5">
|
||||
{stats.map((item) => (
|
||||
<div key={item.label}>
|
||||
<div className="font-industrial text-xl font-bold tracking-wide text-brand md:text-2xl">
|
||||
{item.value}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted md:text-sm">
|
||||
{item.label}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
<Link href="/contact" className="btn-primary">
|
||||
تماس با ما
|
||||
</Link>
|
||||
<Link href="/user-products" className="btn-secondary">
|
||||
مشاهده محصولات
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
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 { getBlogBySlugOrNull, resolveBlogImage } from "@/lib/api/blogs";
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ slug: string }>;
|
||||
};
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: PageProps): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const blog = await getBlogBySlugOrNull(slug);
|
||||
if (!blog) return { title: "مقاله یافت نشد" };
|
||||
return {
|
||||
title: blog.title,
|
||||
description: blog.abstract ?? blog.title,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function BlogDetailPage({ params }: PageProps) {
|
||||
const { slug } = await params;
|
||||
const blog = await getBlogBySlugOrNull(slug);
|
||||
if (!blog) notFound();
|
||||
|
||||
const cover = resolveBlogImage(blog);
|
||||
const authorName = blog.author
|
||||
? [blog.author.firstName, blog.author.lastName].filter(Boolean).join(" ")
|
||||
: null;
|
||||
const published = blog.publishedAt
|
||||
? new Intl.DateTimeFormat("fa-IR", { dateStyle: "medium" }).format(
|
||||
new Date(blog.publishedAt),
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<SiteShell
|
||||
banner={{
|
||||
kicker: "Blog",
|
||||
title: blog.title,
|
||||
description: blog.categoryName || blog.abstract || undefined,
|
||||
}}
|
||||
>
|
||||
<article className="section bg-surface">
|
||||
<div className="container-page max-w-3xl">
|
||||
<div className="mb-6 text-sm text-muted">
|
||||
<Link href="/blog" className="text-brand hover:underline">
|
||||
مقالات
|
||||
</Link>
|
||||
<span className="mx-2">/</span>
|
||||
<span>{blog.title}</span>
|
||||
</div>
|
||||
|
||||
{blog.categoryName ? (
|
||||
<p className="text-sm font-bold text-brand">{blog.categoryName}</p>
|
||||
) : null}
|
||||
<h2 className="mt-2 text-3xl font-extrabold leading-tight text-brand-ink md:text-4xl">
|
||||
{blog.title}
|
||||
</h2>
|
||||
<div className="mt-4 flex flex-wrap gap-3 text-sm text-muted">
|
||||
{authorName ? <span>{authorName}</span> : null}
|
||||
{published ? (
|
||||
<span className="latin-digits">{published}</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{blog.abstract ? (
|
||||
<p className="mt-6 text-base leading-8 text-muted">{blog.abstract}</p>
|
||||
) : null}
|
||||
|
||||
{cover ? (
|
||||
<div className="relative mt-8 aspect-[16/9] overflow-hidden border border-line bg-[#e8eef4]">
|
||||
<Image
|
||||
src={cover}
|
||||
alt={blog.title}
|
||||
fill
|
||||
unoptimized
|
||||
className="object-cover"
|
||||
sizes="(max-width: 768px) 100vw, 768px"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{blog.mainTextHtml ? (
|
||||
<div
|
||||
className="prose-blog mt-10 max-w-none text-base leading-8 text-brand-ink [&_h2]:mt-8 [&_h2]:mb-3 [&_h2]:text-2xl [&_h2]:font-extrabold [&_h3]:mt-6 [&_h3]:mb-2 [&_h3]:text-xl [&_h3]:font-bold [&_img]:my-6 [&_img]:h-auto [&_img]:w-full [&_p]:mb-4 [&_table]:my-6 [&_table]:w-full [&_td]:border [&_td]:border-line [&_td]:p-2 [&_th]:border [&_th]:border-line [&_th]:p-2 [&_ul]:mb-4 [&_ul]:list-disc [&_ul]:pr-5"
|
||||
dangerouslySetInnerHTML={{ __html: blog.mainTextHtml }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="mt-10">
|
||||
<Link
|
||||
href="/blog"
|
||||
className="inline-flex items-center justify-center border border-brand px-5 py-3 text-sm font-bold text-brand transition hover:bg-brand hover:text-white"
|
||||
>
|
||||
بازگشت به مقالات
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { SiteShell } from "@/components/SiteShell";
|
||||
import { Articles } from "@/components/Articles";
|
||||
import { listBlogs } from "@/lib/api/blogs";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "مقالات",
|
||||
description: "مقالات و مطالب تخصصی ماشینیفای",
|
||||
};
|
||||
|
||||
type PageProps = {
|
||||
searchParams: Promise<{ page?: string; title?: string; type?: string }>;
|
||||
};
|
||||
|
||||
export default async function BlogIndexPage({ searchParams }: PageProps) {
|
||||
const params = await searchParams;
|
||||
const page = Number(params.page || "1") || 1;
|
||||
const title = params.title?.trim() || undefined;
|
||||
const type = (params.type as "news" | "article" | "blog" | undefined) || undefined;
|
||||
|
||||
const data = await listBlogs({
|
||||
page,
|
||||
pageSize: 12,
|
||||
title,
|
||||
type,
|
||||
});
|
||||
|
||||
return (
|
||||
<SiteShell
|
||||
banner={{
|
||||
kicker: "Blog",
|
||||
title: "مقالات",
|
||||
description:
|
||||
"راهنماها و نکات تخصصی انتخاب، خرید و نگهداری ماشینآلات صنعتی.",
|
||||
}}
|
||||
>
|
||||
<section className="section bg-surface">
|
||||
<div className="container-page">
|
||||
<form
|
||||
method="get"
|
||||
className="filter-shell mb-8 flex flex-col gap-3 p-4 sm:flex-row md:p-5"
|
||||
>
|
||||
<input
|
||||
name="title"
|
||||
defaultValue={title ?? ""}
|
||||
placeholder="جستجوی عنوان مقاله..."
|
||||
className="field-control min-h-12 flex-1"
|
||||
/>
|
||||
<button type="submit" className="btn-primary min-h-12 px-6">
|
||||
جستجو
|
||||
</button>
|
||||
</form>
|
||||
<Articles
|
||||
items={data.items}
|
||||
total={data.total}
|
||||
showHeader={false}
|
||||
showMoreLink={false}
|
||||
/>
|
||||
|
||||
{data.total > data.pageSize ? (
|
||||
<div className="mt-8 flex items-center justify-center gap-3">
|
||||
{page > 1 ? (
|
||||
<Link
|
||||
href={`/blog?${new URLSearchParams({
|
||||
...(title ? { title } : {}),
|
||||
page: String(page - 1),
|
||||
}).toString()}`}
|
||||
className="border border-line px-4 py-2 text-sm font-bold text-brand-ink hover:border-brand"
|
||||
>
|
||||
قبلی
|
||||
</Link>
|
||||
) : null}
|
||||
<span className="latin-digits text-sm text-muted">
|
||||
صفحه {page}
|
||||
</span>
|
||||
{page * data.pageSize < data.total ? (
|
||||
<Link
|
||||
href={`/blog?${new URLSearchParams({
|
||||
...(title ? { title } : {}),
|
||||
page: String(page + 1),
|
||||
}).toString()}`}
|
||||
className="border border-line px-4 py-2 text-sm font-bold text-brand-ink hover:border-brand"
|
||||
>
|
||||
بعدی
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { Metadata } from "next";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { SiteShell } from "@/components/SiteShell";
|
||||
import { featuredCategoryTiles } from "@/data/featured-categories";
|
||||
import { categoryLabel, listCategories } from "@/lib/api/categories";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "دستهبندیها",
|
||||
description: "۹ دستهبندی منتخب ماشینآلات صنعتی از موجودی ماشینیفای",
|
||||
};
|
||||
|
||||
export default async function CategoriesPage() {
|
||||
const categories = await listCategories("product");
|
||||
const byId = new Map(categories.map((item) => [item.id, item]));
|
||||
|
||||
const tiles = featuredCategoryTiles
|
||||
.map((tile) => {
|
||||
const category = byId.get(tile.id);
|
||||
if (!category) return null;
|
||||
return {
|
||||
...tile,
|
||||
category,
|
||||
title: categoryLabel(category),
|
||||
titleEn: category.name,
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as Array<{
|
||||
id: string;
|
||||
image: string;
|
||||
title: string;
|
||||
titleEn: string;
|
||||
category: { id: string; slug: string };
|
||||
}>;
|
||||
|
||||
return (
|
||||
<SiteShell
|
||||
banner={{
|
||||
kicker: "Categories",
|
||||
title: "دستهبندیها",
|
||||
description:
|
||||
"۹ دسته منتخب از ماشینآلات صنعتی؛ برای مشاهده آگهیها روی هر کاشی کلیک کنید.",
|
||||
}}
|
||||
>
|
||||
<section className="section">
|
||||
<div className="container-page">
|
||||
{tiles.length === 0 ? (
|
||||
<div className="border border-line bg-surface px-6 py-14 text-center">
|
||||
<p className="font-bold text-brand-ink">
|
||||
دستهبندیای برای نمایش یافت نشد.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{tiles.map((tile) => (
|
||||
<Link
|
||||
key={tile.id}
|
||||
href={`/user-products?categoryId=${tile.id}`}
|
||||
className="group relative block aspect-[4/3] overflow-hidden border border-line bg-brand-ink"
|
||||
>
|
||||
<Image
|
||||
src={tile.image}
|
||||
alt={tile.title}
|
||||
fill
|
||||
className="object-cover transition duration-500 group-hover:scale-[1.04]"
|
||||
sizes="(max-width: 768px) 100vw, 33vw"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-brand-ink/55 transition group-hover:bg-brand-ink/45" />
|
||||
<div className="absolute inset-x-0 bottom-0 p-5 text-white">
|
||||
<p className="font-industrial text-[0.7rem] tracking-[0.16em] text-[#7ec2ff]">
|
||||
{tile.titleEn}
|
||||
</p>
|
||||
<h2 className="mt-1 text-xl font-extrabold">{tile.title}</h2>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { Metadata } from "next";
|
||||
import { SiteShell } from "@/components/SiteShell";
|
||||
import { ContactForm } from "@/components/ContactForm";
|
||||
import { getBusinessInfo } from "@/lib/api/business";
|
||||
import { site } from "@/data/home";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "تماس با ما",
|
||||
description: "ارسال پیام و راههای ارتباطی با ماشینیفای",
|
||||
};
|
||||
|
||||
export default async function ContactPage() {
|
||||
const info = await getBusinessInfo();
|
||||
const phones =
|
||||
info?.phoneNumbers?.filter(Boolean).length
|
||||
? info.phoneNumbers
|
||||
: [site.phoneDisplay];
|
||||
const emails = info?.emails?.filter(Boolean) ?? [];
|
||||
const addresses =
|
||||
info?.addresses?.length
|
||||
? info.addresses
|
||||
.map((item) => item.address)
|
||||
.filter(Boolean)
|
||||
: [site.address];
|
||||
|
||||
return (
|
||||
<SiteShell
|
||||
banner={{
|
||||
kicker: "Contact",
|
||||
title: "تماس با ما",
|
||||
description: "برای استعلام، پشتیبانی یا همکاری، پیام بفرستید.",
|
||||
}}
|
||||
>
|
||||
<section className="section">
|
||||
<div className="container-page grid gap-8 lg:grid-cols-[0.9fr_1.1fr]">
|
||||
<aside className="border border-line bg-surface p-6 md:p-8">
|
||||
<p className="section-kicker">Support</p>
|
||||
<h2 className="mt-2 text-2xl font-extrabold text-brand-ink">
|
||||
راههای ارتباطی
|
||||
</h2>
|
||||
<p className="mt-3 text-sm leading-7 text-muted">
|
||||
تیم پشتیبانی ماشینیفای آماده پاسخگویی به سوالات شما درباره خرید،
|
||||
فروش و استعلام ماشینآلات است.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 space-y-5 text-sm">
|
||||
<div>
|
||||
<p className="font-bold text-brand-ink">آدرس</p>
|
||||
{addresses.map((address) => (
|
||||
<p key={String(address)} className="mt-1 text-muted">
|
||||
{address}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="font-bold text-brand-ink">تلفن</p>
|
||||
<div className="mt-1 space-y-1">
|
||||
{phones.map((phone) => (
|
||||
<a
|
||||
key={phone}
|
||||
href={`tel:${phone.replace(/\s/g, "")}`}
|
||||
className="latin-digits block text-brand"
|
||||
dir="ltr"
|
||||
>
|
||||
{phone}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{emails.length > 0 ? (
|
||||
<div>
|
||||
<p className="font-bold text-brand-ink">ایمیل</p>
|
||||
<div className="mt-1 space-y-1">
|
||||
{emails.map((email) => (
|
||||
<a
|
||||
key={email}
|
||||
href={`mailto:${email}`}
|
||||
className="latin-digits block text-brand"
|
||||
dir="ltr"
|
||||
>
|
||||
{email}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="border border-line bg-surface p-6 md:p-8">
|
||||
<p className="section-kicker">Message</p>
|
||||
<h2 className="mt-2 text-2xl font-extrabold text-brand-ink">
|
||||
فرم تماس
|
||||
</h2>
|
||||
<p className="mt-3 mb-6 text-sm text-muted">
|
||||
پیام خود را ارسال کنید؛ در کوتاهترین زمان با شما تماس میگیریم.
|
||||
</p>
|
||||
<ContactForm />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
+51
-3
@@ -140,10 +140,11 @@ img {
|
||||
gap: 0.5rem;
|
||||
min-height: 3rem;
|
||||
padding: 0.7rem 1.35rem;
|
||||
border-radius: 0.75rem;
|
||||
background: var(--brand);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
transition: background 0.25s ease, transform 0.25s ease;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
@@ -157,10 +158,11 @@ img {
|
||||
gap: 0.5rem;
|
||||
min-height: 3rem;
|
||||
padding: 0.7rem 1.35rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.45);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
transition: background 0.25s ease, border-color 0.25s ease;
|
||||
transition: background 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
@@ -168,6 +170,46 @@ img {
|
||||
border-color: #fff;
|
||||
}
|
||||
|
||||
.field-control {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.75rem;
|
||||
background: #fff;
|
||||
padding: 0.7rem 1rem;
|
||||
color: var(--foreground);
|
||||
outline: none;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.field-control:focus,
|
||||
.field-control:focus-within {
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
min-height: 3rem;
|
||||
padding: 0.7rem 1.35rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid var(--brand);
|
||||
color: var(--brand);
|
||||
font-weight: 700;
|
||||
background: #fff;
|
||||
transition: background 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--brand);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.filter-shell {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 1rem;
|
||||
background: #fff;
|
||||
}
|
||||
@keyframes rise-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -205,8 +247,14 @@ img {
|
||||
animation-delay: 0.36s;
|
||||
}
|
||||
|
||||
.search-shell {
|
||||
background: #fff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.55);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.search-shell:focus-within {
|
||||
animation: search-glow 1.6s ease infinite;
|
||||
border-color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
+12
-3
@@ -5,17 +5,26 @@ import { Footer } from "@/components/Footer";
|
||||
import { Header } from "@/components/Header";
|
||||
import { Hero } from "@/components/Hero";
|
||||
import { Products } from "@/components/Products";
|
||||
import { SellCta } from "@/components/SellCta";
|
||||
import { listBlogs } from "@/lib/api/blogs";
|
||||
import { listUserProducts } from "@/lib/api/user-products";
|
||||
|
||||
export default async function Home() {
|
||||
const [userProducts, blogs] = await Promise.all([
|
||||
listUserProducts({ page: 1, pageSize: 8 }),
|
||||
listBlogs({ page: 1, pageSize: 3 }),
|
||||
]);
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main>
|
||||
<Hero />
|
||||
<Categories />
|
||||
<Products />
|
||||
<SellCta />
|
||||
<Products items={userProducts.items} total={userProducts.total} />
|
||||
<AboutCta />
|
||||
<Articles />
|
||||
<Articles items={blogs.items} total={blogs.total} />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { SiteShell } from "@/components/SiteShell";
|
||||
import { Products } from "@/components/Products";
|
||||
import { CategoryTreeSelect } from "@/components/CategoryTreeSelect";
|
||||
import { listCategories } from "@/lib/api/categories";
|
||||
import { listUserProducts } from "@/lib/api/user-products";
|
||||
import type { UserProductCondition } from "@/lib/api/types";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "محصولات",
|
||||
description: "آگهیهای منتشرشده ماشینآلات صنعتی در ماشینیفای",
|
||||
};
|
||||
|
||||
type PageProps = {
|
||||
searchParams: Promise<{
|
||||
q?: string;
|
||||
page?: string;
|
||||
condition?: string;
|
||||
categoryId?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
const conditions: { value: UserProductCondition | ""; label: string }[] = [
|
||||
{ value: "", label: "همه وضعیتها" },
|
||||
{ value: "new", label: "نو" },
|
||||
{ value: "stock", label: "کارکرده" },
|
||||
{ value: "needs_repair", label: "نیازمند تعمیر" },
|
||||
{ value: "scrap", label: "اسقاط" },
|
||||
];
|
||||
|
||||
export default async function UserProductsPage({ searchParams }: PageProps) {
|
||||
const params = await searchParams;
|
||||
const q = params.q?.trim() || undefined;
|
||||
const page = Number(params.page || "1") || 1;
|
||||
const condition = (params.condition || undefined) as
|
||||
| UserProductCondition
|
||||
| undefined;
|
||||
const categoryId = params.categoryId || undefined;
|
||||
|
||||
const [data, categories] = await Promise.all([
|
||||
listUserProducts({
|
||||
page,
|
||||
pageSize: 12,
|
||||
q,
|
||||
condition,
|
||||
categoryId,
|
||||
}),
|
||||
listCategories("product"),
|
||||
]);
|
||||
|
||||
return (
|
||||
<SiteShell
|
||||
banner={{
|
||||
kicker: "Marketplace",
|
||||
title: "محصولات کاربران",
|
||||
description:
|
||||
"جستجو و فیلتر آگهیهای منتشرشده ماشینآلات سنگین و صنعتی.",
|
||||
}}
|
||||
>
|
||||
<section className="section">
|
||||
<div className="container-page">
|
||||
<form method="get" className="filter-shell mb-8 p-4 md:p-5">
|
||||
<div className="grid gap-3 lg:grid-cols-[1.3fr_1fr_0.9fr_auto]">
|
||||
<input
|
||||
name="q"
|
||||
defaultValue={q ?? ""}
|
||||
placeholder="جستجوی عنوان یا توضیحات..."
|
||||
className="field-control min-h-12 w-full"
|
||||
/>
|
||||
|
||||
<CategoryTreeSelect
|
||||
categories={categories}
|
||||
defaultValue={categoryId ?? ""}
|
||||
placeholder="فیلتر دستهبندی"
|
||||
/>
|
||||
|
||||
<select
|
||||
name="condition"
|
||||
defaultValue={condition ?? ""}
|
||||
className="field-control min-h-12 w-full appearance-none"
|
||||
>
|
||||
{conditions.map((item) => (
|
||||
<option key={item.value || "all"} value={item.value}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<button type="submit" className="btn-primary min-h-12 px-6">
|
||||
اعمال فیلتر
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<Products
|
||||
items={data.items}
|
||||
total={data.total}
|
||||
query={q}
|
||||
showHeader={false}
|
||||
showMoreLink={false}
|
||||
/>
|
||||
|
||||
{data.total > data.pageSize ? (
|
||||
<div className="mt-8 flex items-center justify-center gap-3">
|
||||
{page > 1 ? (
|
||||
<Link
|
||||
href={`/user-products?${new URLSearchParams({
|
||||
...(q ? { q } : {}),
|
||||
...(condition ? { condition } : {}),
|
||||
...(categoryId ? { categoryId } : {}),
|
||||
page: String(page - 1),
|
||||
}).toString()}`}
|
||||
className="btn-secondary !min-h-10 px-4 text-sm"
|
||||
>
|
||||
قبلی
|
||||
</Link>
|
||||
) : null}
|
||||
<span className="latin-digits text-sm text-muted">
|
||||
صفحه {page}
|
||||
</span>
|
||||
{page * data.pageSize < data.total ? (
|
||||
<Link
|
||||
href={`/user-products?${new URLSearchParams({
|
||||
...(q ? { q } : {}),
|
||||
...(condition ? { condition } : {}),
|
||||
...(categoryId ? { categoryId } : {}),
|
||||
page: String(page + 1),
|
||||
}).toString()}`}
|
||||
className="btn-secondary !min-h-10 px-4 text-sm"
|
||||
>
|
||||
بعدی
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { site, stats } from "@/data/home";
|
||||
|
||||
export function AboutCta() {
|
||||
@@ -41,12 +42,12 @@ export function AboutCta() {
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
<a href="#contact" className="btn-primary">
|
||||
<Link href="/contact" className="btn-primary">
|
||||
تماس بگیرید
|
||||
</a>
|
||||
<a href="#categories" className="btn-ghost">
|
||||
</Link>
|
||||
<Link href="/about" className="btn-ghost">
|
||||
بیشتر بدانید
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+69
-11
@@ -1,10 +1,25 @@
|
||||
import Image from "next/image";
|
||||
import { articles } from "@/data/home";
|
||||
import Link from "next/link";
|
||||
import { resolveBlogImage } from "@/lib/api/blogs";
|
||||
import type { BlogPost } from "@/lib/api/types";
|
||||
|
||||
export function Articles() {
|
||||
type ArticlesProps = {
|
||||
items: BlogPost[];
|
||||
total?: number;
|
||||
showHeader?: boolean;
|
||||
showMoreLink?: boolean;
|
||||
};
|
||||
|
||||
export function Articles({
|
||||
items,
|
||||
total = 0,
|
||||
showHeader = true,
|
||||
showMoreLink = true,
|
||||
}: ArticlesProps) {
|
||||
return (
|
||||
<section id="articles" className="section bg-surface">
|
||||
<div className="container-page">
|
||||
<section id="articles" className={showHeader ? "section bg-surface" : undefined}>
|
||||
<div className={showHeader ? "container-page" : undefined}>
|
||||
{showHeader ? (
|
||||
<div className="section-head">
|
||||
<p className="section-kicker">Insights</p>
|
||||
<h2 className="section-title">مقالات</h2>
|
||||
@@ -12,35 +27,78 @@ export function Articles() {
|
||||
نکات تخصصی برای خرید هوشمندانه و ارتقاء خطوط تولید صنعتی.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
) : null}
|
||||
{items.length === 0 ? (
|
||||
<div className="border border-line bg-background px-6 py-14 text-center">
|
||||
<p className="text-base font-bold text-brand-ink">
|
||||
هنوز مقالهای منتشر نشده است.
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted">
|
||||
بهمحض انتشار مطالب جدید، در این بخش نمایش داده میشوند.
|
||||
</p>
|
||||
<Link href="/blog" className="btn-primary mt-6 inline-flex">
|
||||
مشاهده بلاگ
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-5 md:grid-cols-3">
|
||||
{articles.map((article) => (
|
||||
<a
|
||||
{items.map((article) => {
|
||||
const image = resolveBlogImage(article);
|
||||
const href = `/blog/${article.slug}`;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={article.id}
|
||||
href={article.href}
|
||||
href={href}
|
||||
className="group block overflow-hidden border border-line transition hover:border-brand/40"
|
||||
>
|
||||
<div className="relative aspect-[16/10] overflow-hidden bg-[#e8eef4]">
|
||||
{image ? (
|
||||
<Image
|
||||
src={article.image}
|
||||
src={image}
|
||||
alt={article.title}
|
||||
fill
|
||||
unoptimized
|
||||
className="object-cover transition duration-500 group-hover:scale-[1.04]"
|
||||
sizes="(max-width: 768px) 100vw, 33vw"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-[linear-gradient(135deg,#d7e0ea,#eef3f8)]" />
|
||||
)}
|
||||
</div>
|
||||
<div className="p-5">
|
||||
{article.categoryName ? (
|
||||
<p className="mb-2 text-xs font-bold text-brand">
|
||||
{article.categoryName}
|
||||
</p>
|
||||
) : null}
|
||||
<h3 className="text-base font-extrabold leading-7 text-brand-ink transition group-hover:text-brand">
|
||||
{article.title}
|
||||
</h3>
|
||||
{article.abstract ? (
|
||||
<p className="mt-2 line-clamp-2 text-sm text-muted">
|
||||
{article.abstract}
|
||||
</p>
|
||||
) : null}
|
||||
<span className="mt-4 inline-flex items-center gap-2 text-sm font-bold text-brand">
|
||||
بیشتر بخوانید
|
||||
<span aria-hidden>←</span>
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{showMoreLink ? (
|
||||
<div className="mt-8 text-center">
|
||||
<Link href="/blog" className="btn-primary">
|
||||
{total > items.length ? "مقالات بیشتر" : "همه مقالات"}
|
||||
</Link>
|
||||
</div>
|
||||
) : null} </>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { categories } from "@/data/home";
|
||||
|
||||
export function Categories() {
|
||||
@@ -13,17 +14,17 @@ export function Categories() {
|
||||
مسیر سریع به موجودی نو، کارکرده، خطوط سیم و کابل و محتوای آموزشی.
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="#products"
|
||||
<Link
|
||||
href="/categories"
|
||||
className="font-industrial text-sm tracking-[0.12em] text-brand transition hover:text-brand-deep"
|
||||
>
|
||||
VIEW STOCK →
|
||||
</a>
|
||||
VIEW ALL →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{categories.map((cat, index) => (
|
||||
<a
|
||||
<Link
|
||||
key={cat.id}
|
||||
href={cat.href}
|
||||
className="group relative block min-h-[22rem] overflow-hidden bg-brand-ink"
|
||||
@@ -44,7 +45,7 @@ export function Categories() {
|
||||
<h3 className="mt-1 text-xl font-extrabold">{cat.title}</h3>
|
||||
<p className="mt-2 text-sm text-white/75">{cat.description}</p>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
buildCategoryTree,
|
||||
categoryLabel,
|
||||
findCategoryById,
|
||||
} from "@/lib/api/categories";
|
||||
import type { Category, CategoryTreeNode } from "@/lib/api/types";
|
||||
|
||||
type CategoryTreeSelectProps = {
|
||||
categories: Category[];
|
||||
name?: string;
|
||||
defaultValue?: string;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
function filterTree(
|
||||
nodes: CategoryTreeNode[],
|
||||
query: string,
|
||||
): CategoryTreeNode[] {
|
||||
if (!query) return nodes;
|
||||
const q = query.trim().toLowerCase();
|
||||
|
||||
const walk = (list: CategoryTreeNode[]): CategoryTreeNode[] => {
|
||||
const result: CategoryTreeNode[] = [];
|
||||
for (const node of list) {
|
||||
const label = categoryLabel(node).toLowerCase();
|
||||
const en = node.name.toLowerCase();
|
||||
const children = walk(node.children);
|
||||
if (label.includes(q) || en.includes(q) || children.length > 0) {
|
||||
result.push({ ...node, children });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
return walk(nodes);
|
||||
}
|
||||
|
||||
function TreeNodes({
|
||||
nodes,
|
||||
depth,
|
||||
selectedId,
|
||||
onSelect,
|
||||
expanded,
|
||||
toggle,
|
||||
forceExpand,
|
||||
}: {
|
||||
nodes: CategoryTreeNode[];
|
||||
depth: number;
|
||||
selectedId: string;
|
||||
onSelect: (id: string, label: string) => void;
|
||||
expanded: Set<string>;
|
||||
toggle: (id: string) => void;
|
||||
forceExpand: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ul className={depth === 0 ? "space-y-0.5" : "mt-0.5 space-y-0.5"}>
|
||||
{nodes.map((node) => {
|
||||
const hasChildren = node.children.length > 0;
|
||||
const isOpen = forceExpand || expanded.has(node.id);
|
||||
const label = categoryLabel(node);
|
||||
const selected = selectedId === node.id;
|
||||
|
||||
return (
|
||||
<li key={node.id}>
|
||||
<div
|
||||
className={`flex items-center gap-1 rounded-lg ${
|
||||
selected ? "bg-brand/10" : "hover:bg-[#eef4fa]"
|
||||
}`}
|
||||
style={{ paddingInlineStart: `${depth * 0.85}rem` }}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isOpen ? "بستن" : "باز کردن"}
|
||||
onClick={() => toggle(node.id)}
|
||||
className="grid h-8 w-8 shrink-0 place-items-center rounded-md text-muted transition hover:bg-white hover:text-brand"
|
||||
>
|
||||
<span
|
||||
className={`block text-xs transition ${isOpen ? "rotate-90" : ""}`}
|
||||
aria-hidden
|
||||
>
|
||||
▶
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<span className="inline-block w-8 shrink-0" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(node.id, label)}
|
||||
className={`min-h-9 flex-1 rounded-lg px-2 py-1.5 text-right text-sm transition ${
|
||||
selected
|
||||
? "font-bold text-brand"
|
||||
: "font-medium text-brand-ink"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</div>
|
||||
{hasChildren && isOpen ? (
|
||||
<TreeNodes
|
||||
nodes={node.children}
|
||||
depth={depth + 1}
|
||||
selectedId={selectedId}
|
||||
onSelect={onSelect}
|
||||
expanded={expanded}
|
||||
toggle={toggle}
|
||||
forceExpand={forceExpand}
|
||||
/>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export function CategoryTreeSelect({
|
||||
categories,
|
||||
name = "categoryId",
|
||||
defaultValue = "",
|
||||
placeholder = "دستهبندی",
|
||||
}: CategoryTreeSelectProps) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const tree = useMemo(() => buildCategoryTree(categories), [categories]);
|
||||
const initial = findCategoryById(categories, defaultValue);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [selectedId, setSelectedId] = useState(defaultValue);
|
||||
const [selectedLabel, setSelectedLabel] = useState(
|
||||
initial ? categoryLabel(initial) : "",
|
||||
);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => {
|
||||
const ids = new Set<string>();
|
||||
if (defaultValue) {
|
||||
let current = findCategoryById(categories, defaultValue);
|
||||
while (current?.parentId) {
|
||||
ids.add(current.parentId);
|
||||
current = findCategoryById(categories, current.parentId);
|
||||
}
|
||||
}
|
||||
for (const root of tree) ids.add(root.id);
|
||||
return ids;
|
||||
});
|
||||
|
||||
const filtered = useMemo(() => filterTree(tree, query), [tree, query]);
|
||||
const searching = query.trim().length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
const onPointerDown = (event: MouseEvent) => {
|
||||
if (!rootRef.current?.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", onPointerDown);
|
||||
return () => document.removeEventListener("mousedown", onPointerDown);
|
||||
}, []);
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const onSelect = (id: string, label: string) => {
|
||||
setSelectedId(id);
|
||||
setSelectedLabel(label);
|
||||
setQuery("");
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
setSelectedId("");
|
||||
setSelectedLabel("");
|
||||
setQuery("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative min-w-[14rem]">
|
||||
<input type="hidden" name={name} value={selectedId} />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="field-control flex w-full min-h-12 items-center justify-between gap-3 text-right"
|
||||
>
|
||||
<span
|
||||
className={selectedLabel ? "font-bold text-brand-ink" : "text-muted"}
|
||||
>
|
||||
{selectedLabel || placeholder}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
{selectedId ? (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
clear();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
clear();
|
||||
}
|
||||
}}
|
||||
className="rounded-md px-1.5 py-0.5 text-xs text-muted hover:bg-white hover:text-brand"
|
||||
>
|
||||
پاک کردن
|
||||
</span>
|
||||
) : null}
|
||||
<span aria-hidden className="text-xs text-muted">
|
||||
{open ? "▴" : "▾"}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div className="absolute inset-x-0 top-[calc(100%+0.4rem)] z-40 overflow-hidden rounded-xl border border-line bg-surface shadow-[0_18px_50px_rgba(6,35,63,0.16)]">
|
||||
<div className="border-b border-line p-2">
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="جستجوی دسته..."
|
||||
className="field-control min-h-10 w-full"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-72 overflow-y-auto p-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
clear();
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`mb-1 flex min-h-9 w-full items-center rounded-lg px-3 text-sm transition hover:bg-[#eef4fa] ${
|
||||
!selectedId ? "font-bold text-brand" : "text-brand-ink"
|
||||
}`}
|
||||
>
|
||||
همه دستهها
|
||||
</button>
|
||||
{filtered.length === 0 ? (
|
||||
<p className="px-3 py-6 text-center text-sm text-muted">
|
||||
دستهای یافت نشد
|
||||
</p>
|
||||
) : (
|
||||
<TreeNodes
|
||||
nodes={filtered}
|
||||
depth={0}
|
||||
selectedId={selectedId}
|
||||
onSelect={onSelect}
|
||||
expanded={expanded}
|
||||
toggle={toggle}
|
||||
forceExpand={searching}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { submitContact } from "@/lib/api/contact";
|
||||
import { ApiError } from "@/lib/api/client";
|
||||
|
||||
export function ContactForm() {
|
||||
const [pending, setPending] = useState(false);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setPending(true);
|
||||
setSuccess(null);
|
||||
setError(null);
|
||||
|
||||
const form = event.currentTarget;
|
||||
const data = new FormData(form);
|
||||
|
||||
try {
|
||||
const result = await submitContact({
|
||||
title: String(data.get("title") || "").trim(),
|
||||
name: String(data.get("name") || "").trim(),
|
||||
email: String(data.get("email") || "").trim() || undefined,
|
||||
cellNumber: String(data.get("cellNumber") || "").trim() || undefined,
|
||||
text: String(data.get("text") || "").trim(),
|
||||
});
|
||||
setSuccess(result.message || "پیام شما با موفقیت ارسال شد.");
|
||||
form.reset();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: "ارسال پیام با خطا مواجه شد. دوباره تلاش کنید.",
|
||||
);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="block space-y-2 text-sm">
|
||||
<span className="font-bold text-brand-ink">نام</span>
|
||||
<input
|
||||
name="name"
|
||||
required
|
||||
className="field-control min-h-12 w-full"
|
||||
placeholder="نام و نام خانوادگی"
|
||||
/>
|
||||
</label>
|
||||
<label className="block space-y-2 text-sm">
|
||||
<span className="font-bold text-brand-ink">موضوع</span>
|
||||
<input
|
||||
name="title"
|
||||
required
|
||||
className="field-control min-h-12 w-full"
|
||||
placeholder="موضوع پیام"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="block space-y-2 text-sm">
|
||||
<span className="font-bold text-brand-ink">ایمیل</span>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
className="field-control min-h-12 w-full"
|
||||
placeholder="email@example.com"
|
||||
dir="ltr"
|
||||
/>
|
||||
</label>
|
||||
<label className="block space-y-2 text-sm">
|
||||
<span className="font-bold text-brand-ink">شماره موبایل</span>
|
||||
<input
|
||||
name="cellNumber"
|
||||
className="field-control min-h-12 w-full"
|
||||
placeholder="+98912..."
|
||||
dir="ltr"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="block space-y-2 text-sm">
|
||||
<span className="font-bold text-brand-ink">پیام</span>
|
||||
<textarea
|
||||
name="text"
|
||||
required
|
||||
rows={6}
|
||||
className="field-control w-full resize-y"
|
||||
placeholder="متن پیام خود را بنویسید..."
|
||||
/>
|
||||
</label>
|
||||
|
||||
{success ? (
|
||||
<p className="rounded-xl border border-brand/30 bg-brand/5 px-4 py-3 text-sm font-bold text-brand">
|
||||
{success}
|
||||
</p>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm font-bold text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
className="btn-primary min-h-12 px-8 disabled:opacity-60"
|
||||
>
|
||||
{pending ? "در حال ارسال..." : "ارسال پیام"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -3,16 +3,24 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { navItems, site } from "@/data/home";
|
||||
|
||||
export function Header() {
|
||||
type HeaderProps = {
|
||||
forceSolid?: boolean;
|
||||
};
|
||||
|
||||
export function Header({ forceSolid = false }: HeaderProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [scrolled, setScrolled] = useState(forceSolid);
|
||||
|
||||
useEffect(() => {
|
||||
if (forceSolid) {
|
||||
setScrolled(true);
|
||||
return;
|
||||
}
|
||||
const onScroll = () => setScrolled(window.scrollY > 24);
|
||||
onScroll();
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
}, [forceSolid]);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = open ? "hidden" : "";
|
||||
@@ -24,7 +32,7 @@ export function Header() {
|
||||
return (
|
||||
<header
|
||||
className={`fixed inset-x-0 top-0 z-50 transition-colors duration-300 ${
|
||||
scrolled || open
|
||||
scrolled || open || forceSolid
|
||||
? "border-b border-white/10 bg-brand-ink/95 backdrop-blur-md"
|
||||
: "bg-transparent"
|
||||
}`}
|
||||
@@ -63,12 +71,12 @@ export function Header() {
|
||||
{site.phoneDisplay}
|
||||
</a>
|
||||
<a
|
||||
href={site.loginUrl}
|
||||
href={site.customerLoginUrl}
|
||||
className="btn-primary !min-h-10 !px-4 !text-sm"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
ورود فروشندگان
|
||||
ورود
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
+14
-7
@@ -2,16 +2,18 @@
|
||||
|
||||
import Image from "next/image";
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { searchSuggestions, site } from "@/data/home";
|
||||
|
||||
export function Hero() {
|
||||
const router = useRouter();
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const onSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const q = query.trim();
|
||||
if (!q) return;
|
||||
window.location.hash = `products`;
|
||||
router.push(`/user-products?q=${encodeURIComponent(q)}`);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -49,7 +51,7 @@ export function Hero() {
|
||||
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="animate-rise animate-rise-delay-3 search-shell mt-8 max-w-3xl border border-white/15 bg-white/95 p-2 shadow-[0_20px_60px_rgba(0,0,0,0.28)] backdrop-blur-sm"
|
||||
className="animate-rise animate-rise-delay-3 search-shell mt-8 max-w-3xl rounded-2xl p-2"
|
||||
>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-stretch">
|
||||
<label className="sr-only" htmlFor="machine-search">
|
||||
@@ -61,22 +63,27 @@ export function Hero() {
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="نام کالای مورد نظر خود را جستجو کنید"
|
||||
className="min-h-14 flex-1 bg-transparent px-4 text-base text-brand-ink outline-none placeholder:text-[#8a97a6]"
|
||||
className="min-h-14 flex-1 rounded-xl bg-transparent px-4 text-base text-brand-ink outline-none placeholder:text-[#8a97a6]"
|
||||
required
|
||||
/>
|
||||
<button type="submit" className="btn-primary min-h-14 px-8">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary min-h-14 rounded-xl px-8"
|
||||
>
|
||||
جستجو
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{searchSuggestions.map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
onClick={() => setQuery(item)}
|
||||
className="border border-white/20 bg-white/5 px-3 py-1.5 text-xs text-white/80 transition hover:border-brand hover:bg-brand/20 hover:text-white"
|
||||
onClick={() => {
|
||||
setQuery(item);
|
||||
router.push(`/user-products?q=${encodeURIComponent(item)}`);
|
||||
}}
|
||||
className="border border-white/20 bg-white/5 px-3 py-1.5 text-xs text-white/80 transition hover:border-brand hover:bg-brand/20 hover:text-white rounded-lg"
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import Image from "next/image";
|
||||
|
||||
type PageBannerProps = {
|
||||
kicker: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export function PageBanner({ kicker, title, description }: PageBannerProps) {
|
||||
return (
|
||||
<section className="relative overflow-hidden bg-brand-ink text-white">
|
||||
<Image
|
||||
src="/images/hero/search-bg.jpg"
|
||||
alt=""
|
||||
fill
|
||||
priority
|
||||
className="object-cover object-[center_40%]"
|
||||
sizes="100vw"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-brand-ink/80" />
|
||||
<div className="absolute inset-0 bg-brand/35" />
|
||||
|
||||
<div className="relative z-10 container-page py-9 md:py-10">
|
||||
<p className="font-industrial mb-2 text-[0.72rem] tracking-[0.18em] text-white/80 uppercase">
|
||||
{kicker}
|
||||
</p>
|
||||
<h1 className="max-w-3xl text-3xl font-extrabold leading-tight text-white md:text-[2.35rem]">
|
||||
{title}
|
||||
</h1>
|
||||
{description ? (
|
||||
<p className="mt-2 max-w-2xl text-sm leading-7 text-white/70 md:text-[0.95rem]">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+99
-26
@@ -1,64 +1,137 @@
|
||||
import Image from "next/image";
|
||||
import { products } from "@/data/home";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
formatCondition,
|
||||
formatPrice,
|
||||
productCategory,
|
||||
productTitle,
|
||||
} from "@/lib/api/user-products";
|
||||
import type { UserProductListItem } from "@/lib/api/types";
|
||||
|
||||
export function Products() {
|
||||
type ProductsProps = {
|
||||
items: UserProductListItem[];
|
||||
total?: number;
|
||||
query?: string;
|
||||
showHeader?: boolean;
|
||||
showMoreLink?: boolean;
|
||||
};
|
||||
|
||||
export function Products({
|
||||
items,
|
||||
total = 0,
|
||||
query,
|
||||
showHeader = true,
|
||||
showMoreLink = true,
|
||||
}: ProductsProps) {
|
||||
return (
|
||||
<section id="products" className="section">
|
||||
<div className="container-page">
|
||||
<section id="products" className={showHeader ? "section" : undefined}>
|
||||
<div className={showHeader ? "container-page" : undefined}>
|
||||
{showHeader ? (
|
||||
<div className="section-head">
|
||||
<p className="section-kicker">Stock Machines</p>
|
||||
<h2 className="section-title">محصولات موجود</h2>
|
||||
<p className="section-desc">
|
||||
منتخب ماشینآلات سنگین و صنعتی از فروشگاه ماشینیفای؛ برای جزئیات و
|
||||
قیمت، استعلام بگیرید.
|
||||
{query
|
||||
? `نتایج جستجو برای «${query}»`
|
||||
: "منتخب ماشینآلات سنگین و صنعتی از فروشگاه ماشینیفای؛ برای جزئیات و قیمت، استعلام بگیرید."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
) : query ? (
|
||||
<p className="mb-6 text-sm text-muted">نتایج جستجو برای «{query}»</p>
|
||||
) : null}
|
||||
{items.length === 0 ? (
|
||||
<div className="border border-line bg-surface px-6 py-14 text-center">
|
||||
<p className="text-base font-bold text-brand-ink">
|
||||
در حال حاضر محصول منتشرشدهای یافت نشد.
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted">
|
||||
بهمحض انتشار آگهیهای جدید، اینجا نمایش داده میشوند.
|
||||
</p>
|
||||
<Link href="/user-products" className="btn-primary mt-6 inline-flex">
|
||||
مشاهده همه محصولات
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{products.map((product) => (
|
||||
{items.map((product) => {
|
||||
const title = productTitle(product);
|
||||
const category = productCategory(product);
|
||||
const condition = formatCondition(product.condition);
|
||||
const price = formatPrice(product.price, product.priceCurrency);
|
||||
const href = `/user-products/${product.slug}`;
|
||||
|
||||
return (
|
||||
<article
|
||||
key={product.id}
|
||||
className="group flex h-full flex-col overflow-hidden border border-line bg-surface transition duration-300 hover:-translate-y-1 hover:border-brand/40"
|
||||
>
|
||||
<a href={product.href} className="relative block aspect-[4/3] overflow-hidden bg-[#e8eef4]">
|
||||
<Link
|
||||
href={href}
|
||||
className="relative block aspect-[4/3] overflow-hidden bg-[#e8eef4]"
|
||||
>
|
||||
{product.imageUrl ? (
|
||||
<Image
|
||||
src={product.image}
|
||||
alt={product.title}
|
||||
src={product.imageUrl}
|
||||
alt={title}
|
||||
fill
|
||||
unoptimized
|
||||
className="object-cover transition duration-500 group-hover:scale-[1.04]"
|
||||
sizes="(max-width: 768px) 100vw, 25vw"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-[linear-gradient(135deg,#d7e0ea,#eef3f8)]" />
|
||||
)}
|
||||
{condition ? (
|
||||
<span className="absolute left-3 top-3 bg-brand-ink/90 px-2.5 py-1 text-xs text-white">
|
||||
{product.condition}
|
||||
{condition}
|
||||
</span>
|
||||
</a>
|
||||
) : null}
|
||||
{product.promoted ? (
|
||||
<span className="absolute right-3 top-3 bg-brand px-2.5 py-1 text-xs text-white">
|
||||
ویژه
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-1 flex-col p-4">
|
||||
{product.titleEn ? (
|
||||
<p className="font-industrial text-[0.68rem] tracking-[0.14em] text-brand">
|
||||
{product.titleEn}
|
||||
</p>
|
||||
) : null}
|
||||
<h3 className="mt-1 text-base font-extrabold leading-7 text-brand-ink">
|
||||
<a href={product.href}>{product.title}</a>
|
||||
<Link href={href}>{title}</Link>
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-muted">{product.category}</p>
|
||||
<div className="mt-auto pt-4">
|
||||
<a
|
||||
href="#contact"
|
||||
className="inline-flex w-full items-center justify-center border border-brand px-3 py-2.5 text-sm font-bold text-brand transition hover:bg-brand hover:text-white"
|
||||
{category ? (
|
||||
<p className="mt-2 text-sm text-muted">{category}</p>
|
||||
) : null}
|
||||
{price ? (
|
||||
<p
|
||||
dir="ltr"
|
||||
className="font-industrial mt-2 w-full text-left text-sm font-bold tracking-wide text-brand-ink"
|
||||
>
|
||||
استعلام از فروشنده
|
||||
</a>
|
||||
</div>
|
||||
{price}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-auto pt-4">
|
||||
<Link href={href} className="btn-secondary w-full !min-h-11 text-sm">
|
||||
جزئیات و استعلام
|
||||
</Link> </div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{showMoreLink ? (
|
||||
<div className="mt-8 text-center">
|
||||
<a href="#contact" className="btn-primary">
|
||||
محصولات بیشتر
|
||||
</a>
|
||||
<Link href="/user-products" className="btn-primary">
|
||||
{total > items.length ? "محصولات بیشتر" : "همه محصولات"}
|
||||
</Link>
|
||||
</div>
|
||||
) : null} </>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import Image from "next/image";
|
||||
import { site } from "@/data/home";
|
||||
|
||||
export function SellCta() {
|
||||
return (
|
||||
<section
|
||||
id="sell"
|
||||
className="relative overflow-hidden bg-brand-ink text-white"
|
||||
>
|
||||
<Image
|
||||
src="/images/categories/used.jpg"
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover object-center opacity-40"
|
||||
sizes="100vw"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-[linear-gradient(100deg,rgba(6,35,63,0.96)_0%,rgba(6,35,63,0.82)_48%,rgba(0,109,212,0.55)_100%)]" />
|
||||
<div className="absolute -left-24 top-1/2 h-72 w-72 -translate-y-1/2 rounded-full bg-brand/25 blur-3xl" />
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="container-page flex flex-col items-start gap-8 py-16 md:flex-row md:items-end md:justify-between md:py-20">
|
||||
<div className="max-w-3xl">
|
||||
<p className="font-industrial text-sm tracking-[0.22em] text-[#7ec2ff]">
|
||||
SELL ON MASHINIFY
|
||||
</p>
|
||||
<h2 className="mt-4 text-[clamp(1.7rem,3.4vw,2.75rem)] font-extrabold leading-[1.35]">
|
||||
ماشین آلات خود را برای فروش در ماشینیفای قرار دهید
|
||||
</h2>
|
||||
<p className="mt-4 max-w-xl text-base text-white/75 md:text-lg">
|
||||
به جمع فروشندگان صنعتی بپیوندید و دستگاههای نو یا کارکرده خود را
|
||||
مستقیم به خریداران واقعی معرفی کنید.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={site.sellStartUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group inline-flex min-h-14 items-center gap-3 bg-brand px-8 text-base font-extrabold text-white transition hover:bg-brand-deep"
|
||||
>
|
||||
شروع کن
|
||||
<span
|
||||
aria-hidden
|
||||
className="font-industrial text-lg transition-transform duration-300 group-hover:-translate-x-1"
|
||||
>
|
||||
←
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Footer } from "@/components/Footer";
|
||||
import { Header } from "@/components/Header";
|
||||
import { PageBanner } from "@/components/PageBanner";
|
||||
|
||||
type SiteShellProps = {
|
||||
children: React.ReactNode;
|
||||
solidHeader?: boolean;
|
||||
banner?: {
|
||||
kicker: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function SiteShell({
|
||||
children,
|
||||
solidHeader = true,
|
||||
banner,
|
||||
}: SiteShellProps) {
|
||||
return (
|
||||
<>
|
||||
<Header forceSolid={solidHeader} />
|
||||
<main className={solidHeader ? "pt-[4.5rem]" : undefined}>
|
||||
{banner ? (
|
||||
<PageBanner
|
||||
kicker={banner.kicker}
|
||||
title={banner.title}
|
||||
description={banner.description}
|
||||
/>
|
||||
) : null}
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Curated homepage/categories showcase tiles.
|
||||
* IDs come from GET /tenants/{domain}/categories?entityType=product
|
||||
*/
|
||||
export const featuredCategoryTiles = [
|
||||
{
|
||||
id: "52",
|
||||
image: "/images/products/fiber-laser.jpg",
|
||||
},
|
||||
{
|
||||
id: "54",
|
||||
image: "/images/products/wire-extruder.jpg",
|
||||
},
|
||||
{
|
||||
id: "60",
|
||||
image: "/images/products/teflon-extruder.jpg",
|
||||
},
|
||||
{
|
||||
id: "61",
|
||||
image: "/images/categories/wire-cable.jpg",
|
||||
},
|
||||
{
|
||||
id: "48",
|
||||
image: "/images/products/granulator.jpg",
|
||||
},
|
||||
{
|
||||
id: "49",
|
||||
image: "/images/categories/used.jpg",
|
||||
},
|
||||
{
|
||||
id: "57",
|
||||
image: "/images/categories/new.jpg",
|
||||
},
|
||||
{
|
||||
id: "51",
|
||||
image: "/images/articles/smart-machines.jpg",
|
||||
},
|
||||
{
|
||||
id: "56",
|
||||
image: "/images/articles/upgrade.jpg",
|
||||
},
|
||||
] as const;
|
||||
+17
-81
@@ -8,18 +8,20 @@ export const site = {
|
||||
phoneDisplay: "025-36700000",
|
||||
address: "قم، سالاریه، میدان پیچک، ساختمان بنفشه",
|
||||
loginUrl: "https://app.mashinify.com/auth/login",
|
||||
customerLoginUrl: "https://customer.mashinify.com/login",
|
||||
sellStartUrl:
|
||||
"https://customer.mashinify.com/login?redirect=%2Fmy-products%2Fnew",
|
||||
supportLabel: "پشتیبانی",
|
||||
};
|
||||
|
||||
export const navItems = [
|
||||
{ label: "خانه", href: "/" },
|
||||
{ label: "محصولات", href: "#products" },
|
||||
{ label: "دستهبندیها", href: "#categories" },
|
||||
{ label: "مقالات", href: "#articles" },
|
||||
{ label: "درباره ما", href: "#about" },
|
||||
{ label: "تماس با ما", href: "#contact" },
|
||||
{ label: "محصولات", href: "/user-products" },
|
||||
{ label: "دستهبندیها", href: "/categories" },
|
||||
{ label: "مقالات", href: "/blog" },
|
||||
{ label: "درباره ما", href: "/about" },
|
||||
{ label: "تماس با ما", href: "/contact" },
|
||||
];
|
||||
|
||||
export const searchSuggestions = [
|
||||
"لیزر فایبر",
|
||||
"اکسترودر سیم",
|
||||
@@ -35,7 +37,7 @@ export const categories = [
|
||||
titleEn: "Used Products",
|
||||
description: "ماشینآلات دستدوم بازرسیشده برای تولید مقرونبهصرفه",
|
||||
image: "/images/categories/used.jpg",
|
||||
href: "#products",
|
||||
href: "/user-products?condition=stock",
|
||||
},
|
||||
{
|
||||
id: "new",
|
||||
@@ -43,7 +45,7 @@ export const categories = [
|
||||
titleEn: "New Products",
|
||||
description: "دستگاههای نو با گارانتی و پشتیبانی فروشنده",
|
||||
image: "/images/categories/new.jpg",
|
||||
href: "#products",
|
||||
href: "/user-products?condition=new",
|
||||
},
|
||||
{
|
||||
id: "wire-cable",
|
||||
@@ -51,7 +53,7 @@ export const categories = [
|
||||
titleEn: "Wire & Cable",
|
||||
description: "اکسترودر، مولتیوایر و خطوط تولید سیم و کابل",
|
||||
image: "/images/categories/wire-cable.jpg",
|
||||
href: "#products",
|
||||
href: "/categories",
|
||||
},
|
||||
{
|
||||
id: "education",
|
||||
@@ -59,50 +61,7 @@ export const categories = [
|
||||
titleEn: "Education",
|
||||
description: "راهنما و مقالات تخصصی انتخاب و نگهداری دستگاه",
|
||||
image: "/images/categories/education.jpg",
|
||||
href: "#articles",
|
||||
},
|
||||
];
|
||||
|
||||
export const products = [
|
||||
{
|
||||
id: 21447,
|
||||
title: "دستگاه لیزر فایبر برش فلزات",
|
||||
titleEn: "Fiber Laser Cutting Machine",
|
||||
category: "ماشینآلات برش",
|
||||
condition: "نو",
|
||||
image: "/images/products/fiber-laser.jpg",
|
||||
href: "#products",
|
||||
inquiry: true,
|
||||
},
|
||||
{
|
||||
id: 21446,
|
||||
title: "دستگاه گرانولساز",
|
||||
titleEn: "Plastic Granulator Machine",
|
||||
category: "بازیافت پلاستیک",
|
||||
condition: "نو",
|
||||
image: "/images/products/granulator.jpg",
|
||||
href: "#products",
|
||||
inquiry: true,
|
||||
},
|
||||
{
|
||||
id: 21427,
|
||||
title: "دستگاه اکسترودر سیم",
|
||||
titleEn: "Wire Extruder Machine",
|
||||
category: "سیم و کابل",
|
||||
condition: "کارکرده",
|
||||
image: "/images/products/wire-extruder.jpg",
|
||||
href: "#products",
|
||||
inquiry: true,
|
||||
},
|
||||
{
|
||||
id: 21426,
|
||||
title: "دستگاه اکسترود تفلون",
|
||||
titleEn: "Teflon Extruder Machine",
|
||||
category: "سیم و کابل",
|
||||
condition: "کارکرده",
|
||||
image: "/images/products/teflon-extruder.jpg",
|
||||
href: "#products",
|
||||
inquiry: true,
|
||||
href: "/blog",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -112,37 +71,14 @@ export const stats = [
|
||||
{ value: "New & Used", label: "موجودی متنوع" },
|
||||
];
|
||||
|
||||
export const articles = [
|
||||
{
|
||||
id: 2898,
|
||||
title:
|
||||
"مزایای ارتقاء دستگاههای صنعتی قدیمی و تأثیر آن بر افزایش بهرهوری",
|
||||
image: "/images/articles/upgrade.jpg",
|
||||
href: "#articles",
|
||||
},
|
||||
{
|
||||
id: 2895,
|
||||
title:
|
||||
"چرا سرمایهگذاری در دستگاههای صنعتی هوشمند، آینده کسبوکار شما را تضمین میکند؟",
|
||||
image: "/images/articles/smart-machines.jpg",
|
||||
href: "#articles",
|
||||
},
|
||||
{
|
||||
id: 2892,
|
||||
title:
|
||||
"ماشینآلات صنعتی دست دوم: فرصت هوشمندانه برای تولیدکنندگان با بودجه محدود",
|
||||
image: "/images/articles/used-machines.jpg",
|
||||
href: "#articles",
|
||||
},
|
||||
];
|
||||
|
||||
export const footerLinks = {
|
||||
pages: [
|
||||
{ label: "صفحه اصلی", href: "/" },
|
||||
{ label: "محصولات", href: "#products" },
|
||||
{ label: "مقالات", href: "#articles" },
|
||||
{ label: "درباره ما", href: "#about" },
|
||||
{ label: "تماس با ما", href: "#contact" },
|
||||
{ label: "محصولات", href: "/user-products" },
|
||||
{ label: "دستهبندیها", href: "/categories" },
|
||||
{ label: "مقالات", href: "/blog" },
|
||||
{ label: "درباره ما", href: "/about" },
|
||||
{ label: "تماس با ما", href: "/contact" },
|
||||
],
|
||||
categories: categories.map((c) => ({
|
||||
label: c.title,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { apiGet, ApiError } from "./client";
|
||||
import type { BlogPost, Paginated } from "./types";
|
||||
|
||||
export type ListBlogsParams = {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
type?: "news" | "article" | "blog";
|
||||
categoryId?: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
const emptyPage = <T,>(page = 1, pageSize = 12): Paginated<T> => ({
|
||||
items: [],
|
||||
total: 0,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
export async function listBlogs(params: ListBlogsParams = {}) {
|
||||
const page = params.page ?? 1;
|
||||
const pageSize = params.pageSize ?? 12;
|
||||
try {
|
||||
return await apiGet<Paginated<BlogPost>>("/blogs", {
|
||||
page,
|
||||
pageSize,
|
||||
type: params.type,
|
||||
categoryId: params.categoryId,
|
||||
title: params.title,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("listBlogs failed", error);
|
||||
return emptyPage<BlogPost>(page, pageSize);
|
||||
}
|
||||
}
|
||||
export async function getBlogBySlug(slug: string) {
|
||||
const data = await apiGet<{ blog: BlogPost }>(
|
||||
`/blogs/${encodeURIComponent(slug)}`,
|
||||
);
|
||||
return data.blog;
|
||||
}
|
||||
|
||||
export async function getBlogBySlugOrNull(slug: string) {
|
||||
try {
|
||||
return await getBlogBySlug(slug);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Prefer title image; otherwise first <img> inside HTML body. */
|
||||
export function resolveBlogImage(blog: Pick<BlogPost, "titleImageUrl" | "mainTextHtml" | "abstract">) {
|
||||
if (blog.titleImageUrl) return blog.titleImageUrl;
|
||||
const html = blog.mainTextHtml ?? "";
|
||||
const match = html.match(/<img[^>]+src=["']([^"']+)["']/i);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { apiGet } from "./client";
|
||||
import type { BusinessInfo } from "./types";
|
||||
|
||||
export async function getBusinessInfo() {
|
||||
try {
|
||||
return await apiGet<BusinessInfo>("/website/business-info");
|
||||
} catch (error) {
|
||||
console.error("getBusinessInfo failed", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { apiGet } from "./client";
|
||||
import type { Category, CategoryTreeNode } from "./types";
|
||||
|
||||
export async function listCategories(entityType: "product" | "blog" = "product") {
|
||||
try {
|
||||
const data = await apiGet<{ items: Category[] }>("/categories", {
|
||||
entityType,
|
||||
});
|
||||
return data.items ?? [];
|
||||
} catch (error) {
|
||||
console.error("listCategories failed", error);
|
||||
return [] as Category[];
|
||||
}
|
||||
}
|
||||
|
||||
export function categoryLabel(category: Pick<Category, "nameFa" | "name">) {
|
||||
return category.nameFa || category.name;
|
||||
}
|
||||
|
||||
export function buildCategoryTree(categories: Category[]): CategoryTreeNode[] {
|
||||
const map = new Map<string, CategoryTreeNode>();
|
||||
for (const category of categories) {
|
||||
map.set(category.id, { ...category, children: [] });
|
||||
}
|
||||
|
||||
const roots: CategoryTreeNode[] = [];
|
||||
for (const node of map.values()) {
|
||||
if (node.parentId && map.has(node.parentId)) {
|
||||
map.get(node.parentId)!.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
const sortNodes = (nodes: CategoryTreeNode[]) => {
|
||||
nodes.sort(
|
||||
(a, b) =>
|
||||
a.sortOrder - b.sortOrder ||
|
||||
categoryLabel(a).localeCompare(categoryLabel(b), "fa"),
|
||||
);
|
||||
for (const node of nodes) sortNodes(node.children);
|
||||
};
|
||||
sortNodes(roots);
|
||||
return roots;
|
||||
}
|
||||
|
||||
export function findCategoryById(categories: Category[], id?: string | null) {
|
||||
if (!id) return null;
|
||||
return categories.find((item) => item.id === id) ?? null;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { API_BASE_URL, SITE_DOMAIN } from "./config";
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
type QueryValue = string | number | boolean | null | undefined;
|
||||
|
||||
export function buildTenantUrl(
|
||||
path: string,
|
||||
query?: Record<string, QueryValue>,
|
||||
domain: string = SITE_DOMAIN,
|
||||
) {
|
||||
const normalized = path.startsWith("/") ? path : `/${path}`;
|
||||
const url = new URL(`${API_BASE_URL}/tenants/${domain}${normalized}`);
|
||||
|
||||
if (query) {
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value === undefined || value === null || value === "") continue;
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
export async function apiGet<T>(
|
||||
path: string,
|
||||
query?: Record<string, QueryValue>,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
const url = buildTenantUrl(path, query);
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
...init?.headers,
|
||||
},
|
||||
next: init?.next ?? { revalidate: 60 },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let message = `API ${response.status}`;
|
||||
try {
|
||||
const body = (await response.json()) as { message?: string };
|
||||
if (body?.message) message = body.message;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new ApiError(message, response.status);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function apiPost<T>(
|
||||
path: string,
|
||||
body: unknown,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
const url = buildTenantUrl(path);
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
...init?.headers,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let message = `API ${response.status}`;
|
||||
try {
|
||||
const data = (await response.json()) as { message?: string };
|
||||
if (data?.message) message = data.message;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new ApiError(message, response.status);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "https://api.meshkee.com/api/v1";
|
||||
|
||||
/** Website apex host (no www/api/customer/business). */
|
||||
export const SITE_DOMAIN =
|
||||
process.env.NEXT_PUBLIC_SITE_DOMAIN ?? "mashinify.com";
|
||||
@@ -0,0 +1,9 @@
|
||||
import { apiPost } from "./client";
|
||||
import type { ContactSubmissionInput } from "./types";
|
||||
|
||||
export async function submitContact(input: ContactSubmissionInput) {
|
||||
return apiPost<{ submission?: unknown; message?: string }>(
|
||||
"/contact-submissions",
|
||||
input,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
export type Paginated<T> = {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
};
|
||||
|
||||
export type BlogAuthor = {
|
||||
id: string;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
email?: string | null;
|
||||
};
|
||||
|
||||
export type BlogPost = {
|
||||
id: string;
|
||||
businessId: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
type: "news" | "article" | "blog" | string;
|
||||
abstract: string | null;
|
||||
mainTextHtml?: string | null;
|
||||
status: string;
|
||||
categoryId: string | null;
|
||||
categoryName: string | null;
|
||||
tags: string[];
|
||||
authorId: string | null;
|
||||
author: BlogAuthor | null;
|
||||
titleImageUrl: string | null;
|
||||
featuredMediaId: string | null;
|
||||
commentCount: number;
|
||||
publishedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type UserProductCondition =
|
||||
| "new"
|
||||
| "stock"
|
||||
| "needs_repair"
|
||||
| "scrap"
|
||||
| string;
|
||||
|
||||
export type UserProductListItem = {
|
||||
id: string;
|
||||
slug: string;
|
||||
titleFa: string | null;
|
||||
titleEn: string | null;
|
||||
price: number | string | null;
|
||||
priceCurrency: string | null;
|
||||
condition: UserProductCondition | null;
|
||||
cityName?: string | null;
|
||||
cityNameFa?: string | null;
|
||||
countryName?: string | null;
|
||||
countryNameFa?: string | null;
|
||||
imageUrl: string | null;
|
||||
categoryId?: string | null;
|
||||
categoryName?: string | null;
|
||||
categoryNameFa?: string | null;
|
||||
promoted?: boolean;
|
||||
publishedAt?: string | null;
|
||||
};
|
||||
|
||||
export type UserProductImage = {
|
||||
mediaId: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type UserProduct = UserProductListItem & {
|
||||
description?: string | null;
|
||||
descriptionFa?: string | null;
|
||||
descriptionEn?: string | null;
|
||||
images?: UserProductImage[];
|
||||
galleryMediaIds?: string[];
|
||||
featuredMediaId?: string | null;
|
||||
countryId?: string | null;
|
||||
cityId?: string | null;
|
||||
countrySlug?: string | null;
|
||||
deliveryNotes?: string | null;
|
||||
technicalNotes?: string | null;
|
||||
technicalValues?: unknown;
|
||||
};
|
||||
|
||||
export type Category = {
|
||||
id: string;
|
||||
businessId: string;
|
||||
entityType: string;
|
||||
parentId: string | null;
|
||||
name: string;
|
||||
nameFa: string | null;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
variationCount?: number;
|
||||
};
|
||||
|
||||
export type CategoryTreeNode = Category & {
|
||||
children: CategoryTreeNode[];
|
||||
};
|
||||
|
||||
export type BusinessInfo = {
|
||||
id: string;
|
||||
name: string;
|
||||
nameFa: string | null;
|
||||
about: string | null;
|
||||
vision: string | null;
|
||||
logoUrl: string | null;
|
||||
faviconUrl: string | null;
|
||||
emails: string[];
|
||||
phoneNumbers: string[];
|
||||
socialMedia?: {
|
||||
whatsapp?: string;
|
||||
telegram?: string;
|
||||
instagram?: string;
|
||||
linkedin?: string;
|
||||
youtube?: string;
|
||||
aparat?: string;
|
||||
};
|
||||
addresses: Array<{
|
||||
title?: string | null;
|
||||
address?: string | null;
|
||||
cityName?: string | null;
|
||||
cityNameFa?: string | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type ContactSubmissionInput = {
|
||||
title: string;
|
||||
name: string;
|
||||
text: string;
|
||||
email?: string;
|
||||
cellNumber?: string;
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import { apiGet, ApiError } from "./client";
|
||||
import type {
|
||||
Paginated,
|
||||
UserProduct,
|
||||
UserProductCondition,
|
||||
UserProductListItem,
|
||||
} from "./types";
|
||||
|
||||
export type ListUserProductsParams = {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
q?: string;
|
||||
name?: string;
|
||||
categoryId?: string;
|
||||
cityId?: string;
|
||||
countryId?: string;
|
||||
condition?: UserProductCondition;
|
||||
promoted?: boolean;
|
||||
};
|
||||
|
||||
const emptyPage = <T,>(page = 1, pageSize = 12): Paginated<T> => ({
|
||||
items: [],
|
||||
total: 0,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
export async function listUserProducts(params: ListUserProductsParams = {}) {
|
||||
const page = params.page ?? 1;
|
||||
const pageSize = params.pageSize ?? 12;
|
||||
try {
|
||||
return await apiGet<Paginated<UserProductListItem>>("/user-products", {
|
||||
page,
|
||||
pageSize,
|
||||
q: params.q,
|
||||
name: params.name,
|
||||
categoryId: params.categoryId,
|
||||
cityId: params.cityId,
|
||||
countryId: params.countryId,
|
||||
condition: params.condition,
|
||||
promoted: params.promoted,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("listUserProducts failed", error);
|
||||
return emptyPage<UserProductListItem>(page, pageSize);
|
||||
}
|
||||
}
|
||||
export async function getUserProductBySlug(slug: string) {
|
||||
const data = await apiGet<{ product: UserProduct }>(
|
||||
`/user-products/${encodeURIComponent(slug)}`,
|
||||
);
|
||||
return data.product;
|
||||
}
|
||||
|
||||
export async function getUserProductBySlugOrNull(slug: string) {
|
||||
try {
|
||||
return await getUserProductBySlug(slug);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const conditionLabels: Record<string, string> = {
|
||||
new: "نو",
|
||||
stock: "کارکرده",
|
||||
needs_repair: "نیازمند تعمیر",
|
||||
scrap: "اسقاط",
|
||||
};
|
||||
|
||||
export function formatCondition(condition: string | null | undefined) {
|
||||
if (!condition) return null;
|
||||
return conditionLabels[condition] ?? condition;
|
||||
}
|
||||
|
||||
export function productTitle(product: Pick<UserProductListItem, "titleFa" | "titleEn">) {
|
||||
return product.titleFa || product.titleEn || "بدون عنوان";
|
||||
}
|
||||
|
||||
export function productCategory(
|
||||
product: Pick<UserProductListItem, "categoryNameFa" | "categoryName">,
|
||||
) {
|
||||
return product.categoryNameFa || product.categoryName || null;
|
||||
}
|
||||
|
||||
export function formatPrice(
|
||||
price: number | string | null | undefined,
|
||||
currency: string | null | undefined = "IRR",
|
||||
) {
|
||||
if (price === null || price === undefined || price === "") return null;
|
||||
const value = typeof price === "string" ? Number(price) : price;
|
||||
if (!Number.isFinite(value)) return String(price);
|
||||
|
||||
const formatted = new Intl.NumberFormat("en-US").format(value);
|
||||
if (!currency || currency === "IRR" || currency === "IRT") {
|
||||
return `${formatted} تومان`;
|
||||
}
|
||||
return `${currency} ${formatted}`;
|
||||
}
|
||||
Reference in New Issue
Block a user