Improve mobile UX, fix category tile links, and wire contact form to Meshkee.
Add a mobile hamburger menu, responsive hero/category grids, category-tree breadcrumbs, resolved dashboard linkUrls, and POST contact-submissions via /api/contact. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
69e6261949
commit
3ffa43641d
@@ -0,0 +1,32 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { submitContactSubmission } from "@/lib/meshkee";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let body: {
|
||||
title?: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
cellNumber?: string;
|
||||
text?: string;
|
||||
};
|
||||
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ message: "درخواست نامعتبر است." }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await submitContactSubmission({
|
||||
title: body.title ?? "",
|
||||
name: body.name ?? "",
|
||||
text: body.text ?? "",
|
||||
email: body.email,
|
||||
cellNumber: body.cellNumber,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
return NextResponse.json({ message: result.message }, { status: result.status });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: "پیام شما ثبت شد." }, { status: 201 });
|
||||
}
|
||||
@@ -31,10 +31,16 @@ body {
|
||||
.rich-content {
|
||||
color: var(--foreground);
|
||||
line-height: 2;
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
.rich-content p {
|
||||
margin-bottom: 1rem;
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
.rich-content li {
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
.rich-content table {
|
||||
|
||||
+7
-2
@@ -7,7 +7,7 @@ import Stats from "@/components/Stats";
|
||||
import Products from "@/components/Products";
|
||||
import About from "@/components/About";
|
||||
import { getStaticImageSlot, getTopLevelCategories } from "@/lib/meshkee";
|
||||
import { categoryPath } from "@/lib/urls";
|
||||
import { categoryPath, resolveCategorySlotHref } from "@/lib/urls";
|
||||
import { pageMetadata } from "@/lib/seo";
|
||||
|
||||
export const metadata: Metadata = pageMetadata({
|
||||
@@ -30,11 +30,16 @@ export default async function Home() {
|
||||
href: categoryPath(cat),
|
||||
}));
|
||||
|
||||
const categoryIconImages = (categoryIconSlot?.images ?? []).map((item) => ({
|
||||
...item,
|
||||
linkUrl: resolveCategorySlotHref(item, topCategories),
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeroSlider
|
||||
categories={heroCategories}
|
||||
categoryIconImages={categoryIconSlot?.images ?? []}
|
||||
categoryIconImages={categoryIconImages}
|
||||
/>
|
||||
<CategoryProducts
|
||||
categoryNameEn="Livestock"
|
||||
|
||||
@@ -8,6 +8,8 @@ import ProductGallery from "@/components/ProductGallery";
|
||||
import CategoryShowcase from "@/components/CategoryShowcase";
|
||||
import HomeContactStrip from "@/components/HomeContactStrip";
|
||||
import {
|
||||
getCategories,
|
||||
getCategoryAncestors,
|
||||
getProductById,
|
||||
getProducts,
|
||||
productCatalog,
|
||||
@@ -16,7 +18,7 @@ import {
|
||||
stripHtml,
|
||||
} from "@/lib/meshkee";
|
||||
import { pageMetadata } from "@/lib/seo";
|
||||
import { productPath, productSlug } from "@/lib/urls";
|
||||
import { categoryPath, productPath, productSlug } from "@/lib/urls";
|
||||
|
||||
export const revalidate = 300;
|
||||
export const dynamicParams = true;
|
||||
@@ -56,6 +58,11 @@ export default async function ProductDetailPage({
|
||||
const catalog = productCatalog(product);
|
||||
const shortDesc = product.summary || stripHtml(product.descriptionHtml, 160);
|
||||
|
||||
const allCategories = await getCategories();
|
||||
const categoryTrail = product.categoryId
|
||||
? getCategoryAncestors(product.categoryId, allCategories)
|
||||
: [];
|
||||
|
||||
const related = product.categoryId
|
||||
? (await getProducts({ categoryId: product.categoryId, pageSize: 5 })).items
|
||||
.filter((p) => p.id !== product.id)
|
||||
@@ -64,7 +71,16 @@ export default async function ProductDetailPage({
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageBanner title={product.nameFa} breadcrumb={product.nameFa} />
|
||||
<PageBanner
|
||||
title={product.nameFa}
|
||||
crumbs={[
|
||||
{ label: "محصولات", href: "/products" },
|
||||
...categoryTrail.map((cat) => ({
|
||||
label: cat.nameFa,
|
||||
href: categoryPath(cat),
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
|
||||
<section className="py-16 sm:py-20">
|
||||
<Container>
|
||||
@@ -90,7 +106,7 @@ export default async function ProductDetailPage({
|
||||
|
||||
<div>
|
||||
{shortDesc && (
|
||||
<p className="text-foreground leading-8">{shortDesc}</p>
|
||||
<p className="text-foreground leading-8 text-justify">{shortDesc}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex items-center gap-3">
|
||||
|
||||
@@ -7,6 +7,7 @@ import ProductCard from "@/components/ProductCard";
|
||||
import Pagination from "@/components/Pagination";
|
||||
import {
|
||||
getCategories,
|
||||
getCategoryAncestors,
|
||||
getCategoryById,
|
||||
getCategoryDescendantIds,
|
||||
getProducts,
|
||||
@@ -82,13 +83,20 @@ export default async function CategoryProductsPage({
|
||||
(a, b) =>
|
||||
a.sortOrder - b.sortOrder || a.nameFa.localeCompare(b.nameFa, "fa")
|
||||
);
|
||||
const categoryTrail = getCategoryAncestors(category.id, allCategories);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageBanner
|
||||
title={category.nameFa}
|
||||
subtitle={`محصولات دسته ${category.nameFa}`}
|
||||
breadcrumb={category.nameFa}
|
||||
crumbs={[
|
||||
{ label: "محصولات", href: "/products" },
|
||||
...categoryTrail.map((cat, i) => ({
|
||||
label: cat.nameFa,
|
||||
href: i < categoryTrail.length - 1 ? categoryPath(cat) : undefined,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
|
||||
<section className="pt-6 sm:pt-8 pb-16 sm:pb-20">
|
||||
|
||||
@@ -5,9 +5,10 @@ import ProductCard from "@/components/ProductCard";
|
||||
import Pagination from "@/components/Pagination";
|
||||
import CategoryShowcase from "@/components/CategoryShowcase";
|
||||
import HomeContactStrip from "@/components/HomeContactStrip";
|
||||
import { getCategoryById, getProducts } from "@/lib/meshkee";
|
||||
import { getCategories, getCategoryById, getProducts } from "@/lib/meshkee";
|
||||
import { pageMetadata } from "@/lib/seo";
|
||||
import { categoryPath } from "@/lib/urls";
|
||||
import { slugifyFa } from "@/lib/slugify";
|
||||
|
||||
export const metadata = pageMetadata({
|
||||
title: "محصولات | آراد آریسمان",
|
||||
@@ -21,9 +22,9 @@ const PAGE_SIZE = 6;
|
||||
export default async function ProductsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ page?: string; categoryId?: string }>;
|
||||
searchParams: Promise<{ page?: string; categoryId?: string; category?: string }>;
|
||||
}) {
|
||||
const { page, categoryId } = await searchParams;
|
||||
const { page, categoryId, category: categorySlug } = await searchParams;
|
||||
|
||||
if (categoryId) {
|
||||
const category = await getCategoryById(categoryId);
|
||||
@@ -32,6 +33,23 @@ export default async function ProductsPage({
|
||||
}
|
||||
}
|
||||
|
||||
if (categorySlug) {
|
||||
const all = await getCategories();
|
||||
const needle = categorySlug.trim().toLowerCase();
|
||||
const matched = all.find((c) => {
|
||||
const enSlug = c.name.toLowerCase().replace(/\s+/g, "-");
|
||||
return (
|
||||
c.slug?.toLowerCase() === needle ||
|
||||
enSlug === needle ||
|
||||
c.name.toLowerCase() === needle ||
|
||||
slugifyFa(c.nameFa) === needle
|
||||
);
|
||||
});
|
||||
if (matched) {
|
||||
permanentRedirect(categoryPath(matched));
|
||||
}
|
||||
}
|
||||
|
||||
const requestedPage = Math.max(1, Number(page) || 1);
|
||||
const { items, total } = await getProducts({
|
||||
page: requestedPage,
|
||||
|
||||
@@ -6,10 +6,22 @@ import { useSession, userDisplayName } from "@/lib/session";
|
||||
|
||||
function UserIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path
|
||||
d="M12 12a5 5 0 1 0 0-10 5 5 0 0 0 0 10Zm0 2c-4.4 0-8 2.2-8 5v3h16v-3c0-2.8-3.6-5-8-5Z"
|
||||
fill="currentColor"
|
||||
d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="7"
|
||||
r="4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
@@ -103,12 +115,12 @@ export default function AccountMenu({ scrolled }: { scrolled: boolean }) {
|
||||
return (
|
||||
<a
|
||||
href={customerPortalUrl}
|
||||
className={`account-menu account-menu--login flex items-center gap-2 rounded-full border px-5 py-2.5 text-[15px] font-medium transition-colors ${triggerClass}`}
|
||||
className={`account-menu account-menu--login flex h-10 w-10 items-center justify-center rounded-full border transition-colors lg:h-auto lg:w-auto lg:gap-2 lg:px-5 lg:py-2.5 lg:text-[15px] lg:font-medium ${triggerClass}`}
|
||||
aria-label="ورود"
|
||||
title="ورود"
|
||||
>
|
||||
<UserIcon />
|
||||
<span>ورود</span>
|
||||
<span className="hidden lg:inline">ورود</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import Container from "./Container";
|
||||
import ScrollReveal from "./ScrollReveal";
|
||||
import { aspectRatioToCss, getCategorySummary, getStaticImageSlot } from "@/lib/meshkee";
|
||||
import { categoryPath } from "@/lib/urls";
|
||||
import { aspectRatioToCss, getCategories, getCategorySummary, getStaticImageSlot } from "@/lib/meshkee";
|
||||
import { categoryPath, resolveCategorySlotHref } from "@/lib/urls";
|
||||
import { categoryFallbackImage } from "@/lib/categoryIcons";
|
||||
import { toFa } from "@/lib/format";
|
||||
|
||||
@@ -17,9 +17,10 @@ type Tile = {
|
||||
};
|
||||
|
||||
export default async function CategoryShowcase() {
|
||||
const [categories, slot] = await Promise.all([
|
||||
const [categories, slot, allCategories] = await Promise.all([
|
||||
getCategorySummary(6),
|
||||
getStaticImageSlot("categories"),
|
||||
getCategories(),
|
||||
]);
|
||||
|
||||
const slotImages = slot?.images ?? [];
|
||||
@@ -29,7 +30,7 @@ export default async function CategoryShowcase() {
|
||||
slotImages.length > 0
|
||||
? slotImages.map((item) => ({
|
||||
key: item.id,
|
||||
href: item.linkUrl || "/products",
|
||||
href: resolveCategorySlotHref(item, allCategories),
|
||||
image: item.url,
|
||||
title: item.titleFa || "",
|
||||
titleEn: item.titleEn || item.title || undefined,
|
||||
@@ -56,15 +57,12 @@ export default async function CategoryShowcase() {
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mt-8 flex snap-x snap-mandatory gap-2 overflow-x-auto pb-2 sm:pb-0 sm:overflow-visible sm:grid sm:gap-2.5 [scrollbar-width:thin]"
|
||||
style={{ gridTemplateColumns: `repeat(${tiles.length}, minmax(0, 1fr))` }}
|
||||
>
|
||||
<div className="mt-8 grid grid-cols-2 gap-2.5 sm:gap-3 lg:grid-cols-5">
|
||||
{tiles.map((tile, i) => (
|
||||
<ScrollReveal
|
||||
key={tile.key}
|
||||
delay={i * 100}
|
||||
className="w-[45%] shrink-0 snap-start sm:w-auto sm:shrink"
|
||||
className="min-w-0"
|
||||
>
|
||||
<Link
|
||||
href={tile.href}
|
||||
@@ -76,17 +74,17 @@ export default async function CategoryShowcase() {
|
||||
alt={tile.title || "دستهبندی محصول"}
|
||||
fill
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-110"
|
||||
sizes="(min-width: 640px) 20vw, 45vw"
|
||||
sizes="(min-width: 1024px) 20vw, 50vw"
|
||||
/>
|
||||
<div className="absolute inset-x-0 bottom-0 h-2/3 bg-gradient-to-t from-[#2b1810] via-[#2b1810]/55 to-transparent" />
|
||||
<div className="absolute inset-x-0 bottom-0 p-4">
|
||||
<div className="absolute inset-x-0 bottom-0 p-3 sm:p-4">
|
||||
{tile.title && (
|
||||
<span className="block font-bold text-white leading-6">
|
||||
<span className="block text-sm font-bold text-white leading-5 sm:text-base sm:leading-6">
|
||||
{tile.title}
|
||||
</span>
|
||||
)}
|
||||
{tile.titleEn && (
|
||||
<span className="mt-1 block text-[11px] font-light tracking-[0.18em] text-white/60">
|
||||
<span className="mt-1 block text-[10px] font-light tracking-[0.18em] text-white/60 sm:text-[11px]">
|
||||
{tile.titleEn}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -5,16 +5,42 @@ import { FormEvent, useState } from "react";
|
||||
export default function ContactForm() {
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
const formEl = e.currentTarget;
|
||||
const data = new FormData(formEl);
|
||||
const name = String(data.get("name") || "").trim();
|
||||
const phone = String(data.get("phone") || "").trim();
|
||||
const subject = String(data.get("subject") || "").trim() || "پیام از وبسایت";
|
||||
const message = String(data.get("message") || "").trim();
|
||||
if (!name || !message) return;
|
||||
|
||||
setSending(true);
|
||||
window.setTimeout(() => {
|
||||
setSending(false);
|
||||
setSubmitted(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/contact", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
title: subject,
|
||||
text: message,
|
||||
cellNumber: phone || undefined,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const payload = (await res.json().catch(() => ({}))) as { message?: string };
|
||||
throw new Error(payload.message || "ارسال ناموفق بود");
|
||||
}
|
||||
formEl.reset();
|
||||
}, 600);
|
||||
setSubmitted(true);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "ارسال پیام ناموفق بود. لطفاً دوباره تلاش کنید.");
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (submitted) {
|
||||
@@ -108,6 +134,12 @@ export default function ContactForm() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-600" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={sending}
|
||||
|
||||
@@ -28,6 +28,8 @@ export default function HeaderNav({
|
||||
const pathname = usePathname();
|
||||
const [scrolledPast, setScrolledPast] = useState(false);
|
||||
const [productsOpen, setProductsOpen] = useState(false);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [mobileProductsOpen, setMobileProductsOpen] = useState(false);
|
||||
const productsRef = useRef<HTMLDivElement>(null);
|
||||
const canBeTransparent = pathname === "/";
|
||||
const scrolled = !canBeTransparent || scrolledPast;
|
||||
@@ -52,13 +54,35 @@ export default function HeaderNav({
|
||||
return () => document.removeEventListener("mousedown", onPointerDown);
|
||||
}, []);
|
||||
|
||||
const linkClass = scrolled
|
||||
useEffect(() => {
|
||||
setMobileOpen(false);
|
||||
setMobileProductsOpen(false);
|
||||
setProductsOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mobileOpen) return;
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setMobileOpen(false);
|
||||
}
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
window.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [mobileOpen]);
|
||||
|
||||
const linkClass = scrolled || mobileOpen
|
||||
? "text-secondary/80 hover:text-primary"
|
||||
: "text-white/90 hover:text-accent";
|
||||
|
||||
const iconClass = scrolled || mobileOpen ? "text-secondary" : "text-white";
|
||||
|
||||
return (
|
||||
<header className="fixed inset-x-0 top-0 z-50">
|
||||
<div className="bg-secondary text-white">
|
||||
<div className="relative z-[60] bg-secondary text-white">
|
||||
<Container className="flex h-11 items-center justify-between text-sm">
|
||||
<a
|
||||
href="tel:09104067935"
|
||||
@@ -102,16 +126,16 @@ export default function HeaderNav({
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`border-b transition-all duration-300 ${
|
||||
scrolled
|
||||
className={`relative z-[60] border-b transition-all duration-300 ${
|
||||
scrolled || mobileOpen
|
||||
? "bg-white/95 backdrop-blur border-black/5 shadow-sm"
|
||||
: "bg-white/10 backdrop-blur-md border-white/10"
|
||||
}`}
|
||||
>
|
||||
<Container className="flex h-20 items-center justify-between">
|
||||
<Link href="/" className="shrink-0">
|
||||
<Link href="/" className="shrink-0" onClick={() => setMobileOpen(false)}>
|
||||
<Image
|
||||
src={scrolled ? "/images/logo-wordmark.png" : "/images/footer-logo.png"}
|
||||
src={scrolled || mobileOpen ? "/images/logo-wordmark.png" : "/images/footer-logo.png"}
|
||||
alt="آراد آریسمان"
|
||||
width={140}
|
||||
height={40}
|
||||
@@ -208,9 +232,139 @@ export default function HeaderNav({
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<AccountMenu scrolled={scrolled} />
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<AccountMenu scrolled={scrolled || mobileOpen} />
|
||||
<button
|
||||
type="button"
|
||||
className={`lg:hidden relative z-[70] inline-flex h-10 w-10 items-center justify-center rounded-full border transition-colors ${
|
||||
scrolled || mobileOpen
|
||||
? "border-black/10 text-secondary hover:border-primary hover:text-primary"
|
||||
: "border-white/25 text-white hover:bg-white/10"
|
||||
}`}
|
||||
aria-label={mobileOpen ? "بستن منو" : "باز کردن منو"}
|
||||
aria-expanded={mobileOpen}
|
||||
aria-controls="mobile-nav"
|
||||
onClick={() => setMobileOpen((open) => !open)}
|
||||
>
|
||||
{mobileOpen ? (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden className={iconClass}>
|
||||
<path
|
||||
d="M6.7 6.7a1 1 0 0 1 1.4 0L12 10.6l3.9-3.9a1 1 0 1 1 1.4 1.4L13.4 12l3.9 3.9a1 1 0 0 1-1.4 1.4L12 13.4l-3.9 3.9a1 1 0 0 1-1.4-1.4L10.6 12 6.7 8.1a1 1 0 0 1 0-1.4Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden className={iconClass}>
|
||||
<path
|
||||
d="M4 7h16M4 12h16M4 17h16"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
|
||||
{mobileOpen && (
|
||||
<div className="lg:hidden">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="بستن منو"
|
||||
className="fixed inset-0 z-[40] bg-black/40"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
/>
|
||||
<nav
|
||||
id="mobile-nav"
|
||||
className="relative z-[50] max-h-[min(70vh,calc(100svh-8rem))] overflow-y-auto border-b border-black/5 bg-white shadow-lg"
|
||||
>
|
||||
<Container className="flex flex-col gap-1 py-3">
|
||||
{navLinks.slice(0, 1).map((link) => (
|
||||
<Link
|
||||
key={link.label}
|
||||
href={link.href}
|
||||
className="rounded-xl px-4 py-3 text-[15px] font-medium text-secondary hover:bg-primary/5 hover:text-primary transition-colors"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
<div className="rounded-xl">
|
||||
<div className="flex items-center">
|
||||
<Link
|
||||
href="/products"
|
||||
className="flex-1 rounded-xl px-4 py-3 text-[15px] font-medium text-secondary hover:bg-primary/5 hover:text-primary transition-colors"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
محصولات
|
||||
</Link>
|
||||
{productCategories.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="نمایش دستهبندی محصولات"
|
||||
aria-expanded={mobileProductsOpen}
|
||||
className="me-2 inline-flex h-10 w-10 items-center justify-center rounded-full text-secondary hover:bg-primary/5 hover:text-primary transition-colors"
|
||||
onClick={() => setMobileProductsOpen((open) => !open)}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
className={`transition-transform ${mobileProductsOpen ? "rotate-180" : ""}`}
|
||||
aria-hidden
|
||||
>
|
||||
<path
|
||||
d="M6 9l6 6 6-6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{mobileProductsOpen && productCategories.length > 0 && (
|
||||
<div className="mb-1 ms-3 border-s border-primary/15 ps-3">
|
||||
{productCategories.map((cat) => (
|
||||
<Link
|
||||
key={cat.id}
|
||||
href={cat.href}
|
||||
className="block rounded-xl px-3 py-2.5 text-[14px] text-secondary/85 hover:bg-primary/5 hover:text-primary transition-colors"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
{cat.nameFa}
|
||||
</Link>
|
||||
))}
|
||||
<Link
|
||||
href="/products"
|
||||
className="block rounded-xl px-3 py-2.5 text-[14px] font-medium text-primary hover:bg-primary/5 transition-colors"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
همه محصولات
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{navLinks.slice(1).map((link) => (
|
||||
<Link
|
||||
key={link.label}
|
||||
href={link.href}
|
||||
className="rounded-xl px-4 py-3 text-[15px] font-medium text-secondary hover:bg-primary/5 hover:text-primary transition-colors"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</Container>
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -217,23 +217,23 @@ export default function HeroSlider({
|
||||
})}
|
||||
|
||||
{showCategories && (
|
||||
<div className="absolute inset-0 z-30 flex items-center justify-center px-4 pointer-events-none">
|
||||
<div className="pointer-events-auto flex w-max max-w-full flex-col items-center gap-6 sm:gap-8">
|
||||
<div className="absolute inset-0 z-30 flex items-center justify-center px-4 pb-24 pointer-events-none sm:pb-28">
|
||||
<div className="pointer-events-auto flex w-full max-w-lg flex-col items-center gap-5 sm:max-w-4xl sm:gap-8 lg:max-w-6xl">
|
||||
<h1 className="max-w-3xl px-2 text-center text-[clamp(1.15rem,3.2vw,2.15rem)] font-extrabold leading-relaxed text-white">
|
||||
{CATEGORIES_TITLE}
|
||||
</h1>
|
||||
<div className="flex w-max max-w-full gap-3 overflow-x-auto sm:gap-5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<div className="grid w-full grid-cols-2 gap-2.5 sm:gap-4 lg:grid-cols-5 lg:gap-5">
|
||||
{categoryBoxes.map((box) => (
|
||||
<Link
|
||||
key={box.key}
|
||||
href={box.href}
|
||||
className="group relative flex w-[9.5rem] shrink-0 flex-col items-center gap-3 overflow-hidden rounded-2xl border border-white/55 bg-white/15 px-3.5 py-6 text-center shadow-[inset_0_1px_0_rgba(255,255,255,0.25)] backdrop-blur-md transition-[border-color] duration-300 hover:border-primary sm:w-[12.5rem] sm:gap-3.5 sm:px-5 sm:py-8 md:w-[13.5rem]"
|
||||
className="group relative flex w-full flex-col items-center gap-2.5 overflow-hidden rounded-2xl border border-white/55 bg-white/15 px-3 py-5 text-center shadow-[inset_0_1px_0_rgba(255,255,255,0.25)] backdrop-blur-md transition-[border-color] duration-300 hover:border-primary sm:gap-3.5 sm:px-5 sm:py-8"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 origin-bottom scale-y-0 bg-primary transition-transform duration-500 ease-out group-hover:scale-y-100"
|
||||
/>
|
||||
<span className="relative z-10 flex h-16 w-16 items-center justify-center overflow-hidden rounded-xl text-white sm:h-[4.75rem] sm:w-[4.75rem]">
|
||||
<span className="relative z-10 flex h-14 w-14 items-center justify-center overflow-hidden rounded-xl text-white sm:h-[4.75rem] sm:w-[4.75rem]">
|
||||
{box.iconUrl ? (
|
||||
<Image
|
||||
src={box.iconUrl}
|
||||
@@ -246,14 +246,14 @@ export default function HeroSlider({
|
||||
categoryIcon(box.nameFa, ICON_SIZE)
|
||||
)}
|
||||
</span>
|
||||
<span className="relative z-10 flex flex-col gap-1.5">
|
||||
<span className="relative z-10 flex flex-col gap-1 sm:gap-1.5">
|
||||
{box.nameFa && (
|
||||
<span className="text-base font-extrabold leading-5 text-white sm:text-lg">
|
||||
<span className="text-sm font-extrabold leading-5 text-white sm:text-lg">
|
||||
{box.nameFa}
|
||||
</span>
|
||||
)}
|
||||
{box.nameEn && (
|
||||
<span className="font-display text-xs font-medium tracking-wide text-white/80 sm:text-sm">
|
||||
<span className="font-display text-[0.7rem] font-medium tracking-wide text-white/80 sm:text-sm">
|
||||
{box.nameEn}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -1,15 +1,30 @@
|
||||
import Link from "next/link";
|
||||
import Container from "./Container";
|
||||
|
||||
export type BreadcrumbItem = {
|
||||
label: string;
|
||||
href?: string;
|
||||
};
|
||||
|
||||
export default function PageBanner({
|
||||
title,
|
||||
subtitle,
|
||||
breadcrumb,
|
||||
crumbs,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
breadcrumb: string;
|
||||
/** Single final crumb label (simple pages). Prefer `crumbs` for a tree. */
|
||||
breadcrumb?: string;
|
||||
crumbs?: BreadcrumbItem[];
|
||||
}) {
|
||||
const items: BreadcrumbItem[] =
|
||||
crumbs && crumbs.length > 0
|
||||
? crumbs
|
||||
: breadcrumb
|
||||
? [{ label: breadcrumb }]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<section className="relative overflow-hidden bg-surface py-8 sm:py-10">
|
||||
<div
|
||||
@@ -30,17 +45,44 @@ export default function PageBanner({
|
||||
/>
|
||||
|
||||
<Container className="relative">
|
||||
<h1 className="text-2xl sm:text-3xl font-extrabold text-primary">{title}</h1>
|
||||
<h1 className="text-lg sm:text-2xl lg:text-3xl font-extrabold leading-8 text-primary sm:leading-snug">
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle && (
|
||||
<p className="mt-2 max-w-2xl text-sm sm:text-base text-foreground">{subtitle}</p>
|
||||
<p className="mt-2 max-w-2xl text-sm sm:text-base text-foreground text-justify">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-3 flex items-center gap-2 text-sm text-foreground">
|
||||
<Link href="/" className="hover:text-primary transition-colors">
|
||||
<nav
|
||||
aria-label="مسیر صفحه"
|
||||
className="mt-3 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs sm:text-sm text-foreground"
|
||||
>
|
||||
<Link href="/" className="shrink-0 hover:text-primary transition-colors">
|
||||
خانه
|
||||
</Link>
|
||||
<span className="text-foreground/50">/</span>
|
||||
<span className="font-medium text-primary">{breadcrumb}</span>
|
||||
</div>
|
||||
{items.map((item, i) => {
|
||||
const isLast = i === items.length - 1;
|
||||
return (
|
||||
<span key={`${item.label}-${i}`} className="inline-flex min-w-0 items-center gap-2">
|
||||
<span className="shrink-0 text-foreground/50">/</span>
|
||||
{item.href && !isLast ? (
|
||||
<Link
|
||||
href={item.href}
|
||||
className="min-w-0 truncate hover:text-primary transition-colors"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span
|
||||
className={`min-w-0 ${isLast ? "font-medium text-primary line-clamp-2" : "truncate"}`}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -29,13 +29,13 @@ export default function Pagination({
|
||||
return (
|
||||
<nav
|
||||
aria-label="صفحهبندی"
|
||||
className="mt-12 flex flex-wrap items-center justify-center gap-2"
|
||||
className="mt-12 flex flex-nowrap items-center justify-center gap-1.5 overflow-x-auto sm:gap-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
<Link
|
||||
href={hrefFor(Math.max(1, currentPage - 1))}
|
||||
aria-disabled={prevDisabled}
|
||||
tabIndex={prevDisabled ? -1 : undefined}
|
||||
className={`flex h-10 items-center justify-center rounded-full border px-4 text-sm font-medium transition-colors ${
|
||||
className={`flex h-9 shrink-0 items-center justify-center rounded-full border px-3 text-sm font-medium transition-colors sm:h-10 sm:px-4 ${
|
||||
prevDisabled
|
||||
? "pointer-events-none border-black/5 text-foreground/30"
|
||||
: "border-black/10 text-secondary hover:border-primary hover:text-primary"
|
||||
@@ -49,7 +49,7 @@ export default function Pagination({
|
||||
key={p}
|
||||
href={hrefFor(p)}
|
||||
aria-current={p === currentPage ? "page" : undefined}
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-full text-sm font-semibold transition-colors ${
|
||||
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-sm font-semibold transition-colors sm:h-10 sm:w-10 ${
|
||||
p === currentPage
|
||||
? "bg-primary text-white"
|
||||
: "text-secondary hover:bg-primary/10"
|
||||
@@ -63,7 +63,7 @@ export default function Pagination({
|
||||
href={hrefFor(Math.min(totalPages, currentPage + 1))}
|
||||
aria-disabled={nextDisabled}
|
||||
tabIndex={nextDisabled ? -1 : undefined}
|
||||
className={`flex h-10 items-center justify-center rounded-full border px-4 text-sm font-medium transition-colors ${
|
||||
className={`flex h-9 shrink-0 items-center justify-center rounded-full border px-3 text-sm font-medium transition-colors sm:h-10 sm:px-4 ${
|
||||
nextDisabled
|
||||
? "pointer-events-none border-black/5 text-foreground/30"
|
||||
: "border-black/10 text-secondary hover:border-primary hover:text-primary"
|
||||
|
||||
@@ -63,9 +63,10 @@ export default function ProductsCarousel({
|
||||
</div>
|
||||
<Link
|
||||
href={moreHref}
|
||||
aria-label="مشاهده بیشتر"
|
||||
className="flex items-center gap-2 text-primary font-semibold hover:text-primary-dark transition-colors"
|
||||
>
|
||||
مشاهده بیشتر
|
||||
<span className="hidden sm:inline">مشاهده بیشتر</span>
|
||||
<ChevronIcon />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -299,6 +299,23 @@ export async function getTopLevelCategories(): Promise<Category[]> {
|
||||
);
|
||||
}
|
||||
|
||||
/** Root → … → leaf ancestors for a category id (inclusive). */
|
||||
export function getCategoryAncestors(
|
||||
categoryId: string,
|
||||
categories: Category[]
|
||||
): Category[] {
|
||||
const byId = new Map(categories.map((c) => [c.id, c]));
|
||||
const chain: Category[] = [];
|
||||
let current = byId.get(categoryId);
|
||||
const seen = new Set<string>();
|
||||
while (current && !seen.has(current.id)) {
|
||||
seen.add(current.id);
|
||||
chain.unshift(current);
|
||||
current = current.parentId ? byId.get(current.parentId) : undefined;
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
|
||||
/** Includes the category itself and all nested descendants. */
|
||||
export function getCategoryDescendantIds(
|
||||
categoryId: string,
|
||||
@@ -513,3 +530,51 @@ export function postReadingTime(post: Post): string {
|
||||
const minutes = Math.max(1, Math.round(words / 150));
|
||||
return `${toFa(minutes)} دقیقه مطالعه`;
|
||||
}
|
||||
|
||||
export type ContactSubmissionInput = {
|
||||
title: string;
|
||||
name: string;
|
||||
text: string;
|
||||
email?: string;
|
||||
cellNumber?: string;
|
||||
};
|
||||
|
||||
/** Public contact form → Meshkee dashboard inbox. */
|
||||
export async function submitContactSubmission(
|
||||
input: ContactSubmissionInput
|
||||
): Promise<{ ok: true } | { ok: false; status: number; message: string }> {
|
||||
const title = input.title.trim();
|
||||
const name = input.name.trim();
|
||||
const text = input.text.trim();
|
||||
if (!title || !name || !text) {
|
||||
return { ok: false, status: 400, message: "نام، موضوع و متن پیام الزامی است." };
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`${API_BASE}/tenants/${TENANT_DOMAIN}/contact-submissions`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
name,
|
||||
text,
|
||||
email: input.email?.trim() || undefined,
|
||||
cellNumber: input.cellNumber?.trim() || undefined,
|
||||
}),
|
||||
cache: "no-store",
|
||||
}
|
||||
);
|
||||
|
||||
if (res.ok || res.status === 201) return { ok: true };
|
||||
|
||||
let message = "ارسال پیام ناموفق بود. لطفاً دوباره تلاش کنید.";
|
||||
try {
|
||||
const data = (await res.json()) as { message?: string | string[] };
|
||||
if (typeof data.message === "string") message = data.message;
|
||||
else if (Array.isArray(data.message) && data.message[0]) message = data.message[0];
|
||||
} catch {
|
||||
/* keep default */
|
||||
}
|
||||
return { ok: false, status: res.status, message };
|
||||
}
|
||||
|
||||
+92
-1
@@ -1,7 +1,98 @@
|
||||
import type { Category, Post, Product } from "@/lib/meshkee";
|
||||
import type { Category, Post, Product, StaticImageItem } from "@/lib/meshkee";
|
||||
import { postTitle } from "@/lib/meshkee";
|
||||
import { slugifyFa } from "@/lib/slugify";
|
||||
|
||||
const STOREFRONT_HOSTS = new Set([
|
||||
"aradarisman.com",
|
||||
"www.aradarisman.com",
|
||||
]);
|
||||
|
||||
/** Turn absolute Meshkee dashboard URLs into same-site paths when possible. */
|
||||
export function storefrontPath(url: string | null | undefined): string | null {
|
||||
if (!url?.trim()) return null;
|
||||
const raw = url.trim();
|
||||
if (raw.startsWith("/")) return raw;
|
||||
try {
|
||||
const parsed = new URL(raw);
|
||||
if (STOREFRONT_HOSTS.has(parsed.hostname)) {
|
||||
return `${parsed.pathname}${parsed.search}${parsed.hash}` || "/";
|
||||
}
|
||||
return raw;
|
||||
} catch {
|
||||
return raw.startsWith("/") ? raw : `/${raw}`;
|
||||
}
|
||||
}
|
||||
|
||||
function findCategoryBySlugOrName(
|
||||
categories: Category[],
|
||||
value: string
|
||||
): Category | undefined {
|
||||
const needle = value.trim().toLowerCase();
|
||||
if (!needle) return undefined;
|
||||
return categories.find((c) => {
|
||||
const enSlug = c.name.toLowerCase().replace(/\s+/g, "-");
|
||||
return (
|
||||
c.slug?.toLowerCase() === needle ||
|
||||
enSlug === needle ||
|
||||
c.name.toLowerCase() === needle ||
|
||||
c.nameFa === value.trim() ||
|
||||
slugifyFa(c.nameFa) === needle
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a hero/category slot link to a real category page.
|
||||
* Dashboard often stores `https://aradarisman.com/products?category=livestock`
|
||||
* which this storefront does not route — map those to `/products/category/...`.
|
||||
*/
|
||||
export function resolveCategorySlotHref(
|
||||
item: Pick<StaticImageItem, "linkUrl" | "titleFa" | "titleEn" | "title">,
|
||||
categories: Category[],
|
||||
fallbackHref = "/products"
|
||||
): string {
|
||||
const path = storefrontPath(item.linkUrl);
|
||||
if (path?.startsWith("/")) {
|
||||
try {
|
||||
const parsed = new URL(path, "https://aradarisman.com");
|
||||
if (parsed.pathname === "/products" || parsed.pathname === "/products/") {
|
||||
const byId = parsed.searchParams.get("categoryId");
|
||||
if (byId) {
|
||||
const cat = categories.find((c) => c.id === byId);
|
||||
if (cat) return categoryPath(cat);
|
||||
}
|
||||
const bySlug = parsed.searchParams.get("category");
|
||||
if (bySlug) {
|
||||
const cat = findCategoryBySlugOrName(categories, bySlug);
|
||||
if (cat) return categoryPath(cat);
|
||||
}
|
||||
if (![...parsed.searchParams.keys()].length) return "/products";
|
||||
} else if (parsed.pathname.startsWith("/products/category/")) {
|
||||
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||
} else {
|
||||
return `${parsed.pathname}${parsed.search}${parsed.hash}` || fallbackHref;
|
||||
}
|
||||
} catch {
|
||||
/* fall through to title match */
|
||||
}
|
||||
}
|
||||
|
||||
const titleEn = item.titleEn?.trim() || item.title?.trim() || "";
|
||||
const titleFa = item.titleFa?.trim() || "";
|
||||
const matched = categories.find(
|
||||
(c) =>
|
||||
(titleEn && c.name.toLowerCase() === titleEn.toLowerCase()) ||
|
||||
(titleFa &&
|
||||
(c.nameFa === titleFa ||
|
||||
titleFa.includes(c.nameFa) ||
|
||||
c.nameFa.includes(titleFa)))
|
||||
);
|
||||
if (matched) return categoryPath(matched);
|
||||
|
||||
if (path?.startsWith("/")) return path;
|
||||
return fallbackHref;
|
||||
}
|
||||
|
||||
export function productSlug(product: Pick<Product, "nameFa">): string {
|
||||
return slugifyFa(product.nameFa);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user