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";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
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;
|
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;
|
gap: 0.5rem;
|
||||||
min-height: 3rem;
|
min-height: 3rem;
|
||||||
padding: 0.7rem 1.35rem;
|
padding: 0.7rem 1.35rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
background: var(--brand);
|
background: var(--brand);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
transition: background 0.25s ease, transform 0.25s ease;
|
transition: background 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover {
|
.btn-primary:hover {
|
||||||
@@ -157,10 +158,11 @@ img {
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
min-height: 3rem;
|
min-height: 3rem;
|
||||||
padding: 0.7rem 1.35rem;
|
padding: 0.7rem 1.35rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.45);
|
border: 1px solid rgba(255, 255, 255, 0.45);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-weight: 600;
|
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 {
|
.btn-ghost:hover {
|
||||||
@@ -168,6 +170,46 @@ img {
|
|||||||
border-color: #fff;
|
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 {
|
@keyframes rise-in {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -205,8 +247,14 @@ img {
|
|||||||
animation-delay: 0.36s;
|
animation-delay: 0.36s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-shell {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.55);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
.search-shell:focus-within {
|
.search-shell:focus-within {
|
||||||
animation: search-glow 1.6s ease infinite;
|
border-color: rgba(255, 255, 255, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||
+12
-3
@@ -5,17 +5,26 @@ import { Footer } from "@/components/Footer";
|
|||||||
import { Header } from "@/components/Header";
|
import { Header } from "@/components/Header";
|
||||||
import { Hero } from "@/components/Hero";
|
import { Hero } from "@/components/Hero";
|
||||||
import { Products } from "@/components/Products";
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header />
|
<Header />
|
||||||
<main>
|
<main>
|
||||||
<Hero />
|
<Hero />
|
||||||
<Categories />
|
<Categories />
|
||||||
<Products />
|
<SellCta />
|
||||||
|
<Products items={userProducts.items} total={userProducts.total} />
|
||||||
<AboutCta />
|
<AboutCta />
|
||||||
<Articles />
|
<Articles items={blogs.items} total={blogs.total} />
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<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 Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
import { site, stats } from "@/data/home";
|
import { site, stats } from "@/data/home";
|
||||||
|
|
||||||
export function AboutCta() {
|
export function AboutCta() {
|
||||||
@@ -41,12 +42,12 @@ export function AboutCta() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 flex flex-wrap gap-3">
|
<div className="mt-8 flex flex-wrap gap-3">
|
||||||
<a href="#contact" className="btn-primary">
|
<Link href="/contact" className="btn-primary">
|
||||||
تماس بگیرید
|
تماس بگیرید
|
||||||
</a>
|
</Link>
|
||||||
<a href="#categories" className="btn-ghost">
|
<Link href="/about" className="btn-ghost">
|
||||||
بیشتر بدانید
|
بیشتر بدانید
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+96
-38
@@ -1,46 +1,104 @@
|
|||||||
import Image from "next/image";
|
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 (
|
return (
|
||||||
<section id="articles" className="section bg-surface">
|
<section id="articles" className={showHeader ? "section bg-surface" : undefined}>
|
||||||
<div className="container-page">
|
<div className={showHeader ? "container-page" : undefined}>
|
||||||
<div className="section-head">
|
{showHeader ? (
|
||||||
<p className="section-kicker">Insights</p>
|
<div className="section-head">
|
||||||
<h2 className="section-title">مقالات</h2>
|
<p className="section-kicker">Insights</p>
|
||||||
<p className="section-desc">
|
<h2 className="section-title">مقالات</h2>
|
||||||
نکات تخصصی برای خرید هوشمندانه و ارتقاء خطوط تولید صنعتی.
|
<p className="section-desc">
|
||||||
</p>
|
نکات تخصصی برای خرید هوشمندانه و ارتقاء خطوط تولید صنعتی.
|
||||||
</div>
|
</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">
|
||||||
|
{items.map((article) => {
|
||||||
|
const image = resolveBlogImage(article);
|
||||||
|
const href = `/blog/${article.slug}`;
|
||||||
|
|
||||||
<div className="grid gap-5 md:grid-cols-3">
|
return (
|
||||||
{articles.map((article) => (
|
<Link
|
||||||
<a
|
key={article.id}
|
||||||
key={article.id}
|
href={href}
|
||||||
href={article.href}
|
className="group block overflow-hidden border border-line transition hover:border-brand/40"
|
||||||
className="group block overflow-hidden border border-line transition hover:border-brand/40"
|
>
|
||||||
>
|
<div className="relative aspect-[16/10] overflow-hidden bg-[#e8eef4]">
|
||||||
<div className="relative aspect-[16/10] overflow-hidden bg-[#e8eef4]">
|
{image ? (
|
||||||
<Image
|
<Image
|
||||||
src={article.image}
|
src={image}
|
||||||
alt={article.title}
|
alt={article.title}
|
||||||
fill
|
fill
|
||||||
className="object-cover transition duration-500 group-hover:scale-[1.04]"
|
unoptimized
|
||||||
sizes="(max-width: 768px) 100vw, 33vw"
|
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>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showMoreLink ? (
|
||||||
|
<div className="mt-8 text-center">
|
||||||
|
<Link href="/blog" className="btn-primary">
|
||||||
|
{total > items.length ? "مقالات بیشتر" : "همه مقالات"}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-5">
|
) : null} </>
|
||||||
<h3 className="text-base font-extrabold leading-7 text-brand-ink transition group-hover:text-brand">
|
)}
|
||||||
{article.title}
|
|
||||||
</h3>
|
|
||||||
<span className="mt-4 inline-flex items-center gap-2 text-sm font-bold text-brand">
|
|
||||||
بیشتر بخوانید
|
|
||||||
<span aria-hidden>←</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
import { categories } from "@/data/home";
|
import { categories } from "@/data/home";
|
||||||
|
|
||||||
export function Categories() {
|
export function Categories() {
|
||||||
@@ -13,17 +14,17 @@ export function Categories() {
|
|||||||
مسیر سریع به موجودی نو، کارکرده، خطوط سیم و کابل و محتوای آموزشی.
|
مسیر سریع به موجودی نو، کارکرده، خطوط سیم و کابل و محتوای آموزشی.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<a
|
<Link
|
||||||
href="#products"
|
href="/categories"
|
||||||
className="font-industrial text-sm tracking-[0.12em] text-brand transition hover:text-brand-deep"
|
className="font-industrial text-sm tracking-[0.12em] text-brand transition hover:text-brand-deep"
|
||||||
>
|
>
|
||||||
VIEW STOCK →
|
VIEW ALL →
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
{categories.map((cat, index) => (
|
{categories.map((cat, index) => (
|
||||||
<a
|
<Link
|
||||||
key={cat.id}
|
key={cat.id}
|
||||||
href={cat.href}
|
href={cat.href}
|
||||||
className="group relative block min-h-[22rem] overflow-hidden bg-brand-ink"
|
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>
|
<h3 className="mt-1 text-xl font-extrabold">{cat.title}</h3>
|
||||||
<p className="mt-2 text-sm text-white/75">{cat.description}</p>
|
<p className="mt-2 text-sm text-white/75">{cat.description}</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</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 { useEffect, useState } from "react";
|
||||||
import { navItems, site } from "@/data/home";
|
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 [open, setOpen] = useState(false);
|
||||||
const [scrolled, setScrolled] = useState(false);
|
const [scrolled, setScrolled] = useState(forceSolid);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (forceSolid) {
|
||||||
|
setScrolled(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const onScroll = () => setScrolled(window.scrollY > 24);
|
const onScroll = () => setScrolled(window.scrollY > 24);
|
||||||
onScroll();
|
onScroll();
|
||||||
window.addEventListener("scroll", onScroll, { passive: true });
|
window.addEventListener("scroll", onScroll, { passive: true });
|
||||||
return () => window.removeEventListener("scroll", onScroll);
|
return () => window.removeEventListener("scroll", onScroll);
|
||||||
}, []);
|
}, [forceSolid]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.body.style.overflow = open ? "hidden" : "";
|
document.body.style.overflow = open ? "hidden" : "";
|
||||||
@@ -24,7 +32,7 @@ export function Header() {
|
|||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className={`fixed inset-x-0 top-0 z-50 transition-colors duration-300 ${
|
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"
|
? "border-b border-white/10 bg-brand-ink/95 backdrop-blur-md"
|
||||||
: "bg-transparent"
|
: "bg-transparent"
|
||||||
}`}
|
}`}
|
||||||
@@ -63,12 +71,12 @@ export function Header() {
|
|||||||
{site.phoneDisplay}
|
{site.phoneDisplay}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href={site.loginUrl}
|
href={site.customerLoginUrl}
|
||||||
className="btn-primary !min-h-10 !px-4 !text-sm"
|
className="btn-primary !min-h-10 !px-4 !text-sm"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
ورود فروشندگان
|
ورود
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+14
-7
@@ -2,16 +2,18 @@
|
|||||||
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { FormEvent, useState } from "react";
|
import { FormEvent, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { searchSuggestions, site } from "@/data/home";
|
import { searchSuggestions, site } from "@/data/home";
|
||||||
|
|
||||||
export function Hero() {
|
export function Hero() {
|
||||||
|
const router = useRouter();
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
const onSubmit = (e: FormEvent) => {
|
const onSubmit = (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const q = query.trim();
|
const q = query.trim();
|
||||||
if (!q) return;
|
if (!q) return;
|
||||||
window.location.hash = `products`;
|
router.push(`/user-products?q=${encodeURIComponent(q)}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -49,7 +51,7 @@ export function Hero() {
|
|||||||
|
|
||||||
<form
|
<form
|
||||||
onSubmit={onSubmit}
|
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">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-stretch">
|
||||||
<label className="sr-only" htmlFor="machine-search">
|
<label className="sr-only" htmlFor="machine-search">
|
||||||
@@ -61,22 +63,27 @@ export function Hero() {
|
|||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="نام کالای مورد نظر خود را جستجو کنید"
|
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
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap gap-2">
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
{searchSuggestions.map((item) => (
|
{searchSuggestions.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item}
|
key={item}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setQuery(item)}
|
onClick={() => {
|
||||||
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"
|
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}
|
{item}
|
||||||
</button>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+128
-55
@@ -1,64 +1,137 @@
|
|||||||
import Image from "next/image";
|
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 (
|
return (
|
||||||
<section id="products" className="section">
|
<section id="products" className={showHeader ? "section" : undefined}>
|
||||||
<div className="container-page">
|
<div className={showHeader ? "container-page" : undefined}>
|
||||||
<div className="section-head">
|
{showHeader ? (
|
||||||
<p className="section-kicker">Stock Machines</p>
|
<div className="section-head">
|
||||||
<h2 className="section-title">محصولات موجود</h2>
|
<p className="section-kicker">Stock Machines</p>
|
||||||
<p className="section-desc">
|
<h2 className="section-title">محصولات موجود</h2>
|
||||||
منتخب ماشینآلات سنگین و صنعتی از فروشگاه ماشینیفای؛ برای جزئیات و
|
<p className="section-desc">
|
||||||
قیمت، استعلام بگیرید.
|
{query
|
||||||
</p>
|
? `نتایج جستجو برای «${query}»`
|
||||||
</div>
|
: "منتخب ماشینآلات سنگین و صنعتی از فروشگاه ماشینیفای؛ برای جزئیات و قیمت، استعلام بگیرید."}
|
||||||
|
</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">
|
||||||
|
{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}`;
|
||||||
|
|
||||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
return (
|
||||||
{products.map((product) => (
|
<article
|
||||||
<article
|
key={product.id}
|
||||||
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"
|
||||||
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]">
|
|
||||||
<Image
|
|
||||||
src={product.image}
|
|
||||||
alt={product.title}
|
|
||||||
fill
|
|
||||||
className="object-cover transition duration-500 group-hover:scale-[1.04]"
|
|
||||||
sizes="(max-width: 768px) 100vw, 25vw"
|
|
||||||
/>
|
|
||||||
<span className="absolute left-3 top-3 bg-brand-ink/90 px-2.5 py-1 text-xs text-white">
|
|
||||||
{product.condition}
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col p-4">
|
|
||||||
<p className="font-industrial text-[0.68rem] tracking-[0.14em] text-brand">
|
|
||||||
{product.titleEn}
|
|
||||||
</p>
|
|
||||||
<h3 className="mt-1 text-base font-extrabold leading-7 text-brand-ink">
|
|
||||||
<a href={product.href}>{product.title}</a>
|
|
||||||
</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"
|
|
||||||
>
|
>
|
||||||
استعلام از فروشنده
|
<Link
|
||||||
</a>
|
href={href}
|
||||||
</div>
|
className="relative block aspect-[4/3] overflow-hidden bg-[#e8eef4]"
|
||||||
</div>
|
>
|
||||||
</article>
|
{product.imageUrl ? (
|
||||||
))}
|
<Image
|
||||||
</div>
|
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">
|
||||||
|
{condition}
|
||||||
|
</span>
|
||||||
|
) : 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="mt-8 text-center">
|
<div className="flex flex-1 flex-col p-4">
|
||||||
<a href="#contact" className="btn-primary">
|
{product.titleEn ? (
|
||||||
محصولات بیشتر
|
<p className="font-industrial text-[0.68rem] tracking-[0.14em] text-brand">
|
||||||
</a>
|
{product.titleEn}
|
||||||
</div>
|
</p>
|
||||||
|
) : null}
|
||||||
|
<h3 className="mt-1 text-base font-extrabold leading-7 text-brand-ink">
|
||||||
|
<Link href={href}>{title}</Link>
|
||||||
|
</h3>
|
||||||
|
{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"
|
||||||
|
>
|
||||||
|
{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">
|
||||||
|
<Link href="/user-products" className="btn-primary">
|
||||||
|
{total > items.length ? "محصولات بیشتر" : "همه محصولات"}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : null} </>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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",
|
phoneDisplay: "025-36700000",
|
||||||
address: "قم، سالاریه، میدان پیچک، ساختمان بنفشه",
|
address: "قم، سالاریه، میدان پیچک، ساختمان بنفشه",
|
||||||
loginUrl: "https://app.mashinify.com/auth/login",
|
loginUrl: "https://app.mashinify.com/auth/login",
|
||||||
|
customerLoginUrl: "https://customer.mashinify.com/login",
|
||||||
|
sellStartUrl:
|
||||||
|
"https://customer.mashinify.com/login?redirect=%2Fmy-products%2Fnew",
|
||||||
supportLabel: "پشتیبانی",
|
supportLabel: "پشتیبانی",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const navItems = [
|
export const navItems = [
|
||||||
{ label: "خانه", href: "/" },
|
{ label: "خانه", href: "/" },
|
||||||
{ label: "محصولات", href: "#products" },
|
{ label: "محصولات", href: "/user-products" },
|
||||||
{ label: "دستهبندیها", href: "#categories" },
|
{ label: "دستهبندیها", href: "/categories" },
|
||||||
{ label: "مقالات", href: "#articles" },
|
{ label: "مقالات", href: "/blog" },
|
||||||
{ label: "درباره ما", href: "#about" },
|
{ label: "درباره ما", href: "/about" },
|
||||||
{ label: "تماس با ما", href: "#contact" },
|
{ label: "تماس با ما", href: "/contact" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const searchSuggestions = [
|
export const searchSuggestions = [
|
||||||
"لیزر فایبر",
|
"لیزر فایبر",
|
||||||
"اکسترودر سیم",
|
"اکسترودر سیم",
|
||||||
@@ -35,7 +37,7 @@ export const categories = [
|
|||||||
titleEn: "Used Products",
|
titleEn: "Used Products",
|
||||||
description: "ماشینآلات دستدوم بازرسیشده برای تولید مقرونبهصرفه",
|
description: "ماشینآلات دستدوم بازرسیشده برای تولید مقرونبهصرفه",
|
||||||
image: "/images/categories/used.jpg",
|
image: "/images/categories/used.jpg",
|
||||||
href: "#products",
|
href: "/user-products?condition=stock",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "new",
|
id: "new",
|
||||||
@@ -43,7 +45,7 @@ export const categories = [
|
|||||||
titleEn: "New Products",
|
titleEn: "New Products",
|
||||||
description: "دستگاههای نو با گارانتی و پشتیبانی فروشنده",
|
description: "دستگاههای نو با گارانتی و پشتیبانی فروشنده",
|
||||||
image: "/images/categories/new.jpg",
|
image: "/images/categories/new.jpg",
|
||||||
href: "#products",
|
href: "/user-products?condition=new",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "wire-cable",
|
id: "wire-cable",
|
||||||
@@ -51,7 +53,7 @@ export const categories = [
|
|||||||
titleEn: "Wire & Cable",
|
titleEn: "Wire & Cable",
|
||||||
description: "اکسترودر، مولتیوایر و خطوط تولید سیم و کابل",
|
description: "اکسترودر، مولتیوایر و خطوط تولید سیم و کابل",
|
||||||
image: "/images/categories/wire-cable.jpg",
|
image: "/images/categories/wire-cable.jpg",
|
||||||
href: "#products",
|
href: "/categories",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "education",
|
id: "education",
|
||||||
@@ -59,50 +61,7 @@ export const categories = [
|
|||||||
titleEn: "Education",
|
titleEn: "Education",
|
||||||
description: "راهنما و مقالات تخصصی انتخاب و نگهداری دستگاه",
|
description: "راهنما و مقالات تخصصی انتخاب و نگهداری دستگاه",
|
||||||
image: "/images/categories/education.jpg",
|
image: "/images/categories/education.jpg",
|
||||||
href: "#articles",
|
href: "/blog",
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -112,37 +71,14 @@ export const stats = [
|
|||||||
{ value: "New & Used", label: "موجودی متنوع" },
|
{ 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 = {
|
export const footerLinks = {
|
||||||
pages: [
|
pages: [
|
||||||
{ label: "صفحه اصلی", href: "/" },
|
{ label: "صفحه اصلی", href: "/" },
|
||||||
{ label: "محصولات", href: "#products" },
|
{ label: "محصولات", href: "/user-products" },
|
||||||
{ label: "مقالات", href: "#articles" },
|
{ label: "دستهبندیها", href: "/categories" },
|
||||||
{ label: "درباره ما", href: "#about" },
|
{ label: "مقالات", href: "/blog" },
|
||||||
{ label: "تماس با ما", href: "#contact" },
|
{ label: "درباره ما", href: "/about" },
|
||||||
|
{ label: "تماس با ما", href: "/contact" },
|
||||||
],
|
],
|
||||||
categories: categories.map((c) => ({
|
categories: categories.map((c) => ({
|
||||||
label: c.title,
|
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