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:
co-authored by
Claude Sonnet 5
parent
09a409b3e7
commit
db7d00e473
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user