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:
Alireza Hassani
2026-09-29 08:46:20 +03:30
co-authored by Cursor
parent 69e6261949
commit 3ffa43641d
16 changed files with 541 additions and 61 deletions
+32
View File
@@ -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 });
}
+6
View File
@@ -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
View File
@@ -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"
+19 -3
View File
@@ -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">
+21 -3
View File
@@ -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,
+17 -5
View File
@@ -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>
);
}
+11 -13
View File
@@ -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>
)}
+37 -5
View File
@@ -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}
+161 -7
View File
@@ -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>
);
}
+8 -8
View File
@@ -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>
)}
+50 -8
View File
@@ -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>
);
+4 -4
View File
@@ -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"
+2 -1
View File
@@ -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>
+65
View File
@@ -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
View File
@@ -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);
}