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:
Alireza Hassani
2026-08-10 10:54:27 +03:30
co-authored by Cursor
parent e74e5ca45f
commit 8194b7292f
31 changed files with 2160 additions and 204 deletions
+8 -1
View File
@@ -1,7 +1,14 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
images: {
remotePatterns: [
{ protocol: "https", hostname: "**.parspack.net" },
{ protocol: "https", hostname: "cdn.meshkee.com" },
{ protocol: "https", hostname: "**.meshkee.com" },
{ protocol: "https", hostname: "api.meshkee.com" },
],
},
};
export default nextConfig;
+76
View File
@@ -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>
);
}
+107
View File
@@ -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>
);
}
+94
View File
@@ -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>
);
}
+83
View File
@@ -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>
);
}
+106
View File
@@ -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
View File
@@ -140,10 +140,11 @@ img {
gap: 0.5rem;
min-height: 3rem;
padding: 0.7rem 1.35rem;
border-radius: 0.75rem;
background: var(--brand);
color: #fff;
font-weight: 700;
transition: background 0.25s ease, transform 0.25s ease;
transition: background 0.2s ease;
}
.btn-primary:hover {
@@ -157,10 +158,11 @@ img {
gap: 0.5rem;
min-height: 3rem;
padding: 0.7rem 1.35rem;
border-radius: 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.45);
color: #fff;
font-weight: 600;
transition: background 0.25s ease, border-color 0.25s ease;
transition: background 0.2s ease, border-color 0.2s ease;
}
.btn-ghost:hover {
@@ -168,6 +170,46 @@ img {
border-color: #fff;
}
.field-control {
border: 1px solid var(--line);
border-radius: 0.75rem;
background: #fff;
padding: 0.7rem 1rem;
color: var(--foreground);
outline: none;
transition: border-color 0.2s ease;
}
.field-control:focus,
.field-control:focus-within {
border-color: var(--brand);
}
.btn-secondary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
min-height: 3rem;
padding: 0.7rem 1.35rem;
border-radius: 0.75rem;
border: 1px solid var(--brand);
color: var(--brand);
font-weight: 700;
background: #fff;
transition: background 0.2s ease, color 0.2s ease;
}
.btn-secondary:hover {
background: var(--brand);
color: #fff;
}
.filter-shell {
border: 1px solid var(--line);
border-radius: 1rem;
background: #fff;
}
@keyframes rise-in {
from {
opacity: 0;
@@ -205,8 +247,14 @@ img {
animation-delay: 0.36s;
}
.search-shell {
background: #fff;
border: 1px solid rgba(255, 255, 255, 0.55);
box-shadow: none;
}
.search-shell:focus-within {
animation: search-glow 1.6s ease infinite;
border-color: rgba(255, 255, 255, 0.8);
}
@media (prefers-reduced-motion: reduce) {
+12 -3
View File
@@ -5,17 +5,26 @@ import { Footer } from "@/components/Footer";
import { Header } from "@/components/Header";
import { Hero } from "@/components/Hero";
import { Products } from "@/components/Products";
import { SellCta } from "@/components/SellCta";
import { listBlogs } from "@/lib/api/blogs";
import { listUserProducts } from "@/lib/api/user-products";
export default async function Home() {
const [userProducts, blogs] = await Promise.all([
listUserProducts({ page: 1, pageSize: 8 }),
listBlogs({ page: 1, pageSize: 3 }),
]);
export default function Home() {
return (
<>
<Header />
<main>
<Hero />
<Categories />
<Products />
<SellCta />
<Products items={userProducts.items} total={userProducts.total} />
<AboutCta />
<Articles />
<Articles items={blogs.items} total={blogs.total} />
</main>
<Footer />
</>
+185
View File
@@ -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>
);
}
+141
View File
@@ -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>
);
}
+5 -4
View File
@@ -1,4 +1,5 @@
import Image from "next/image";
import Link from "next/link";
import { site, stats } from "@/data/home";
export function AboutCta() {
@@ -41,12 +42,12 @@ export function AboutCta() {
</div>
<div className="mt-8 flex flex-wrap gap-3">
<a href="#contact" className="btn-primary">
<Link href="/contact" className="btn-primary">
تماس بگیرید
</a>
<a href="#categories" className="btn-ghost">
</Link>
<Link href="/about" className="btn-ghost">
بیشتر بدانید
</a>
</Link>
</div>
</div>
</div>
+69 -11
View File
@@ -1,10 +1,25 @@
import Image from "next/image";
import { articles } from "@/data/home";
import Link from "next/link";
import { resolveBlogImage } from "@/lib/api/blogs";
import type { BlogPost } from "@/lib/api/types";
export function Articles() {
type ArticlesProps = {
items: BlogPost[];
total?: number;
showHeader?: boolean;
showMoreLink?: boolean;
};
export function Articles({
items,
total = 0,
showHeader = true,
showMoreLink = true,
}: ArticlesProps) {
return (
<section id="articles" className="section bg-surface">
<div className="container-page">
<section id="articles" className={showHeader ? "section bg-surface" : undefined}>
<div className={showHeader ? "container-page" : undefined}>
{showHeader ? (
<div className="section-head">
<p className="section-kicker">Insights</p>
<h2 className="section-title">مقالات</h2>
@@ -12,35 +27,78 @@ export function Articles() {
نکات تخصصی برای خرید هوشمندانه و ارتقاء خطوط تولید صنعتی.
</p>
</div>
) : null}
{items.length === 0 ? (
<div className="border border-line bg-background px-6 py-14 text-center">
<p className="text-base font-bold text-brand-ink">
هنوز مقالهای منتشر نشده است.
</p>
<p className="mt-2 text-sm text-muted">
بهمحض انتشار مطالب جدید، در این بخش نمایش داده میشوند.
</p>
<Link href="/blog" className="btn-primary mt-6 inline-flex">
مشاهده بلاگ
</Link>
</div>
) : (
<>
<div className="grid gap-5 md:grid-cols-3">
{articles.map((article) => (
<a
{items.map((article) => {
const image = resolveBlogImage(article);
const href = `/blog/${article.slug}`;
return (
<Link
key={article.id}
href={article.href}
href={href}
className="group block overflow-hidden border border-line transition hover:border-brand/40"
>
<div className="relative aspect-[16/10] overflow-hidden bg-[#e8eef4]">
{image ? (
<Image
src={article.image}
src={image}
alt={article.title}
fill
unoptimized
className="object-cover transition duration-500 group-hover:scale-[1.04]"
sizes="(max-width: 768px) 100vw, 33vw"
/>
) : (
<div className="absolute inset-0 bg-[linear-gradient(135deg,#d7e0ea,#eef3f8)]" />
)}
</div>
<div className="p-5">
{article.categoryName ? (
<p className="mb-2 text-xs font-bold text-brand">
{article.categoryName}
</p>
) : null}
<h3 className="text-base font-extrabold leading-7 text-brand-ink transition group-hover:text-brand">
{article.title}
</h3>
{article.abstract ? (
<p className="mt-2 line-clamp-2 text-sm text-muted">
{article.abstract}
</p>
) : null}
<span className="mt-4 inline-flex items-center gap-2 text-sm font-bold text-brand">
بیشتر بخوانید
<span aria-hidden></span>
</span>
</div>
</a>
))}
</Link>
);
})}
</div>
{showMoreLink ? (
<div className="mt-8 text-center">
<Link href="/blog" className="btn-primary">
{total > items.length ? "مقالات بیشتر" : "همه مقالات"}
</Link>
</div>
) : null} </>
)}
</div>
</section>
);
+7 -6
View File
@@ -1,4 +1,5 @@
import Image from "next/image";
import Link from "next/link";
import { categories } from "@/data/home";
export function Categories() {
@@ -13,17 +14,17 @@ export function Categories() {
مسیر سریع به موجودی نو، کارکرده، خطوط سیم و کابل و محتوای آموزشی.
</p>
</div>
<a
href="#products"
<Link
href="/categories"
className="font-industrial text-sm tracking-[0.12em] text-brand transition hover:text-brand-deep"
>
VIEW STOCK
</a>
VIEW ALL
</Link>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{categories.map((cat, index) => (
<a
<Link
key={cat.id}
href={cat.href}
className="group relative block min-h-[22rem] overflow-hidden bg-brand-ink"
@@ -44,7 +45,7 @@ export function Categories() {
<h3 className="mt-1 text-xl font-extrabold">{cat.title}</h3>
<p className="mt-2 text-sm text-white/75">{cat.description}</p>
</div>
</a>
</Link>
))}
</div>
</div>
+271
View File
@@ -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>
);
}
+118
View File
@@ -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>
);
}
+14 -6
View File
@@ -3,16 +3,24 @@
import { useEffect, useState } from "react";
import { navItems, site } from "@/data/home";
export function Header() {
type HeaderProps = {
forceSolid?: boolean;
};
export function Header({ forceSolid = false }: HeaderProps) {
const [open, setOpen] = useState(false);
const [scrolled, setScrolled] = useState(false);
const [scrolled, setScrolled] = useState(forceSolid);
useEffect(() => {
if (forceSolid) {
setScrolled(true);
return;
}
const onScroll = () => setScrolled(window.scrollY > 24);
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
}, [forceSolid]);
useEffect(() => {
document.body.style.overflow = open ? "hidden" : "";
@@ -24,7 +32,7 @@ export function Header() {
return (
<header
className={`fixed inset-x-0 top-0 z-50 transition-colors duration-300 ${
scrolled || open
scrolled || open || forceSolid
? "border-b border-white/10 bg-brand-ink/95 backdrop-blur-md"
: "bg-transparent"
}`}
@@ -63,12 +71,12 @@ export function Header() {
{site.phoneDisplay}
</a>
<a
href={site.loginUrl}
href={site.customerLoginUrl}
className="btn-primary !min-h-10 !px-4 !text-sm"
target="_blank"
rel="noopener noreferrer"
>
ورود فروشندگان
ورود
</a>
</div>
+14 -7
View File
@@ -2,16 +2,18 @@
import Image from "next/image";
import { FormEvent, useState } from "react";
import { useRouter } from "next/navigation";
import { searchSuggestions, site } from "@/data/home";
export function Hero() {
const router = useRouter();
const [query, setQuery] = useState("");
const onSubmit = (e: FormEvent) => {
e.preventDefault();
const q = query.trim();
if (!q) return;
window.location.hash = `products`;
router.push(`/user-products?q=${encodeURIComponent(q)}`);
};
return (
@@ -49,7 +51,7 @@ export function Hero() {
<form
onSubmit={onSubmit}
className="animate-rise animate-rise-delay-3 search-shell mt-8 max-w-3xl border border-white/15 bg-white/95 p-2 shadow-[0_20px_60px_rgba(0,0,0,0.28)] backdrop-blur-sm"
className="animate-rise animate-rise-delay-3 search-shell mt-8 max-w-3xl rounded-2xl p-2"
>
<div className="flex flex-col gap-2 sm:flex-row sm:items-stretch">
<label className="sr-only" htmlFor="machine-search">
@@ -61,22 +63,27 @@ export function Hero() {
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="نام کالای مورد نظر خود را جستجو کنید"
className="min-h-14 flex-1 bg-transparent px-4 text-base text-brand-ink outline-none placeholder:text-[#8a97a6]"
className="min-h-14 flex-1 rounded-xl bg-transparent px-4 text-base text-brand-ink outline-none placeholder:text-[#8a97a6]"
required
/>
<button type="submit" className="btn-primary min-h-14 px-8">
<button
type="submit"
className="btn-primary min-h-14 rounded-xl px-8"
>
جستجو
</button>
</div>
</form>
<div className="mt-4 flex flex-wrap gap-2">
{searchSuggestions.map((item) => (
<button
key={item}
type="button"
onClick={() => setQuery(item)}
className="border border-white/20 bg-white/5 px-3 py-1.5 text-xs text-white/80 transition hover:border-brand hover:bg-brand/20 hover:text-white"
onClick={() => {
setQuery(item);
router.push(`/user-products?q=${encodeURIComponent(item)}`);
}}
className="border border-white/20 bg-white/5 px-3 py-1.5 text-xs text-white/80 transition hover:border-brand hover:bg-brand/20 hover:text-white rounded-lg"
>
{item}
</button>
+38
View File
@@ -0,0 +1,38 @@
import Image from "next/image";
type PageBannerProps = {
kicker: string;
title: string;
description?: string;
};
export function PageBanner({ kicker, title, description }: PageBannerProps) {
return (
<section className="relative overflow-hidden bg-brand-ink text-white">
<Image
src="/images/hero/search-bg.jpg"
alt=""
fill
priority
className="object-cover object-[center_40%]"
sizes="100vw"
/>
<div className="absolute inset-0 bg-brand-ink/80" />
<div className="absolute inset-0 bg-brand/35" />
<div className="relative z-10 container-page py-9 md:py-10">
<p className="font-industrial mb-2 text-[0.72rem] tracking-[0.18em] text-white/80 uppercase">
{kicker}
</p>
<h1 className="max-w-3xl text-3xl font-extrabold leading-tight text-white md:text-[2.35rem]">
{title}
</h1>
{description ? (
<p className="mt-2 max-w-2xl text-sm leading-7 text-white/70 md:text-[0.95rem]">
{description}
</p>
) : null}
</div>
</section>
);
}
+99 -26
View File
@@ -1,64 +1,137 @@
import Image from "next/image";
import { products } from "@/data/home";
import Link from "next/link";
import {
formatCondition,
formatPrice,
productCategory,
productTitle,
} from "@/lib/api/user-products";
import type { UserProductListItem } from "@/lib/api/types";
export function Products() {
type ProductsProps = {
items: UserProductListItem[];
total?: number;
query?: string;
showHeader?: boolean;
showMoreLink?: boolean;
};
export function Products({
items,
total = 0,
query,
showHeader = true,
showMoreLink = true,
}: ProductsProps) {
return (
<section id="products" className="section">
<div className="container-page">
<section id="products" className={showHeader ? "section" : undefined}>
<div className={showHeader ? "container-page" : undefined}>
{showHeader ? (
<div className="section-head">
<p className="section-kicker">Stock Machines</p>
<h2 className="section-title">محصولات موجود</h2>
<p className="section-desc">
منتخب ماشینآلات سنگین و صنعتی از فروشگاه ماشینیفای؛ برای جزئیات و
قیمت، استعلام بگیرید.
{query
? `نتایج جستجو برای «${query}»`
: "منتخب ماشین‌آلات سنگین و صنعتی از فروشگاه ماشینیفای؛ برای جزئیات و قیمت، استعلام بگیرید."}
</p>
</div>
) : query ? (
<p className="mb-6 text-sm text-muted">نتایج جستجو برای «{query}»</p>
) : null}
{items.length === 0 ? (
<div className="border border-line bg-surface px-6 py-14 text-center">
<p className="text-base font-bold text-brand-ink">
در حال حاضر محصول منتشرشدهای یافت نشد.
</p>
<p className="mt-2 text-sm text-muted">
بهمحض انتشار آگهیهای جدید، اینجا نمایش داده میشوند.
</p>
<Link href="/user-products" className="btn-primary mt-6 inline-flex">
مشاهده همه محصولات
</Link>
</div>
) : (
<>
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
{products.map((product) => (
{items.map((product) => {
const title = productTitle(product);
const category = productCategory(product);
const condition = formatCondition(product.condition);
const price = formatPrice(product.price, product.priceCurrency);
const href = `/user-products/${product.slug}`;
return (
<article
key={product.id}
className="group flex h-full flex-col overflow-hidden border border-line bg-surface transition duration-300 hover:-translate-y-1 hover:border-brand/40"
>
<a href={product.href} className="relative block aspect-[4/3] overflow-hidden bg-[#e8eef4]">
<Link
href={href}
className="relative block aspect-[4/3] overflow-hidden bg-[#e8eef4]"
>
{product.imageUrl ? (
<Image
src={product.image}
alt={product.title}
src={product.imageUrl}
alt={title}
fill
unoptimized
className="object-cover transition duration-500 group-hover:scale-[1.04]"
sizes="(max-width: 768px) 100vw, 25vw"
/>
) : (
<div className="absolute inset-0 bg-[linear-gradient(135deg,#d7e0ea,#eef3f8)]" />
)}
{condition ? (
<span className="absolute left-3 top-3 bg-brand-ink/90 px-2.5 py-1 text-xs text-white">
{product.condition}
{condition}
</span>
</a>
) : null}
{product.promoted ? (
<span className="absolute right-3 top-3 bg-brand px-2.5 py-1 text-xs text-white">
ویژه
</span>
) : null}
</Link>
<div className="flex flex-1 flex-col p-4">
{product.titleEn ? (
<p className="font-industrial text-[0.68rem] tracking-[0.14em] text-brand">
{product.titleEn}
</p>
) : null}
<h3 className="mt-1 text-base font-extrabold leading-7 text-brand-ink">
<a href={product.href}>{product.title}</a>
<Link href={href}>{title}</Link>
</h3>
<p className="mt-2 text-sm text-muted">{product.category}</p>
<div className="mt-auto pt-4">
<a
href="#contact"
className="inline-flex w-full items-center justify-center border border-brand px-3 py-2.5 text-sm font-bold text-brand transition hover:bg-brand hover:text-white"
{category ? (
<p className="mt-2 text-sm text-muted">{category}</p>
) : null}
{price ? (
<p
dir="ltr"
className="font-industrial mt-2 w-full text-left text-sm font-bold tracking-wide text-brand-ink"
>
استعلام از فروشنده
</a>
</div>
{price}
</p>
) : null}
<div className="mt-auto pt-4">
<Link href={href} className="btn-secondary w-full !min-h-11 text-sm">
جزئیات و استعلام
</Link> </div>
</div>
</article>
))}
);
})}
</div>
{showMoreLink ? (
<div className="mt-8 text-center">
<a href="#contact" className="btn-primary">
محصولات بیشتر
</a>
<Link href="/user-products" className="btn-primary">
{total > items.length ? "محصولات بیشتر" : "همه محصولات"}
</Link>
</div>
) : null} </>
)}
</div>
</section>
);
+53
View File
@@ -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>
);
}
+36
View File
@@ -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 />
</>
);
}
+42
View File
@@ -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
View File
@@ -8,18 +8,20 @@ export const site = {
phoneDisplay: "025-36700000",
address: "قم، سالاریه، میدان پیچک، ساختمان بنفشه",
loginUrl: "https://app.mashinify.com/auth/login",
customerLoginUrl: "https://customer.mashinify.com/login",
sellStartUrl:
"https://customer.mashinify.com/login?redirect=%2Fmy-products%2Fnew",
supportLabel: "پشتیبانی",
};
export const navItems = [
{ label: "خانه", href: "/" },
{ label: "محصولات", href: "#products" },
{ label: "دسته‌بندی‌ها", href: "#categories" },
{ label: "مقالات", href: "#articles" },
{ label: "درباره ما", href: "#about" },
{ label: "تماس با ما", href: "#contact" },
{ label: "محصولات", href: "/user-products" },
{ label: "دسته‌بندی‌ها", href: "/categories" },
{ label: "مقالات", href: "/blog" },
{ label: "درباره ما", href: "/about" },
{ label: "تماس با ما", href: "/contact" },
];
export const searchSuggestions = [
"لیزر فایبر",
"اکسترودر سیم",
@@ -35,7 +37,7 @@ export const categories = [
titleEn: "Used Products",
description: "ماشین‌آلات دست‌دوم بازرسی‌شده برای تولید مقرون‌به‌صرفه",
image: "/images/categories/used.jpg",
href: "#products",
href: "/user-products?condition=stock",
},
{
id: "new",
@@ -43,7 +45,7 @@ export const categories = [
titleEn: "New Products",
description: "دستگاه‌های نو با گارانتی و پشتیبانی فروشنده",
image: "/images/categories/new.jpg",
href: "#products",
href: "/user-products?condition=new",
},
{
id: "wire-cable",
@@ -51,7 +53,7 @@ export const categories = [
titleEn: "Wire & Cable",
description: "اکسترودر، مولتی‌وایر و خطوط تولید سیم و کابل",
image: "/images/categories/wire-cable.jpg",
href: "#products",
href: "/categories",
},
{
id: "education",
@@ -59,50 +61,7 @@ export const categories = [
titleEn: "Education",
description: "راهنما و مقالات تخصصی انتخاب و نگهداری دستگاه",
image: "/images/categories/education.jpg",
href: "#articles",
},
];
export const products = [
{
id: 21447,
title: "دستگاه لیزر فایبر برش فلزات",
titleEn: "Fiber Laser Cutting Machine",
category: "ماشین‌آلات برش",
condition: "نو",
image: "/images/products/fiber-laser.jpg",
href: "#products",
inquiry: true,
},
{
id: 21446,
title: "دستگاه گرانول‌ساز",
titleEn: "Plastic Granulator Machine",
category: "بازیافت پلاستیک",
condition: "نو",
image: "/images/products/granulator.jpg",
href: "#products",
inquiry: true,
},
{
id: 21427,
title: "دستگاه اکسترودر سیم",
titleEn: "Wire Extruder Machine",
category: "سیم و کابل",
condition: "کارکرده",
image: "/images/products/wire-extruder.jpg",
href: "#products",
inquiry: true,
},
{
id: 21426,
title: "دستگاه اکسترود تفلون",
titleEn: "Teflon Extruder Machine",
category: "سیم و کابل",
condition: "کارکرده",
image: "/images/products/teflon-extruder.jpg",
href: "#products",
inquiry: true,
href: "/blog",
},
];
@@ -112,37 +71,14 @@ export const stats = [
{ value: "New & Used", label: "موجودی متنوع" },
];
export const articles = [
{
id: 2898,
title:
"مزایای ارتقاء دستگاه‌های صنعتی قدیمی و تأثیر آن بر افزایش بهره‌وری",
image: "/images/articles/upgrade.jpg",
href: "#articles",
},
{
id: 2895,
title:
"چرا سرمایه‌گذاری در دستگاه‌های صنعتی هوشمند، آینده کسب‌وکار شما را تضمین می‌کند؟",
image: "/images/articles/smart-machines.jpg",
href: "#articles",
},
{
id: 2892,
title:
"ماشین‌آلات صنعتی دست دوم: فرصت هوشمندانه برای تولیدکنندگان با بودجه محدود",
image: "/images/articles/used-machines.jpg",
href: "#articles",
},
];
export const footerLinks = {
pages: [
{ label: "صفحه اصلی", href: "/" },
{ label: "محصولات", href: "#products" },
{ label: "مقالات", href: "#articles" },
{ label: "درباره ما", href: "#about" },
{ label: "تماس با ما", href: "#contact" },
{ label: "محصولات", href: "/user-products" },
{ label: "دسته‌بندی‌ها", href: "/categories" },
{ label: "مقالات", href: "/blog" },
{ label: "درباره ما", href: "/about" },
{ label: "تماس با ما", href: "/contact" },
],
categories: categories.map((c) => ({
label: c.title,
+57
View File
@@ -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;
}
+11
View File
@@ -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;
}
}
+50
View File
@@ -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;
}
+92
View File
@@ -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>;
}
+6
View File
@@ -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";
+9
View File
@@ -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,
);
}
+134
View File
@@ -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;
};
+99
View File
@@ -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}`;
}