mirror of
https://git.meshkee.com/Meshkee-Websites/mashinify.git
synced 2026-08-12 04:30:59 +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
@@ -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>
|
||||
|
||||
+96
-38
@@ -1,46 +1,104 @@
|
||||
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">
|
||||
<div className="section-head">
|
||||
<p className="section-kicker">Insights</p>
|
||||
<h2 className="section-title">مقالات</h2>
|
||||
<p className="section-desc">
|
||||
نکات تخصصی برای خرید هوشمندانه و ارتقاء خطوط تولید صنعتی.
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
<p className="section-desc">
|
||||
نکات تخصصی برای خرید هوشمندانه و ارتقاء خطوط تولید صنعتی.
|
||||
</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">
|
||||
{articles.map((article) => (
|
||||
<a
|
||||
key={article.id}
|
||||
href={article.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
|
||||
src={article.image}
|
||||
alt={article.title}
|
||||
fill
|
||||
className="object-cover transition duration-500 group-hover:scale-[1.04]"
|
||||
sizes="(max-width: 768px) 100vw, 33vw"
|
||||
/>
|
||||
return (
|
||||
<Link
|
||||
key={article.id}
|
||||
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={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>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{showMoreLink ? (
|
||||
<div className="mt-8 text-center">
|
||||
<Link href="/blog" className="btn-primary">
|
||||
{total > items.length ? "مقالات بیشتر" : "همه مقالات"}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<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>
|
||||
) : null} </>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { categories } from "@/data/home";
|
||||
|
||||
export function Categories() {
|
||||
@@ -13,17 +14,17 @@ export function Categories() {
|
||||
مسیر سریع به موجودی نو، کارکرده، خطوط سیم و کابل و محتوای آموزشی.
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="#products"
|
||||
<Link
|
||||
href="/categories"
|
||||
className="font-industrial text-sm tracking-[0.12em] text-brand transition hover:text-brand-deep"
|
||||
>
|
||||
VIEW STOCK →
|
||||
</a>
|
||||
VIEW ALL →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{categories.map((cat, index) => (
|
||||
<a
|
||||
<Link
|
||||
key={cat.id}
|
||||
href={cat.href}
|
||||
className="group relative block min-h-[22rem] overflow-hidden bg-brand-ink"
|
||||
@@ -44,7 +45,7 @@ export function Categories() {
|
||||
<h3 className="mt-1 text-xl font-extrabold">{cat.title}</h3>
|
||||
<p className="mt-2 text-sm text-white/75">{cat.description}</p>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
buildCategoryTree,
|
||||
categoryLabel,
|
||||
findCategoryById,
|
||||
} from "@/lib/api/categories";
|
||||
import type { Category, CategoryTreeNode } from "@/lib/api/types";
|
||||
|
||||
type CategoryTreeSelectProps = {
|
||||
categories: Category[];
|
||||
name?: string;
|
||||
defaultValue?: string;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
function filterTree(
|
||||
nodes: CategoryTreeNode[],
|
||||
query: string,
|
||||
): CategoryTreeNode[] {
|
||||
if (!query) return nodes;
|
||||
const q = query.trim().toLowerCase();
|
||||
|
||||
const walk = (list: CategoryTreeNode[]): CategoryTreeNode[] => {
|
||||
const result: CategoryTreeNode[] = [];
|
||||
for (const node of list) {
|
||||
const label = categoryLabel(node).toLowerCase();
|
||||
const en = node.name.toLowerCase();
|
||||
const children = walk(node.children);
|
||||
if (label.includes(q) || en.includes(q) || children.length > 0) {
|
||||
result.push({ ...node, children });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
return walk(nodes);
|
||||
}
|
||||
|
||||
function TreeNodes({
|
||||
nodes,
|
||||
depth,
|
||||
selectedId,
|
||||
onSelect,
|
||||
expanded,
|
||||
toggle,
|
||||
forceExpand,
|
||||
}: {
|
||||
nodes: CategoryTreeNode[];
|
||||
depth: number;
|
||||
selectedId: string;
|
||||
onSelect: (id: string, label: string) => void;
|
||||
expanded: Set<string>;
|
||||
toggle: (id: string) => void;
|
||||
forceExpand: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ul className={depth === 0 ? "space-y-0.5" : "mt-0.5 space-y-0.5"}>
|
||||
{nodes.map((node) => {
|
||||
const hasChildren = node.children.length > 0;
|
||||
const isOpen = forceExpand || expanded.has(node.id);
|
||||
const label = categoryLabel(node);
|
||||
const selected = selectedId === node.id;
|
||||
|
||||
return (
|
||||
<li key={node.id}>
|
||||
<div
|
||||
className={`flex items-center gap-1 rounded-lg ${
|
||||
selected ? "bg-brand/10" : "hover:bg-[#eef4fa]"
|
||||
}`}
|
||||
style={{ paddingInlineStart: `${depth * 0.85}rem` }}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isOpen ? "بستن" : "باز کردن"}
|
||||
onClick={() => toggle(node.id)}
|
||||
className="grid h-8 w-8 shrink-0 place-items-center rounded-md text-muted transition hover:bg-white hover:text-brand"
|
||||
>
|
||||
<span
|
||||
className={`block text-xs transition ${isOpen ? "rotate-90" : ""}`}
|
||||
aria-hidden
|
||||
>
|
||||
▶
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<span className="inline-block w-8 shrink-0" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(node.id, label)}
|
||||
className={`min-h-9 flex-1 rounded-lg px-2 py-1.5 text-right text-sm transition ${
|
||||
selected
|
||||
? "font-bold text-brand"
|
||||
: "font-medium text-brand-ink"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</div>
|
||||
{hasChildren && isOpen ? (
|
||||
<TreeNodes
|
||||
nodes={node.children}
|
||||
depth={depth + 1}
|
||||
selectedId={selectedId}
|
||||
onSelect={onSelect}
|
||||
expanded={expanded}
|
||||
toggle={toggle}
|
||||
forceExpand={forceExpand}
|
||||
/>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export function CategoryTreeSelect({
|
||||
categories,
|
||||
name = "categoryId",
|
||||
defaultValue = "",
|
||||
placeholder = "دستهبندی",
|
||||
}: CategoryTreeSelectProps) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const tree = useMemo(() => buildCategoryTree(categories), [categories]);
|
||||
const initial = findCategoryById(categories, defaultValue);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [selectedId, setSelectedId] = useState(defaultValue);
|
||||
const [selectedLabel, setSelectedLabel] = useState(
|
||||
initial ? categoryLabel(initial) : "",
|
||||
);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => {
|
||||
const ids = new Set<string>();
|
||||
if (defaultValue) {
|
||||
let current = findCategoryById(categories, defaultValue);
|
||||
while (current?.parentId) {
|
||||
ids.add(current.parentId);
|
||||
current = findCategoryById(categories, current.parentId);
|
||||
}
|
||||
}
|
||||
for (const root of tree) ids.add(root.id);
|
||||
return ids;
|
||||
});
|
||||
|
||||
const filtered = useMemo(() => filterTree(tree, query), [tree, query]);
|
||||
const searching = query.trim().length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
const onPointerDown = (event: MouseEvent) => {
|
||||
if (!rootRef.current?.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", onPointerDown);
|
||||
return () => document.removeEventListener("mousedown", onPointerDown);
|
||||
}, []);
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const onSelect = (id: string, label: string) => {
|
||||
setSelectedId(id);
|
||||
setSelectedLabel(label);
|
||||
setQuery("");
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
setSelectedId("");
|
||||
setSelectedLabel("");
|
||||
setQuery("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative min-w-[14rem]">
|
||||
<input type="hidden" name={name} value={selectedId} />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="field-control flex w-full min-h-12 items-center justify-between gap-3 text-right"
|
||||
>
|
||||
<span
|
||||
className={selectedLabel ? "font-bold text-brand-ink" : "text-muted"}
|
||||
>
|
||||
{selectedLabel || placeholder}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
{selectedId ? (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
clear();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
clear();
|
||||
}
|
||||
}}
|
||||
className="rounded-md px-1.5 py-0.5 text-xs text-muted hover:bg-white hover:text-brand"
|
||||
>
|
||||
پاک کردن
|
||||
</span>
|
||||
) : null}
|
||||
<span aria-hidden className="text-xs text-muted">
|
||||
{open ? "▴" : "▾"}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div className="absolute inset-x-0 top-[calc(100%+0.4rem)] z-40 overflow-hidden rounded-xl border border-line bg-surface shadow-[0_18px_50px_rgba(6,35,63,0.16)]">
|
||||
<div className="border-b border-line p-2">
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="جستجوی دسته..."
|
||||
className="field-control min-h-10 w-full"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-72 overflow-y-auto p-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
clear();
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`mb-1 flex min-h-9 w-full items-center rounded-lg px-3 text-sm transition hover:bg-[#eef4fa] ${
|
||||
!selectedId ? "font-bold text-brand" : "text-brand-ink"
|
||||
}`}
|
||||
>
|
||||
همه دستهها
|
||||
</button>
|
||||
{filtered.length === 0 ? (
|
||||
<p className="px-3 py-6 text-center text-sm text-muted">
|
||||
دستهای یافت نشد
|
||||
</p>
|
||||
) : (
|
||||
<TreeNodes
|
||||
nodes={filtered}
|
||||
depth={0}
|
||||
selectedId={selectedId}
|
||||
onSelect={onSelect}
|
||||
expanded={expanded}
|
||||
toggle={toggle}
|
||||
forceExpand={searching}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { submitContact } from "@/lib/api/contact";
|
||||
import { ApiError } from "@/lib/api/client";
|
||||
|
||||
export function ContactForm() {
|
||||
const [pending, setPending] = useState(false);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setPending(true);
|
||||
setSuccess(null);
|
||||
setError(null);
|
||||
|
||||
const form = event.currentTarget;
|
||||
const data = new FormData(form);
|
||||
|
||||
try {
|
||||
const result = await submitContact({
|
||||
title: String(data.get("title") || "").trim(),
|
||||
name: String(data.get("name") || "").trim(),
|
||||
email: String(data.get("email") || "").trim() || undefined,
|
||||
cellNumber: String(data.get("cellNumber") || "").trim() || undefined,
|
||||
text: String(data.get("text") || "").trim(),
|
||||
});
|
||||
setSuccess(result.message || "پیام شما با موفقیت ارسال شد.");
|
||||
form.reset();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: "ارسال پیام با خطا مواجه شد. دوباره تلاش کنید.",
|
||||
);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="block space-y-2 text-sm">
|
||||
<span className="font-bold text-brand-ink">نام</span>
|
||||
<input
|
||||
name="name"
|
||||
required
|
||||
className="field-control min-h-12 w-full"
|
||||
placeholder="نام و نام خانوادگی"
|
||||
/>
|
||||
</label>
|
||||
<label className="block space-y-2 text-sm">
|
||||
<span className="font-bold text-brand-ink">موضوع</span>
|
||||
<input
|
||||
name="title"
|
||||
required
|
||||
className="field-control min-h-12 w-full"
|
||||
placeholder="موضوع پیام"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="block space-y-2 text-sm">
|
||||
<span className="font-bold text-brand-ink">ایمیل</span>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
className="field-control min-h-12 w-full"
|
||||
placeholder="email@example.com"
|
||||
dir="ltr"
|
||||
/>
|
||||
</label>
|
||||
<label className="block space-y-2 text-sm">
|
||||
<span className="font-bold text-brand-ink">شماره موبایل</span>
|
||||
<input
|
||||
name="cellNumber"
|
||||
className="field-control min-h-12 w-full"
|
||||
placeholder="+98912..."
|
||||
dir="ltr"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="block space-y-2 text-sm">
|
||||
<span className="font-bold text-brand-ink">پیام</span>
|
||||
<textarea
|
||||
name="text"
|
||||
required
|
||||
rows={6}
|
||||
className="field-control w-full resize-y"
|
||||
placeholder="متن پیام خود را بنویسید..."
|
||||
/>
|
||||
</label>
|
||||
|
||||
{success ? (
|
||||
<p className="rounded-xl border border-brand/30 bg-brand/5 px-4 py-3 text-sm font-bold text-brand">
|
||||
{success}
|
||||
</p>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm font-bold text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
className="btn-primary min-h-12 px-8 disabled:opacity-60"
|
||||
>
|
||||
{pending ? "در حال ارسال..." : "ارسال پیام"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -3,16 +3,24 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { navItems, site } from "@/data/home";
|
||||
|
||||
export function Header() {
|
||||
type HeaderProps = {
|
||||
forceSolid?: boolean;
|
||||
};
|
||||
|
||||
export function Header({ forceSolid = false }: HeaderProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [scrolled, setScrolled] = useState(forceSolid);
|
||||
|
||||
useEffect(() => {
|
||||
if (forceSolid) {
|
||||
setScrolled(true);
|
||||
return;
|
||||
}
|
||||
const onScroll = () => setScrolled(window.scrollY > 24);
|
||||
onScroll();
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
}, [forceSolid]);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = open ? "hidden" : "";
|
||||
@@ -24,7 +32,7 @@ export function Header() {
|
||||
return (
|
||||
<header
|
||||
className={`fixed inset-x-0 top-0 z-50 transition-colors duration-300 ${
|
||||
scrolled || open
|
||||
scrolled || open || forceSolid
|
||||
? "border-b border-white/10 bg-brand-ink/95 backdrop-blur-md"
|
||||
: "bg-transparent"
|
||||
}`}
|
||||
@@ -63,12 +71,12 @@ export function Header() {
|
||||
{site.phoneDisplay}
|
||||
</a>
|
||||
<a
|
||||
href={site.loginUrl}
|
||||
href={site.customerLoginUrl}
|
||||
className="btn-primary !min-h-10 !px-4 !text-sm"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
ورود فروشندگان
|
||||
ورود
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
+14
-7
@@ -2,16 +2,18 @@
|
||||
|
||||
import Image from "next/image";
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { searchSuggestions, site } from "@/data/home";
|
||||
|
||||
export function Hero() {
|
||||
const router = useRouter();
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const onSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const q = query.trim();
|
||||
if (!q) return;
|
||||
window.location.hash = `products`;
|
||||
router.push(`/user-products?q=${encodeURIComponent(q)}`);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -49,7 +51,7 @@ export function Hero() {
|
||||
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="animate-rise animate-rise-delay-3 search-shell mt-8 max-w-3xl border border-white/15 bg-white/95 p-2 shadow-[0_20px_60px_rgba(0,0,0,0.28)] backdrop-blur-sm"
|
||||
className="animate-rise animate-rise-delay-3 search-shell mt-8 max-w-3xl rounded-2xl p-2"
|
||||
>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-stretch">
|
||||
<label className="sr-only" htmlFor="machine-search">
|
||||
@@ -61,22 +63,27 @@ export function Hero() {
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="نام کالای مورد نظر خود را جستجو کنید"
|
||||
className="min-h-14 flex-1 bg-transparent px-4 text-base text-brand-ink outline-none placeholder:text-[#8a97a6]"
|
||||
className="min-h-14 flex-1 rounded-xl bg-transparent px-4 text-base text-brand-ink outline-none placeholder:text-[#8a97a6]"
|
||||
required
|
||||
/>
|
||||
<button type="submit" className="btn-primary min-h-14 px-8">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary min-h-14 rounded-xl px-8"
|
||||
>
|
||||
جستجو
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{searchSuggestions.map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
onClick={() => setQuery(item)}
|
||||
className="border border-white/20 bg-white/5 px-3 py-1.5 text-xs text-white/80 transition hover:border-brand hover:bg-brand/20 hover:text-white"
|
||||
onClick={() => {
|
||||
setQuery(item);
|
||||
router.push(`/user-products?q=${encodeURIComponent(item)}`);
|
||||
}}
|
||||
className="border border-white/20 bg-white/5 px-3 py-1.5 text-xs text-white/80 transition hover:border-brand hover:bg-brand/20 hover:text-white rounded-lg"
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import Image from "next/image";
|
||||
|
||||
type PageBannerProps = {
|
||||
kicker: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export function PageBanner({ kicker, title, description }: PageBannerProps) {
|
||||
return (
|
||||
<section className="relative overflow-hidden bg-brand-ink text-white">
|
||||
<Image
|
||||
src="/images/hero/search-bg.jpg"
|
||||
alt=""
|
||||
fill
|
||||
priority
|
||||
className="object-cover object-[center_40%]"
|
||||
sizes="100vw"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-brand-ink/80" />
|
||||
<div className="absolute inset-0 bg-brand/35" />
|
||||
|
||||
<div className="relative z-10 container-page py-9 md:py-10">
|
||||
<p className="font-industrial mb-2 text-[0.72rem] tracking-[0.18em] text-white/80 uppercase">
|
||||
{kicker}
|
||||
</p>
|
||||
<h1 className="max-w-3xl text-3xl font-extrabold leading-tight text-white md:text-[2.35rem]">
|
||||
{title}
|
||||
</h1>
|
||||
{description ? (
|
||||
<p className="mt-2 max-w-2xl text-sm leading-7 text-white/70 md:text-[0.95rem]">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+128
-55
@@ -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">
|
||||
<div className="section-head">
|
||||
<p className="section-kicker">Stock Machines</p>
|
||||
<h2 className="section-title">محصولات موجود</h2>
|
||||
<p className="section-desc">
|
||||
منتخب ماشینآلات سنگین و صنعتی از فروشگاه ماشینیفای؛ برای جزئیات و
|
||||
قیمت، استعلام بگیرید.
|
||||
</p>
|
||||
</div>
|
||||
<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">
|
||||
{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">
|
||||
{products.map((product) => (
|
||||
<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]">
|
||||
<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"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<Link
|
||||
href={href}
|
||||
className="relative block aspect-[4/3] overflow-hidden bg-[#e8eef4]"
|
||||
>
|
||||
{product.imageUrl ? (
|
||||
<Image
|
||||
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">
|
||||
<a href="#contact" className="btn-primary">
|
||||
محصولات بیشتر
|
||||
</a>
|
||||
</div>
|
||||
<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">
|
||||
<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>
|
||||
</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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user