Add about/contact/installments pages, fix category slot bugs, wire live branding

- Enlarge category carousel thumbnails (90px -> 104-140px responsive) and
  confirm the slider/categories/banner static-image slots are all reading
  correctly from the CMS (verified live, not a wiring bug)
- Fix installments page dropping "هندزفری و هدفون": it's a level-3 category,
  not level-2, so level2.find() silently missed it - use findNode() over the
  whole tree instead
- Extend gen-category-placeholders.mjs to generate a placeholder for every
  category at every level (was level-2 only), needed for the fix above
- Add /about, /contact, /installments pages
  - About: real copy transcribed from the live moalem.shop theme, real
    product photos from the API, text-only brand strip (no scraped logos)
  - Contact: ContactForm.tsx posts to the real POST /contact-submissions,
    branch cards with key-less Google Maps embeds
  - Installments: flat brand-colored hero (no photo/gradient), category
    highlights reusing the same categories slot images as the homepage
- Replace the placeholder header/footer phone number with the real one, add
  real branch addresses and Instagram/Telegram links (src/data/site.ts),
  pulled from the live theme's footer
- Wire logo/favicon to GET /website/favicon (getFavicon() in api.ts) instead
  of the static bundled files, so a dashboard upload now shows automatically;
  layout.tsx metadata is now generateMetadata() to support this. The tenant's
  uploaded logo/favicon currently belongs to a different business - the
  wiring is correct and will self-correct once that's fixed in the dashboard

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
amirhosein.ashourloo
2026-09-06 07:22:54 +03:30
co-authored by Claude Sonnet 5
parent 09a409b3e7
commit db7d00e473
46 changed files with 913 additions and 54 deletions
+141
View File
@@ -0,0 +1,141 @@
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import Breadcrumbs from "@/components/Breadcrumbs";
import { CheckIcon } from "@/components/icons";
import { FALLBACK_BLOGS } from "@/data/fallbackBlogs";
export const metadata: Metadata = {
title: "درباره ما",
description:
"فروشگاه اینترنتی موبایل معلم، بیش از دو دهه تجربه در عرضه موبایل، لپ‌تاپ، کنسول بازی و لوازم جانبی دیجیتال با ضمانت اصالت کالا.",
};
// Real copy transcribed from the live moalem.shop theme's about-us module —
// see the paragraph match confirmed against the cached homepage HTML.
const INTRO =
"فروشگاه اینترنتی موبایل معلم با گستره‌ای از کالاهای متنوع در زمینه دیجیتال، برای کاربران خود «تجربه‌ای لذت‌بخش از یک خرید اینترنتی» را تداعی می‌کند. ارسال سریع، ضمانت بهترین قیمت و تضمین اصل بودن کالا، سه اصل اولیه‌ای است که ما از نخستین روز سعی کرده‌ایم به آن پایبند باشیم. فروشگاه موبایل معلم فعالیت خود را از سال ۱۳۸۰ با دو دهه تجربه در صنف موبایل آغاز نمود. این شرکت مفتخر است که با بهره‌گیری از کادری متخصص و مجرب، توانسته گام‌های بلندی در زمینه تامین و پخش تلفن همراه، لپ‌تاپ، کنسول بازی، دوربین دیجیتال و انواع تجهیزات و اکسسوری کالای دیجیتال بردارد و سهم شایسته‌ای در حوزه موبایل و رایانه را به خود اختصاص دهد.";
const ACTIVITIES = [
"عرضه انواع گوشی‌های موبایل و تبلت برندهای اصلی",
"عرضه و فروش انواع لپ‌تاپ‌های ایسوس و لنوو",
"عرضه انواع کنسول بازی و دوربین‌های دیجیتال",
"فروش و عرضه انواع اکسسوری موبایل در بندهای مختلف",
"ارائه پشتیبانی و خدمات پس از فروش مناسب با نیاز مشتریان",
"تعمیرات تخصصی انواع لپ‌تاپ، موبایل و…",
"فروش اقساطی تمامی کالاهای دیجیتال",
"فروش آنلاین به همکاران عزیز و مشتریان گرامی",
];
const BRANDS = ["Samsung", "Apple", "Xiaomi", "Huawei", "Asus", "Lenovo", "Nokia"];
export default function AboutPage() {
return (
<main className="boxed py-8 md:py-12">
<Breadcrumbs items={[{ label: "درباره ما" }]} />
<h1 className="mb-8 text-xl font-bold text-ink md:text-2xl">درباره ما</h1>
{/* Intro */}
<section className="grid grid-cols-1 items-center gap-8 rounded-2xl border border-line bg-surface p-6 md:grid-cols-2 md:p-10">
<div>
<span className="inline-flex items-center rounded-full bg-brand px-3 py-1.5 text-[12px] font-bold text-white">
بیش از ۱۰۰+ فروش موفق
</span>
<h2 className="mt-4 text-lg font-bold text-ink md:text-xl">فروشگاه موبایل معلم را بیشتر بشناسید</h2>
<p className="mt-4 text-[13.5px] leading-8 text-body">{INTRO}</p>
</div>
<div className="relative aspect-square w-full overflow-hidden rounded-2xl bg-white">
<Image
src="https://c804387.parspack.net/c804387/meshkee/businesses/27/media/e5dfb9fe-8910-4ce7-a43a-6a66e327bbf6.jpg"
alt="گوشی موبایل از فروشگاه موبایل معلم"
fill
sizes="(max-width: 768px) 100vw, 500px"
className="object-contain p-6"
/>
</div>
</section>
{/* Brand strip */}
<section className="my-10 flex flex-wrap items-center justify-center gap-x-10 gap-y-4 opacity-60 grayscale">
{BRANDS.map((b) => (
<span key={b} className="text-lg font-bold text-ink-soft">
{b}
</span>
))}
</section>
{/* Activities */}
<section className="grid grid-cols-1 items-center gap-8 md:grid-cols-2">
<div className="relative order-2 aspect-square w-full overflow-hidden rounded-2xl bg-surface md:order-1">
<Image
src="https://c804387.parspack.net/c804387/meshkee/businesses/27/media/a089678c-b194-4d2c-bf4d-b920208ac2e7.jpg"
alt="ساعت هوشمند از فروشگاه موبایل معلم"
fill
sizes="(max-width: 768px) 100vw, 500px"
className="object-contain p-6"
/>
</div>
<div className="order-1 md:order-2">
<h2 className="text-lg font-bold text-ink md:text-xl">از ما بیشتر بدانید</h2>
<p className="mt-2 text-[13px] text-body">زمینه فعالیت شرکت به شرح زیر می‌باشد:</p>
<ul className="mt-5 space-y-3">
{ACTIVITIES.map((item, i) => (
<li key={i} className="flex items-start gap-2.5 text-[13.5px] leading-7 text-ink-soft">
<CheckIcon className="mt-0.5 size-4 shrink-0 text-brand" />
{item}
</li>
))}
</ul>
<Link
href="/contact"
className="mt-6 inline-flex items-center gap-2 rounded-full bg-brand px-6 py-3 text-[13px] font-bold text-white transition hover:bg-brand-dark"
>
همین حالا با ما تماس بگیرید
</Link>
</div>
</section>
{/* Magazine highlight */}
<section className="mt-14 md:mt-20">
<h2 className="mb-5 text-lg font-bold text-ink md:text-xl">از مجله معلم بخوانید</h2>
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
<ul className="divide-y divide-line rounded-xl border border-line bg-white">
{FALLBACK_BLOGS.slice(0, 3).map((post) => (
<li key={post.id} className="flex gap-3 p-4">
<span className="relative size-16 shrink-0 overflow-hidden rounded-lg bg-surface">
<Image src={post.image} alt={post.title} fill sizes="64px" className="object-cover" />
</span>
<div className="min-w-0">
<span className="text-[11px] font-bold text-brand">{post.category}</span>
<p className="clamp-2 mt-1 text-[13px] leading-6 text-ink-soft">{post.title}</p>
</div>
</li>
))}
</ul>
<div className="relative overflow-hidden rounded-xl">
<span className="relative block aspect-[16/10] w-full bg-surface lg:aspect-auto lg:h-full">
<Image
src={FALLBACK_BLOGS[0].image}
alt={FALLBACK_BLOGS[0].title}
fill
sizes="(max-width: 1024px) 100vw, 50vw"
className="object-cover"
/>
{/* Flat overlay, not a gradient — this site's design keeps to flat fills. */}
<span className="absolute inset-x-0 bottom-0 bg-black/55 p-5">
<span className="rounded-full bg-white/90 px-2.5 py-1 text-[11px] font-bold text-brand">
{FALLBACK_BLOGS[0].category}
</span>
<p className="clamp-2 mt-2 text-[14px] font-bold leading-7 text-white">
{FALLBACK_BLOGS[0].title}
</p>
</span>
</span>
</div>
</div>
</section>
</main>
);
}
+100
View File
@@ -0,0 +1,100 @@
import type { Metadata } from "next";
import Breadcrumbs from "@/components/Breadcrumbs";
import ContactForm from "@/components/ContactForm";
import { InstagramIcon, MailIcon, MapPinIcon, PhoneIcon, TelegramIcon } from "@/components/icons";
import { BRANCHES, MOBILE, MOBILE_HREF, PHONE, PHONE_HREF, SOCIAL } from "@/data/site";
export const metadata: Metadata = {
title: "تماس با ما",
description: "راه‌های تماس با فروشگاه موبایل معلم — شماره تماس، آدرس شعب و فرم ارسال پیام.",
};
export default function ContactPage() {
return (
<main className="boxed py-8 md:py-12">
<Breadcrumbs items={[{ label: "تماس با ما" }]} />
<h1 className="mb-1 text-xl font-bold text-ink md:text-2xl">تماس با ما</h1>
<p className="mb-8 text-[13px] text-body">
سوالی دارید؟ فرم زیر را پر کنید یا مستقیم با ما تماس بگیرید.
</p>
<div className="grid grid-cols-1 gap-8 lg:grid-cols-[1fr_360px]">
<div className="rounded-2xl border border-line bg-white p-6 md:p-8">
<h2 className="mb-5 text-[15px] font-bold text-ink">فرم ارسال پیام</h2>
<ContactForm />
</div>
<div className="space-y-5">
<div className="rounded-2xl border border-line bg-white p-6">
<h2 className="mb-4 text-[15px] font-bold text-ink">راه‌های ارتباطی</h2>
<ul className="space-y-4 text-[13px]">
<li className="flex items-center gap-3">
<span className="flex size-9 shrink-0 items-center justify-center rounded-full bg-brand-soft text-brand">
<PhoneIcon className="size-4.5" />
</span>
<a href={PHONE_HREF} className="en text-ink transition hover:text-brand" dir="ltr">
{PHONE}
</a>
</li>
<li className="flex items-center gap-3">
<span className="flex size-9 shrink-0 items-center justify-center rounded-full bg-brand-soft text-brand">
<PhoneIcon className="size-4.5" />
</span>
<a href={MOBILE_HREF} className="en text-ink transition hover:text-brand" dir="ltr">
{MOBILE}
</a>
</li>
<li className="flex items-center gap-3">
<span className="flex size-9 shrink-0 items-center justify-center rounded-full bg-brand-soft text-brand">
<MailIcon className="size-4.5" />
</span>
<span className="text-body">پاسخگویی از ۹ صبح تا ۹ شب</span>
</li>
</ul>
<div className="mt-5 flex items-center gap-2 border-t border-line pt-5">
<a
href={SOCIAL.instagram}
target="_blank"
rel="noreferrer"
aria-label="اینستاگرام موبایل معلم"
className="flex size-9 items-center justify-center rounded-full border border-line text-ink-soft transition hover:border-brand hover:text-brand"
>
<InstagramIcon className="size-4.5" />
</a>
<a
href={SOCIAL.telegram}
target="_blank"
rel="noreferrer"
aria-label="تلگرام موبایل معلم"
className="flex size-9 items-center justify-center rounded-full border border-line text-ink-soft transition hover:border-brand hover:text-brand"
>
<TelegramIcon className="size-4.5" />
</a>
</div>
</div>
{BRANCHES.map((branch) => (
<div key={branch.title} className="overflow-hidden rounded-2xl border border-line bg-white">
<div className="flex items-start gap-3 p-5">
<span className="flex size-9 shrink-0 items-center justify-center rounded-full bg-brand-soft text-brand">
<MapPinIcon className="size-4.5" />
</span>
<div>
<p className="text-[13px] font-bold text-ink">{branch.title}</p>
<p className="mt-1 text-[12.5px] leading-6 text-body">{branch.address}</p>
</div>
</div>
<iframe
title={`نقشه ${branch.title}`}
src={`https://www.google.com/maps?q=${encodeURIComponent(branch.mapQuery)}&output=embed`}
className="h-48 w-full border-0 grayscale"
loading="lazy"
/>
</div>
))}
</div>
</div>
</main>
);
}
+138
View File
@@ -0,0 +1,138 @@
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import Breadcrumbs from "@/components/Breadcrumbs";
import TrustBadges from "@/components/ui/TrustBadges";
import { ChevronLeft, LockIcon, RotateIcon, ShieldIcon, TruckIcon, WalletIcon } from "@/components/icons";
import { findSlot, getCategories, getStaticImageSlots, safe } from "@/lib/api";
import { buildCategoryTree, carouselCategories, findNode } from "@/lib/categoryTree";
import { categoryHref } from "@/lib/slug";
export const metadata: Metadata = {
title: "فروش اقساطی",
description: "خرید اقساطی موبایل، لپ‌تاپ، تبلت، کنسول بازی و لوازم دیجیتال تا ۱۴ ماه، فقط با یک برگ چک.",
};
// Categories this page highlights, mapped to real ids from
// GET /tenants/moalem.shop/categories — "لوازم برقی" stands in for
// "خانه هوشمند" from the reference design since this tenant has no such
// category; every other label matches a real one.
const HIGHLIGHT_IDS = ["1251", "1254", "1253", "1256", "1261", "1232", "1260"];
const TRUST_ITEMS = [
{ icon: TruckIcon, title: "ارسال رایگان کالا", desc: "برای سفارش‌های بالای ۵ میلیون تومان" },
{ icon: ShieldIcon, title: "تضمین کیفیت", desc: "ضمانت اصالت تمامی کالاها" },
{ icon: LockIcon, title: "حریم خصوصی", desc: "عدم ذخیره‌سازی اطلاعات شما" },
{ icon: RotateIcon, title: "برگشت پول", desc: "برای اولین سفارش شما" },
{ icon: WalletIcon, title: "ضمانت با چک", desc: "بدون نیاز به ضامن" },
];
export default async function InstallmentsPage() {
const [roots, slots] = await Promise.all([
safe(getCategories().then(buildCategoryTree), []),
safe(getStaticImageSlots("home"), []),
]);
const level2 = carouselCategories(roots);
const categoriesSlot = findSlot(slots, "categories");
const slotImages = categoriesSlot?.images ?? [];
const highlights = HIGHLIGHT_IDS.map((id) => {
// findNode searches the whole tree, not just level2 — "هندزفری و هدفون"
// (1232) is a level-3 category, not level-2, so level2.find missed it.
// The "categories" slot only has art for the level-2 list though, so a
// deeper id like this one always keeps its own placeholder.
const cat = findNode(roots, id);
if (!cat) return null;
const index = level2.findIndex((c) => c.id === id);
const art = (index >= 0 && slotImages[index]?.url) || `/images/categories/${id}.svg`;
return { id, label: cat.nameFa || cat.name, art, href: categoryHref(id, cat.nameFa, cat.name) };
}).filter((x): x is NonNullable<typeof x> => Boolean(x));
return (
<main className="boxed py-8 md:py-12">
<Breadcrumbs items={[{ label: "فروش اقساطی" }]} />
<h1 className="mb-1 text-xl font-bold text-ink md:text-2xl">فروش اقساطی</h1>
<p className="mb-8 text-[13px] text-body">خرید به‌صرفه و مطمئن</p>
{/* Flat hero banner — brand-colored, no photo, no gradient. */}
<section className="overflow-hidden rounded-2xl bg-ink p-8 text-white md:p-12">
<p className="en text-sm font-bold tracking-widest text-white/60">INSTALLMENTS</p>
<p className="mt-2 text-6xl font-black leading-none text-brand md:text-8xl">۱۴ ماه</p>
<h2 className="mt-4 text-xl font-bold md:text-2xl">فروش اقساطی تمامی محصولات</h2>
<p className="mt-1 text-[14px] text-white/70">راهی مطمئن برای خرید آسان و اقساطی</p>
<Link
href="/products"
className="mt-6 inline-flex items-center gap-1.5 rounded-full bg-brand px-6 py-3 text-[13px] font-bold text-white transition hover:bg-brand-dark"
>
همین حالا خرید کنید
<ChevronLeft className="size-4" />
</Link>
</section>
{/* Goal + intro */}
<section className="mt-10 grid grid-cols-1 items-center gap-8 md:grid-cols-[200px_1fr]">
<div className="text-center md:text-start">
<p className="text-7xl font-black leading-none text-brand">۱۴</p>
<p className="en text-sm font-bold tracking-widest text-body">MONTH</p>
</div>
<div>
<h2 className="text-lg font-bold text-ink md:text-xl">هدف ما، فروش اقساطی به‌صورت منصفانه و متنوع</h2>
<p className="mt-3 text-[13.5px] leading-8 text-body">
ما فروش اقساطی لوازم دیجیتال ارائه می‌دهیم؛ شامل گوشی موبایل، لپ‌تاپ، تبلت، کنسول بازی و سایر
محصولات روز. پرداخت اقساط به‌صورت اقساط تا ۱۴ ماه انجام می‌شود و تنها تضمین موردنیاز، چک است.
هدف ما فراهم‌کردن خرید آسان، سریع و مطمئن برای مشتریان است تا بدون فشار مالی، دستگاه موردنظرشان
را تهیه کنند.
</p>
</div>
</section>
{/* Category highlights */}
{highlights.length > 0 && (
<section className="mt-14">
<h2 className="text-lg font-bold text-ink md:text-xl">نقد و اقساط ۱۴ ماهه</h2>
<p className="mt-1 text-[13px] text-body">
با شرایطی آسان، هر کالای دیجیتالی و گجت هوشمند مورد علاقه‌تان را به‌صورت اقساطی بخرید
</p>
<div className="mt-6 grid grid-cols-3 gap-6 sm:grid-cols-4 lg:grid-cols-7">
{highlights.map((h) => (
<Link key={h.id} href={h.href} className="group flex flex-col items-center gap-2">
<span className="relative size-[84px] overflow-hidden rounded-full bg-surface transition group-hover:bg-brand-soft sm:size-[96px]">
<Image src={h.art} alt={h.label} fill sizes="96px" className="object-contain p-3" />
</span>
<span className="text-center text-[12.5px] text-ink transition group-hover:text-brand">
{h.label}
</span>
</Link>
))}
</div>
</section>
)}
{/* Second promo block */}
<section className="mt-14 grid grid-cols-1 items-center gap-8 rounded-2xl border border-line bg-surface p-6 md:grid-cols-[1fr_220px] md:p-10">
<div>
<h2 className="text-lg font-bold text-ink md:text-xl">
فروش اقساطی تمامی اجناس و پرداخت حداکثر تا ۱۴ ماه
</h2>
<p className="mt-3 text-[13.5px] leading-8 text-body">
برای سهولت بیشتر، تنها با ۲۰٪ پیش‌پرداخت می‌توانید محصول مورد نظرتان را دریافت کنید. باقی مبلغ
در بلندمدت تا ۱۴ ماه تسویه می‌شود و کارمزدها هم کاملاً منصفانه و شفاف هستند. با این شرایط، خرید
راحت، سریع و بدون فشار مالی انجام می‌شود.
</p>
<Link
href="/contact"
className="mt-5 inline-flex items-center gap-1.5 rounded-full bg-brand px-6 py-3 text-[13px] font-bold text-white transition hover:bg-brand-dark"
>
همین حالا ثبت‌نام بگیرید
<ChevronLeft className="size-4" />
</Link>
</div>
<p className="text-center text-8xl font-black leading-none text-brand md:text-9xl">۱۴</p>
</section>
<section className="mt-14">
<TrustBadges items={TRUST_ITEMS} />
</section>
</main>
);
}
+42 -24
View File
@@ -6,12 +6,14 @@ import Header from "@/components/Header";
import Footer from "@/components/Footer";
import TopBar from "@/components/TopBar";
import MobileBottomNav from "@/components/MobileBottomNav";
import { getCategories, safe } from "@/lib/api";
import { getCategories, getFavicon, safe } from "@/lib/api";
import { buildCategoryTree, carouselCategories } from "@/lib/categoryTree";
import { getSession } from "@/lib/auth";
import { SITE_NAME } from "@/data/site";
import { SITE_URL } from "@/lib/config";
const NO_BRAND_ASSETS = { faviconUrl: null, logoUrl: null, logoDarkUrl: null, hasDedicatedFavicon: false };
// Montserrat is the Latin face the live moalem.shop theme pairs with IRANYekan.
const montserrat = Montserrat({
variable: "--font-en",
@@ -20,41 +22,57 @@ const montserrat = Montserrat({
display: "swap",
});
export const metadata: Metadata = {
metadataBase: new URL(SITE_URL),
title: {
default: `${SITE_NAME} | خرید موبایل، لپ‌تاپ و لوازم جانبی`,
template: `%s | ${SITE_NAME}`,
},
description:
"خرید اینترنتی گوشی موبایل، لپ‌تاپ، تبلت، کنسول بازی و لوازم جانبی با ضمانت اصالت کالا از فروشگاه موبایل معلم.",
icons: [{ url: "/favicon.png", type: "image/png" }],
openGraph: {
type: "website",
locale: "fa_IR",
siteName: SITE_NAME,
title: `${SITE_NAME} | خرید موبایل، لپ‌تاپ و لوازم جانبی`,
description:
"خرید اینترنتی گوشی موبایل، لپ‌تاپ، تبلت، کنسول بازی و لوازم جانبی با ضمانت اصالت کالا از فروشگاه موبایل معلم.",
url: SITE_URL,
images: [{ url: "/images/logo.png", width: 130, height: 36, alt: SITE_NAME }],
},
};
const BASE_DESCRIPTION =
"خرید اینترنتی گوشی موبایل، لپ‌تاپ، تبلت، کنسول بازی و لوازم جانبی با ضمانت اصالت کالا از فروشگاه موبایل معلم.";
// generateMetadata (not a static export) because the tab icon comes from
// GET /tenants/{domain}/website/favicon — once the business uploads a real
// one in the dashboard this picks it up with no code change. Falls back to
// the bundled /favicon.png only while that API field is empty.
export async function generateMetadata(): Promise<Metadata> {
const brand = await safe(getFavicon(), NO_BRAND_ASSETS);
return {
metadataBase: new URL(SITE_URL),
title: {
default: `${SITE_NAME} | خرید موبایل، لپ‌تاپ و لوازم جانبی`,
template: `%s | ${SITE_NAME}`,
},
description: BASE_DESCRIPTION,
icons: [{ url: brand.faviconUrl || "/favicon.png", type: "image/png" }],
openGraph: {
type: "website",
locale: "fa_IR",
siteName: SITE_NAME,
title: `${SITE_NAME} | خرید موبایل، لپ‌تاپ و لوازم جانبی`,
description: BASE_DESCRIPTION,
url: SITE_URL,
// The dashboard logo is a square mark; the bundled fallback is a wide
// wordmark — the two aren't the same shape, so their og:image sizes
// can't share one hardcoded pair.
images: brand.logoUrl
? [{ url: brand.logoUrl, width: 512, height: 512, alt: SITE_NAME }]
: [{ url: "/images/logo.png", width: 130, height: 36, alt: SITE_NAME }],
},
};
}
export default async function RootLayout({ children }: LayoutProps<"/">) {
const [categories, user] = await Promise.all([
const [categories, user, brand] = await Promise.all([
safe(getCategories().then(buildCategoryTree), []),
getSession(),
safe(getFavicon(), NO_BRAND_ASSETS),
]);
const menu = carouselCategories(categories);
const logoUrl = brand.logoUrl || "/images/logo.png";
return (
<html lang="fa" dir="rtl" className={`${iranYekan.variable} ${montserrat.variable} h-full antialiased`}>
<body className="flex min-h-full flex-col">
<TopBar />
<Header menu={menu} user={user} />
<Header menu={menu} user={user} logoUrl={logoUrl} />
<main className="flex-1 pb-6">{children}</main>
<Footer categories={menu} />
<Footer categories={menu} logoUrl={logoUrl} />
{/* Spacer so the fixed mobile bar never covers the end of the footer. */}
<div className="h-[68px] lg:hidden" />
<MobileBottomNav categories={menu} user={user} />
+7 -7
View File
@@ -31,12 +31,12 @@ export default function CategoryCarousel({
modules={[FreeMode]}
dir="rtl"
freeMode
slidesPerView={3.5}
slidesPerView={2.8}
spaceBetween={12}
breakpoints={{
480: { slidesPerView: 4.5 },
768: { slidesPerView: 6.5 },
1024: { slidesPerView: 9 },
480: { slidesPerView: 3.6 },
768: { slidesPerView: 5 },
1024: { slidesPerView: 7 },
}}
>
{categories.map((cat, i) => {
@@ -45,10 +45,10 @@ export default function CategoryCarousel({
return (
<SwiperSlide key={cat.id}>
<Link href={categoryHref(cat.id, cat.nameFa, cat.name)} className="group flex flex-col items-center gap-2">
<span className="relative size-[90px] overflow-hidden rounded-full bg-surface transition group-hover:bg-brand-soft">
<Image src={art} alt={label} fill sizes="90px" className="object-contain p-2" />
<span className="relative size-[104px] overflow-hidden rounded-full bg-surface transition group-hover:bg-brand-soft sm:size-[124px] lg:size-[140px]">
<Image src={art} alt={label} fill sizes="140px" className="object-contain p-3" />
</span>
<span className="text-center text-[12.5px] text-ink transition group-hover:text-brand">
<span className="text-center text-[13.5px] text-ink transition group-hover:text-brand">
{label}
</span>
</Link>
+88
View File
@@ -0,0 +1,88 @@
"use client";
import { useState } from "react";
import { submitContactForm } from "@/lib/api";
type Status = "idle" | "submitting" | "sent" | "error";
// Posts straight to POST /tenants/moalem.shop/contact-submissions — a real,
// documented endpoint, no bearer required.
export default function ContactForm() {
const [status, setStatus] = useState<Status>("idle");
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const form = new FormData(e.currentTarget);
const name = String(form.get("name") || "").trim();
const title = String(form.get("title") || "").trim();
const text = String(form.get("text") || "").trim();
const email = String(form.get("email") || "").trim();
const cellNumber = String(form.get("cellNumber") || "").trim();
if (!name || !title || !text) return;
setStatus("submitting");
try {
await submitContactForm({
name,
title,
text,
email: email || undefined,
cellNumber: cellNumber || undefined,
});
setStatus("sent");
e.currentTarget.reset();
} catch {
setStatus("error");
}
}
if (status === "sent") {
return (
<div className="flex flex-col items-center gap-3 rounded-2xl border border-line bg-surface p-10 text-center">
<p className="text-[15px] font-bold text-ink">پیام شما با موفقیت ارسال شد.</p>
<p className="text-[13px] text-body">همکاران ما در اسرع وقت با شما تماس می‌گیرند.</p>
<button
onClick={() => setStatus("idle")}
className="mt-2 rounded-lg border border-line px-4 py-2 text-[13px] text-ink-soft transition hover:border-brand hover:text-brand"
>
ارسال پیام دیگر
</button>
</div>
);
}
const input =
"h-12 w-full rounded-xl border border-line bg-white px-4 text-sm outline-none transition focus:border-brand";
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<input name="name" required placeholder="نام و نام خانوادگی" className={input} />
<input name="cellNumber" placeholder="شماره موبایل" inputMode="tel" className={input} />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<input name="email" type="email" placeholder="ایمیل (اختیاری)" className={input} />
<input name="title" required placeholder="موضوع پیام" className={input} />
</div>
<textarea
name="text"
required
rows={5}
placeholder="متن پیام شما…"
className="w-full rounded-xl border border-line bg-white p-4 text-sm outline-none transition focus:border-brand"
/>
{status === "error" && (
<p className="text-[12.5px] text-brand">ارسال پیام ناموفق بود. لطفاً دوباره تلاش کنید.</p>
)}
<button
type="submit"
disabled={status === "submitting"}
className="flex h-12 w-full items-center justify-center rounded-xl bg-brand text-sm font-bold text-white transition hover:bg-brand-dark disabled:cursor-not-allowed disabled:opacity-50 sm:w-auto sm:px-10"
>
{status === "submitting" ? "در حال ارسال…" : "ارسال پیام"}
</button>
</form>
);
}
+33 -4
View File
@@ -1,17 +1,25 @@
import Link from "next/link";
import Image from "next/image";
import { MAIN_NAV, PHONE, PHONE_HREF, SITE_NAME, SITE_TAGLINE } from "@/data/site";
import { MAIN_NAV, PHONE, PHONE_HREF, SITE_NAME, SITE_TAGLINE, SOCIAL } from "@/data/site";
import { categoryHref } from "@/lib/slug";
import type { CategoryNode } from "@/lib/types";
import { PhoneIcon } from "./icons";
import { InstagramIcon, PhoneIcon, TelegramIcon } from "./icons";
import { toFaDigits } from "@/lib/format";
export default function Footer({ categories }: { categories: CategoryNode[] }) {
export default function Footer({
categories,
logoUrl,
}: {
categories: CategoryNode[];
logoUrl: string;
}) {
return (
<footer className="mt-14 border-t border-line bg-surface">
<div className="boxed grid gap-8 py-10 md:grid-cols-4">
<div className="md:col-span-2">
<Image src="/images/logo.png" alt={SITE_NAME} width={130} height={36} />
{/* h-9 w-auto so a square dashboard logo and the wide fallback
wordmark both render undistorted — see Header.tsx. */}
<Image src={logoUrl} alt={SITE_NAME} width={140} height={40} className="h-9 w-auto object-contain" />
<p className="mt-3 max-w-md text-[13px] leading-7 text-body">{SITE_TAGLINE}</p>
<a
href={PHONE_HREF}
@@ -20,6 +28,27 @@ export default function Footer({ categories }: { categories: CategoryNode[] }) {
<PhoneIcon className="size-4" />
{PHONE}
</a>
<div className="mt-4 flex items-center gap-2">
<a
href={SOCIAL.instagram}
target="_blank"
rel="noreferrer"
aria-label="اینستاگرام موبایل معلم"
className="flex size-9 items-center justify-center rounded-full border border-line text-ink-soft transition hover:border-brand hover:text-brand"
>
<InstagramIcon className="size-4.5" />
</a>
<a
href={SOCIAL.telegram}
target="_blank"
rel="noreferrer"
aria-label="تلگرام موبایل معلم"
className="flex size-9 items-center justify-center rounded-full border border-line text-ink-soft transition hover:border-brand hover:text-brand"
>
<TelegramIcon className="size-4.5" />
</a>
</div>
</div>
<div>
+12 -2
View File
@@ -13,7 +13,15 @@ import type { AuthUser, CategoryNode } from "@/lib/types";
// Two rows: logo + search + account/cart, then the product mega menu and the
// main nav — the same split the reference storefront uses.
export default function Header({ menu, user }: { menu: CategoryNode[]; user: AuthUser | null }) {
export default function Header({
menu,
user,
logoUrl,
}: {
menu: CategoryNode[];
user: AuthUser | null;
logoUrl: string;
}) {
const [megaOpen, setMegaOpen] = useState(false);
return (
@@ -23,7 +31,9 @@ export default function Header({ menu, user }: { menu: CategoryNode[]; user: Aut
cart and browsing live in the fixed bottom bar instead. */}
<div className="flex h-[70px] items-center gap-4">
<Link href="/" className="hidden shrink-0 lg:block" aria-label={SITE_NAME}>
<Image src="/images/logo.png" alt={SITE_NAME} width={130} height={36} priority />
{/* h-9 w-auto (not a fixed box) so a square dashboard logo mark
and the wide fallback wordmark both render undistorted. */}
<Image src={logoUrl} alt={SITE_NAME} width={140} height={40} className="h-9 w-auto object-contain" priority />
</Link>
<div className="flex-1">
+43
View File
@@ -110,3 +110,46 @@ export const RotateIcon = ({ className }: P) => (
<path d="M17.5 3.5V7H14M6.5 20.5V17H10" />
</svg>
);
export const LockIcon = ({ className }: P) => (
<svg {...base} className={className} aria-hidden>
<rect x="5" y="11" width="14" height="9" rx="2" />
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
</svg>
);
export const WalletIcon = ({ className }: P) => (
<svg {...base} className={className} aria-hidden>
<rect x="3.5" y="6" width="17" height="13" rx="2" />
<path d="M3.5 10h17" />
<circle cx="16.5" cy="14" r="1.2" />
</svg>
);
export const MapPinIcon = ({ className }: P) => (
<svg {...base} className={className} aria-hidden>
<path d="M12 21s7-6.5 7-11.5A7 7 0 0 0 5 9.5C5 14.5 12 21 12 21z" />
<circle cx="12" cy="9.5" r="2.4" />
</svg>
);
export const MailIcon = ({ className }: P) => (
<svg {...base} className={className} aria-hidden>
<rect x="3" y="5" width="18" height="14" rx="2" />
<path d="M3.5 6.5 12 13l8.5-6.5" />
</svg>
);
export const InstagramIcon = ({ className }: P) => (
<svg {...base} className={className} aria-hidden>
<rect x="3.5" y="3.5" width="17" height="17" rx="5" />
<circle cx="12" cy="12" r="4" />
<circle cx="17.2" cy="6.8" r="0.9" fill="currentColor" stroke="none" />
</svg>
);
export const TelegramIcon = ({ className }: P) => (
<svg {...base} className={className} aria-hidden>
<path d="M21 4 3 11.4l6 2.1M21 4l-3.3 15L9 13.5M21 4 9 13.5v5.4l3-3.3" />
</svg>
);
+26
View File
@@ -0,0 +1,26 @@
import type { ComponentType } from "react";
export interface TrustBadge {
icon: ComponentType<{ className?: string }>;
title: string;
desc: string;
}
// Shared trust-point row used on /about and /installments — same visual
// language as the badges in ProductBuyPanel, just laid out wider.
export default function TrustBadges({ items }: { items: TrustBadge[] }) {
return (
<div className="grid grid-cols-2 gap-6 sm:grid-cols-3 lg:grid-cols-5">
{items.map((item) => (
<div
key={item.title}
className="flex flex-col items-center gap-2 rounded-xl border border-line bg-white p-5 text-center"
>
<item.icon className="size-7 text-brand" />
<span className="text-[13px] font-bold text-ink">{item.title}</span>
<span className="text-[11.5px] leading-5 text-body">{item.desc}</span>
</div>
))}
</div>
);
}
+26 -2
View File
@@ -9,8 +9,32 @@ export const PROMO = {
href: "/installments",
};
export const PHONE = "۰۲۱-۳۳۹۰۰۰۰۰";
export const PHONE_HREF = "tel:+982133900000";
// Real contact data — pulled from the live moalem.shop theme (footer +
// support badge), not invented. website/business-info on the API is still
// blank for phones/addresses/social, so this stays hand-maintained here
// until that's filled in; update both together when it is.
export const PHONE = "۰۲۵-۳۲۱۰۶";
export const PHONE_HREF = "tel:+982532106";
export const MOBILE = "۰۹۳۵۷۵۳۲۱۰۶";
export const MOBILE_HREF = "tel:+989357532106";
export const SOCIAL = {
instagram: "https://instagram.com/mobilemoalem",
telegram: "https://t.me/mobilemoalem",
};
export const BRANCHES = [
{
title: "شعبه ۱",
address: "قم، تقاطع عماریاسر، جنب پاساژ الزهرا، موبایل معلم",
mapQuery: "قم تقاطع عماریاسر جنب پاساژ الزهرا موبایل معلم",
},
{
title: "شعبه ۲",
address: "قم، عماریاسر، نبش بازار سلام، فروشگاه معلم پلاس",
mapQuery: "قم عماریاسر نبش بازار سلام فروشگاه معلم پلاس",
},
];
export const MAIN_NAV = [
{ label: "برندها", href: "/brands" },
+8
View File
@@ -4,6 +4,7 @@ import type {
ApiCategory,
ApiProduct,
AuthUser,
BrandAssets,
BusinessInfo,
ContactFormInput,
EntityComment,
@@ -86,6 +87,13 @@ export function getBusinessInfo(): Promise<BusinessInfo> {
return apiGet<BusinessInfo>(t("/website/business-info"));
}
// Single documented source for header/footer logos and the browser-tab
// icon — see AGENTS.md "Branding". Prefer this over reading logoUrl off
// getTenant()/getBusinessInfo() directly.
export function getFavicon(): Promise<BrandAssets> {
return apiGet<BrandAssets>(t("/website/favicon"));
}
export function getSliders(): Promise<SliderItem[]> {
return apiGet<{ items: SliderItem[] }>(t("/website/sliders")).then((r) => r.items);
}
+10
View File
@@ -23,6 +23,16 @@ export interface Tenant {
faviconUrl: string | null;
}
// GET /tenants/{domain}/website/favicon — the documented single source for
// header/footer logos and the browser-tab icon (falls back to the light
// logo when hasDedicatedFavicon is false).
export interface BrandAssets {
faviconUrl: string | null;
logoUrl: string | null;
logoDarkUrl: string | null;
hasDedicatedFavicon: boolean;
}
// This tenant has no emails/phones/addresses filled in yet, so the loose
// shapes are read defensively at render time.
export interface BusinessInfo {