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 {
|
.rich-content {
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
line-height: 2;
|
line-height: 2;
|
||||||
|
text-align: justify;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rich-content p {
|
.rich-content p {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
|
text-align: justify;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-content li {
|
||||||
|
text-align: justify;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rich-content table {
|
.rich-content table {
|
||||||
|
|||||||
+7
-2
@@ -7,7 +7,7 @@ import Stats from "@/components/Stats";
|
|||||||
import Products from "@/components/Products";
|
import Products from "@/components/Products";
|
||||||
import About from "@/components/About";
|
import About from "@/components/About";
|
||||||
import { getStaticImageSlot, getTopLevelCategories } from "@/lib/meshkee";
|
import { getStaticImageSlot, getTopLevelCategories } from "@/lib/meshkee";
|
||||||
import { categoryPath } from "@/lib/urls";
|
import { categoryPath, resolveCategorySlotHref } from "@/lib/urls";
|
||||||
import { pageMetadata } from "@/lib/seo";
|
import { pageMetadata } from "@/lib/seo";
|
||||||
|
|
||||||
export const metadata: Metadata = pageMetadata({
|
export const metadata: Metadata = pageMetadata({
|
||||||
@@ -30,11 +30,16 @@ export default async function Home() {
|
|||||||
href: categoryPath(cat),
|
href: categoryPath(cat),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const categoryIconImages = (categoryIconSlot?.images ?? []).map((item) => ({
|
||||||
|
...item,
|
||||||
|
linkUrl: resolveCategorySlotHref(item, topCategories),
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<HeroSlider
|
<HeroSlider
|
||||||
categories={heroCategories}
|
categories={heroCategories}
|
||||||
categoryIconImages={categoryIconSlot?.images ?? []}
|
categoryIconImages={categoryIconImages}
|
||||||
/>
|
/>
|
||||||
<CategoryProducts
|
<CategoryProducts
|
||||||
categoryNameEn="Livestock"
|
categoryNameEn="Livestock"
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import ProductGallery from "@/components/ProductGallery";
|
|||||||
import CategoryShowcase from "@/components/CategoryShowcase";
|
import CategoryShowcase from "@/components/CategoryShowcase";
|
||||||
import HomeContactStrip from "@/components/HomeContactStrip";
|
import HomeContactStrip from "@/components/HomeContactStrip";
|
||||||
import {
|
import {
|
||||||
|
getCategories,
|
||||||
|
getCategoryAncestors,
|
||||||
getProductById,
|
getProductById,
|
||||||
getProducts,
|
getProducts,
|
||||||
productCatalog,
|
productCatalog,
|
||||||
@@ -16,7 +18,7 @@ import {
|
|||||||
stripHtml,
|
stripHtml,
|
||||||
} from "@/lib/meshkee";
|
} from "@/lib/meshkee";
|
||||||
import { pageMetadata } from "@/lib/seo";
|
import { pageMetadata } from "@/lib/seo";
|
||||||
import { productPath, productSlug } from "@/lib/urls";
|
import { categoryPath, productPath, productSlug } from "@/lib/urls";
|
||||||
|
|
||||||
export const revalidate = 300;
|
export const revalidate = 300;
|
||||||
export const dynamicParams = true;
|
export const dynamicParams = true;
|
||||||
@@ -56,6 +58,11 @@ export default async function ProductDetailPage({
|
|||||||
const catalog = productCatalog(product);
|
const catalog = productCatalog(product);
|
||||||
const shortDesc = product.summary || stripHtml(product.descriptionHtml, 160);
|
const shortDesc = product.summary || stripHtml(product.descriptionHtml, 160);
|
||||||
|
|
||||||
|
const allCategories = await getCategories();
|
||||||
|
const categoryTrail = product.categoryId
|
||||||
|
? getCategoryAncestors(product.categoryId, allCategories)
|
||||||
|
: [];
|
||||||
|
|
||||||
const related = product.categoryId
|
const related = product.categoryId
|
||||||
? (await getProducts({ categoryId: product.categoryId, pageSize: 5 })).items
|
? (await getProducts({ categoryId: product.categoryId, pageSize: 5 })).items
|
||||||
.filter((p) => p.id !== product.id)
|
.filter((p) => p.id !== product.id)
|
||||||
@@ -64,7 +71,16 @@ export default async function ProductDetailPage({
|
|||||||
|
|
||||||
return (
|
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">
|
<section className="py-16 sm:py-20">
|
||||||
<Container>
|
<Container>
|
||||||
@@ -90,7 +106,7 @@ export default async function ProductDetailPage({
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
{shortDesc && (
|
{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">
|
<div className="mt-6 flex items-center gap-3">
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import ProductCard from "@/components/ProductCard";
|
|||||||
import Pagination from "@/components/Pagination";
|
import Pagination from "@/components/Pagination";
|
||||||
import {
|
import {
|
||||||
getCategories,
|
getCategories,
|
||||||
|
getCategoryAncestors,
|
||||||
getCategoryById,
|
getCategoryById,
|
||||||
getCategoryDescendantIds,
|
getCategoryDescendantIds,
|
||||||
getProducts,
|
getProducts,
|
||||||
@@ -82,13 +83,20 @@ export default async function CategoryProductsPage({
|
|||||||
(a, b) =>
|
(a, b) =>
|
||||||
a.sortOrder - b.sortOrder || a.nameFa.localeCompare(b.nameFa, "fa")
|
a.sortOrder - b.sortOrder || a.nameFa.localeCompare(b.nameFa, "fa")
|
||||||
);
|
);
|
||||||
|
const categoryTrail = getCategoryAncestors(category.id, allCategories);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageBanner
|
<PageBanner
|
||||||
title={category.nameFa}
|
title={category.nameFa}
|
||||||
subtitle={`محصولات دسته ${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">
|
<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 Pagination from "@/components/Pagination";
|
||||||
import CategoryShowcase from "@/components/CategoryShowcase";
|
import CategoryShowcase from "@/components/CategoryShowcase";
|
||||||
import HomeContactStrip from "@/components/HomeContactStrip";
|
import HomeContactStrip from "@/components/HomeContactStrip";
|
||||||
import { getCategoryById, getProducts } from "@/lib/meshkee";
|
import { getCategories, getCategoryById, getProducts } from "@/lib/meshkee";
|
||||||
import { pageMetadata } from "@/lib/seo";
|
import { pageMetadata } from "@/lib/seo";
|
||||||
import { categoryPath } from "@/lib/urls";
|
import { categoryPath } from "@/lib/urls";
|
||||||
|
import { slugifyFa } from "@/lib/slugify";
|
||||||
|
|
||||||
export const metadata = pageMetadata({
|
export const metadata = pageMetadata({
|
||||||
title: "محصولات | آراد آریسمان",
|
title: "محصولات | آراد آریسمان",
|
||||||
@@ -21,9 +22,9 @@ const PAGE_SIZE = 6;
|
|||||||
export default async function ProductsPage({
|
export default async function ProductsPage({
|
||||||
searchParams,
|
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) {
|
if (categoryId) {
|
||||||
const category = await getCategoryById(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 requestedPage = Math.max(1, Number(page) || 1);
|
||||||
const { items, total } = await getProducts({
|
const { items, total } = await getProducts({
|
||||||
page: requestedPage,
|
page: requestedPage,
|
||||||
|
|||||||
@@ -6,10 +6,22 @@ import { useSession, userDisplayName } from "@/lib/session";
|
|||||||
|
|
||||||
function UserIcon() {
|
function UserIcon() {
|
||||||
return (
|
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
|
<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"
|
d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"
|
||||||
fill="currentColor"
|
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>
|
</svg>
|
||||||
);
|
);
|
||||||
@@ -103,12 +115,12 @@ export default function AccountMenu({ scrolled }: { scrolled: boolean }) {
|
|||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={customerPortalUrl}
|
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="ورود"
|
aria-label="ورود"
|
||||||
title="ورود"
|
title="ورود"
|
||||||
>
|
>
|
||||||
<UserIcon />
|
<UserIcon />
|
||||||
<span>ورود</span>
|
<span className="hidden lg:inline">ورود</span>
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import Image from "next/image";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Container from "./Container";
|
import Container from "./Container";
|
||||||
import ScrollReveal from "./ScrollReveal";
|
import ScrollReveal from "./ScrollReveal";
|
||||||
import { aspectRatioToCss, getCategorySummary, getStaticImageSlot } from "@/lib/meshkee";
|
import { aspectRatioToCss, getCategories, getCategorySummary, getStaticImageSlot } from "@/lib/meshkee";
|
||||||
import { categoryPath } from "@/lib/urls";
|
import { categoryPath, resolveCategorySlotHref } from "@/lib/urls";
|
||||||
import { categoryFallbackImage } from "@/lib/categoryIcons";
|
import { categoryFallbackImage } from "@/lib/categoryIcons";
|
||||||
import { toFa } from "@/lib/format";
|
import { toFa } from "@/lib/format";
|
||||||
|
|
||||||
@@ -17,9 +17,10 @@ type Tile = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function CategoryShowcase() {
|
export default async function CategoryShowcase() {
|
||||||
const [categories, slot] = await Promise.all([
|
const [categories, slot, allCategories] = await Promise.all([
|
||||||
getCategorySummary(6),
|
getCategorySummary(6),
|
||||||
getStaticImageSlot("categories"),
|
getStaticImageSlot("categories"),
|
||||||
|
getCategories(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const slotImages = slot?.images ?? [];
|
const slotImages = slot?.images ?? [];
|
||||||
@@ -29,7 +30,7 @@ export default async function CategoryShowcase() {
|
|||||||
slotImages.length > 0
|
slotImages.length > 0
|
||||||
? slotImages.map((item) => ({
|
? slotImages.map((item) => ({
|
||||||
key: item.id,
|
key: item.id,
|
||||||
href: item.linkUrl || "/products",
|
href: resolveCategorySlotHref(item, allCategories),
|
||||||
image: item.url,
|
image: item.url,
|
||||||
title: item.titleFa || "",
|
title: item.titleFa || "",
|
||||||
titleEn: item.titleEn || item.title || undefined,
|
titleEn: item.titleEn || item.title || undefined,
|
||||||
@@ -56,15 +57,12 @@ export default async function CategoryShowcase() {
|
|||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div className="mt-8 grid grid-cols-2 gap-2.5 sm:gap-3 lg:grid-cols-5">
|
||||||
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))` }}
|
|
||||||
>
|
|
||||||
{tiles.map((tile, i) => (
|
{tiles.map((tile, i) => (
|
||||||
<ScrollReveal
|
<ScrollReveal
|
||||||
key={tile.key}
|
key={tile.key}
|
||||||
delay={i * 100}
|
delay={i * 100}
|
||||||
className="w-[45%] shrink-0 snap-start sm:w-auto sm:shrink"
|
className="min-w-0"
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href={tile.href}
|
href={tile.href}
|
||||||
@@ -76,17 +74,17 @@ export default async function CategoryShowcase() {
|
|||||||
alt={tile.title || "دستهبندی محصول"}
|
alt={tile.title || "دستهبندی محصول"}
|
||||||
fill
|
fill
|
||||||
className="object-cover transition-transform duration-500 group-hover:scale-110"
|
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 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 && (
|
{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}
|
{tile.title}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{tile.titleEn && (
|
{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}
|
{tile.titleEn}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,16 +5,42 @@ import { FormEvent, useState } from "react";
|
|||||||
export default function ContactForm() {
|
export default function ContactForm() {
|
||||||
const [submitted, setSubmitted] = useState(false);
|
const [submitted, setSubmitted] = useState(false);
|
||||||
const [sending, setSending] = 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();
|
e.preventDefault();
|
||||||
const formEl = e.currentTarget;
|
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);
|
setSending(true);
|
||||||
window.setTimeout(() => {
|
setError(null);
|
||||||
setSending(false);
|
try {
|
||||||
setSubmitted(true);
|
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();
|
formEl.reset();
|
||||||
}, 600);
|
setSubmitted(true);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "ارسال پیام ناموفق بود. لطفاً دوباره تلاش کنید.");
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (submitted) {
|
if (submitted) {
|
||||||
@@ -108,6 +134,12 @@ export default function ContactForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-600" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ export default function HeaderNav({
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [scrolledPast, setScrolledPast] = useState(false);
|
const [scrolledPast, setScrolledPast] = useState(false);
|
||||||
const [productsOpen, setProductsOpen] = useState(false);
|
const [productsOpen, setProductsOpen] = useState(false);
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
const [mobileProductsOpen, setMobileProductsOpen] = useState(false);
|
||||||
const productsRef = useRef<HTMLDivElement>(null);
|
const productsRef = useRef<HTMLDivElement>(null);
|
||||||
const canBeTransparent = pathname === "/";
|
const canBeTransparent = pathname === "/";
|
||||||
const scrolled = !canBeTransparent || scrolledPast;
|
const scrolled = !canBeTransparent || scrolledPast;
|
||||||
@@ -52,13 +54,35 @@ export default function HeaderNav({
|
|||||||
return () => document.removeEventListener("mousedown", onPointerDown);
|
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-secondary/80 hover:text-primary"
|
||||||
: "text-white/90 hover:text-accent";
|
: "text-white/90 hover:text-accent";
|
||||||
|
|
||||||
|
const iconClass = scrolled || mobileOpen ? "text-secondary" : "text-white";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="fixed inset-x-0 top-0 z-50">
|
<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">
|
<Container className="flex h-11 items-center justify-between text-sm">
|
||||||
<a
|
<a
|
||||||
href="tel:09104067935"
|
href="tel:09104067935"
|
||||||
@@ -102,16 +126,16 @@ export default function HeaderNav({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`border-b transition-all duration-300 ${
|
className={`relative z-[60] border-b transition-all duration-300 ${
|
||||||
scrolled
|
scrolled || mobileOpen
|
||||||
? "bg-white/95 backdrop-blur border-black/5 shadow-sm"
|
? "bg-white/95 backdrop-blur border-black/5 shadow-sm"
|
||||||
: "bg-white/10 backdrop-blur-md border-white/10"
|
: "bg-white/10 backdrop-blur-md border-white/10"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Container className="flex h-20 items-center justify-between">
|
<Container className="flex h-20 items-center justify-between">
|
||||||
<Link href="/" className="shrink-0">
|
<Link href="/" className="shrink-0" onClick={() => setMobileOpen(false)}>
|
||||||
<Image
|
<Image
|
||||||
src={scrolled ? "/images/logo-wordmark.png" : "/images/footer-logo.png"}
|
src={scrolled || mobileOpen ? "/images/logo-wordmark.png" : "/images/footer-logo.png"}
|
||||||
alt="آراد آریسمان"
|
alt="آراد آریسمان"
|
||||||
width={140}
|
width={140}
|
||||||
height={40}
|
height={40}
|
||||||
@@ -208,9 +232,139 @@ export default function HeaderNav({
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</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>
|
</Container>
|
||||||
</div>
|
</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>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -217,23 +217,23 @@ export default function HeroSlider({
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
{showCategories && (
|
{showCategories && (
|
||||||
<div className="absolute inset-0 z-30 flex items-center justify-center px-4 pointer-events-none">
|
<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-max max-w-full flex-col items-center gap-6 sm:gap-8">
|
<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">
|
<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}
|
{CATEGORIES_TITLE}
|
||||||
</h1>
|
</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) => (
|
{categoryBoxes.map((box) => (
|
||||||
<Link
|
<Link
|
||||||
key={box.key}
|
key={box.key}
|
||||||
href={box.href}
|
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
|
<span
|
||||||
aria-hidden
|
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"
|
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 ? (
|
{box.iconUrl ? (
|
||||||
<Image
|
<Image
|
||||||
src={box.iconUrl}
|
src={box.iconUrl}
|
||||||
@@ -246,14 +246,14 @@ export default function HeroSlider({
|
|||||||
categoryIcon(box.nameFa, ICON_SIZE)
|
categoryIcon(box.nameFa, ICON_SIZE)
|
||||||
)}
|
)}
|
||||||
</span>
|
</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 && (
|
{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}
|
{box.nameFa}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{box.nameEn && (
|
{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}
|
{box.nameEn}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,15 +1,30 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Container from "./Container";
|
import Container from "./Container";
|
||||||
|
|
||||||
|
export type BreadcrumbItem = {
|
||||||
|
label: string;
|
||||||
|
href?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export default function PageBanner({
|
export default function PageBanner({
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
breadcrumb,
|
breadcrumb,
|
||||||
|
crumbs,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
subtitle?: 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 (
|
return (
|
||||||
<section className="relative overflow-hidden bg-surface py-8 sm:py-10">
|
<section className="relative overflow-hidden bg-surface py-8 sm:py-10">
|
||||||
<div
|
<div
|
||||||
@@ -30,17 +45,44 @@ export default function PageBanner({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Container className="relative">
|
<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 && (
|
{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">
|
<nav
|
||||||
<Link href="/" className="hover:text-primary transition-colors">
|
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>
|
</Link>
|
||||||
<span className="text-foreground/50">/</span>
|
{items.map((item, i) => {
|
||||||
<span className="font-medium text-primary">{breadcrumb}</span>
|
const isLast = i === items.length - 1;
|
||||||
</div>
|
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>
|
</Container>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,13 +29,13 @@ export default function Pagination({
|
|||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
aria-label="صفحهبندی"
|
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
|
<Link
|
||||||
href={hrefFor(Math.max(1, currentPage - 1))}
|
href={hrefFor(Math.max(1, currentPage - 1))}
|
||||||
aria-disabled={prevDisabled}
|
aria-disabled={prevDisabled}
|
||||||
tabIndex={prevDisabled ? -1 : undefined}
|
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
|
prevDisabled
|
||||||
? "pointer-events-none border-black/5 text-foreground/30"
|
? "pointer-events-none border-black/5 text-foreground/30"
|
||||||
: "border-black/10 text-secondary hover:border-primary hover:text-primary"
|
: "border-black/10 text-secondary hover:border-primary hover:text-primary"
|
||||||
@@ -49,7 +49,7 @@ export default function Pagination({
|
|||||||
key={p}
|
key={p}
|
||||||
href={hrefFor(p)}
|
href={hrefFor(p)}
|
||||||
aria-current={p === currentPage ? "page" : undefined}
|
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
|
p === currentPage
|
||||||
? "bg-primary text-white"
|
? "bg-primary text-white"
|
||||||
: "text-secondary hover:bg-primary/10"
|
: "text-secondary hover:bg-primary/10"
|
||||||
@@ -63,7 +63,7 @@ export default function Pagination({
|
|||||||
href={hrefFor(Math.min(totalPages, currentPage + 1))}
|
href={hrefFor(Math.min(totalPages, currentPage + 1))}
|
||||||
aria-disabled={nextDisabled}
|
aria-disabled={nextDisabled}
|
||||||
tabIndex={nextDisabled ? -1 : undefined}
|
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
|
nextDisabled
|
||||||
? "pointer-events-none border-black/5 text-foreground/30"
|
? "pointer-events-none border-black/5 text-foreground/30"
|
||||||
: "border-black/10 text-secondary hover:border-primary hover:text-primary"
|
: "border-black/10 text-secondary hover:border-primary hover:text-primary"
|
||||||
|
|||||||
@@ -63,9 +63,10 @@ export default function ProductsCarousel({
|
|||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
href={moreHref}
|
href={moreHref}
|
||||||
|
aria-label="مشاهده بیشتر"
|
||||||
className="flex items-center gap-2 text-primary font-semibold hover:text-primary-dark transition-colors"
|
className="flex items-center gap-2 text-primary font-semibold hover:text-primary-dark transition-colors"
|
||||||
>
|
>
|
||||||
مشاهده بیشتر
|
<span className="hidden sm:inline">مشاهده بیشتر</span>
|
||||||
<ChevronIcon />
|
<ChevronIcon />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</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. */
|
/** Includes the category itself and all nested descendants. */
|
||||||
export function getCategoryDescendantIds(
|
export function getCategoryDescendantIds(
|
||||||
categoryId: string,
|
categoryId: string,
|
||||||
@@ -513,3 +530,51 @@ export function postReadingTime(post: Post): string {
|
|||||||
const minutes = Math.max(1, Math.round(words / 150));
|
const minutes = Math.max(1, Math.round(words / 150));
|
||||||
return `${toFa(minutes)} دقیقه مطالعه`;
|
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 { postTitle } from "@/lib/meshkee";
|
||||||
import { slugifyFa } from "@/lib/slugify";
|
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 {
|
export function productSlug(product: Pick<Product, "nameFa">): string {
|
||||||
return slugifyFa(product.nameFa);
|
return slugifyFa(product.nameFa);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user