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
+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>
);
}