diff --git a/src/app/api/contact/route.ts b/src/app/api/contact/route.ts new file mode 100644 index 0000000..804542b --- /dev/null +++ b/src/app/api/contact/route.ts @@ -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 }); +} diff --git a/src/app/globals.css b/src/app/globals.css index b787352..5c6d648 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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 { diff --git a/src/app/page.tsx b/src/app/page.tsx index 13b83c2..524806b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 ( <> p.id !== product.id) @@ -64,7 +71,16 @@ export default async function ProductDetailPage({ return ( <> - + ({ + label: cat.nameFa, + href: categoryPath(cat), + })), + ]} + />
@@ -90,7 +106,7 @@ export default async function ProductDetailPage({
{shortDesc && ( -

{shortDesc}

+

{shortDesc}

)}
diff --git a/src/app/products/category/[id]/[slug]/page.tsx b/src/app/products/category/[id]/[slug]/page.tsx index 7517e03..04b250e 100644 --- a/src/app/products/category/[id]/[slug]/page.tsx +++ b/src/app/products/category/[id]/[slug]/page.tsx @@ -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 ( <> ({ + label: cat.nameFa, + href: i < categoryTrail.length - 1 ? categoryPath(cat) : undefined, + })), + ]} />
diff --git a/src/app/products/page.tsx b/src/app/products/page.tsx index 89d3399..fb2388f 100644 --- a/src/app/products/page.tsx +++ b/src/app/products/page.tsx @@ -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, diff --git a/src/components/AccountMenu.tsx b/src/components/AccountMenu.tsx index 47eafc4..ba9492f 100644 --- a/src/components/AccountMenu.tsx +++ b/src/components/AccountMenu.tsx @@ -6,10 +6,22 @@ import { useSession, userDisplayName } from "@/lib/session"; function UserIcon() { return ( - + + ); @@ -103,12 +115,12 @@ export default function AccountMenu({ scrolled }: { scrolled: boolean }) { return ( - ورود + ورود ); } diff --git a/src/components/CategoryShowcase.tsx b/src/components/CategoryShowcase.tsx index 5091e8b..07dc485 100644 --- a/src/components/CategoryShowcase.tsx +++ b/src/components/CategoryShowcase.tsx @@ -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() {
-
+
{tiles.map((tile, i) => (
-
+
{tile.title && ( - + {tile.title} )} {tile.titleEn && ( - + {tile.titleEn} )} diff --git a/src/components/ContactForm.tsx b/src/components/ContactForm.tsx index 1824db1..6ef5910 100644 --- a/src/components/ContactForm.tsx +++ b/src/components/ContactForm.tsx @@ -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(null); - function handleSubmit(e: FormEvent) { + async function handleSubmit(e: FormEvent) { 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() { />
+ {error && ( +

+ {error} +

+ )} + +
+ + {mobileOpen && ( +
+ + )} +
+ {mobileProductsOpen && productCategories.length > 0 && ( +
+ {productCategories.map((cat) => ( + setMobileOpen(false)} + > + {cat.nameFa} + + ))} + setMobileOpen(false)} + > + همه محصولات + +
+ )} +
+ + {navLinks.slice(1).map((link) => ( + setMobileOpen(false)} + > + {link.label} + + ))} + + +
+ )} ); } diff --git a/src/components/HeroSlider.tsx b/src/components/HeroSlider.tsx index 0dd259d..2f6ea3c 100644 --- a/src/components/HeroSlider.tsx +++ b/src/components/HeroSlider.tsx @@ -217,23 +217,23 @@ export default function HeroSlider({ })} {showCategories && ( -
-
+
+

{CATEGORIES_TITLE}

-
+
{categoryBoxes.map((box) => ( - + {box.iconUrl ? ( - + {box.nameFa && ( - + {box.nameFa} )} {box.nameEn && ( - + {box.nameEn} )} diff --git a/src/components/PageBanner.tsx b/src/components/PageBanner.tsx index aeaf16e..73abb93 100644 --- a/src/components/PageBanner.tsx +++ b/src/components/PageBanner.tsx @@ -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 (
-

{title}

+

+ {title} +

{subtitle && ( -

{subtitle}

+

+ {subtitle} +

)} -
- +
+ {items.map((item, i) => { + const isLast = i === items.length - 1; + return ( + + / + {item.href && !isLast ? ( + + {item.label} + + ) : ( + + {item.label} + + )} + + ); + })} +
); diff --git a/src/components/Pagination.tsx b/src/components/Pagination.tsx index 5db8e1d..2cdf6a8 100644 --- a/src/components/Pagination.tsx +++ b/src/components/Pagination.tsx @@ -29,13 +29,13 @@ export default function Pagination({ return (
diff --git a/src/lib/meshkee.ts b/src/lib/meshkee.ts index 71ac35c..236389b 100644 --- a/src/lib/meshkee.ts +++ b/src/lib/meshkee.ts @@ -299,6 +299,23 @@ export async function getTopLevelCategories(): Promise { ); } +/** 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(); + 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 }; +} diff --git a/src/lib/urls.ts b/src/lib/urls.ts index 7e7de84..9da634b 100644 --- a/src/lib/urls.ts +++ b/src/lib/urls.ts @@ -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, + 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): string { return slugifyFa(product.nameFa); }