Compare commits

...
3 Commits
Author SHA1 Message Date
amirhosein.ashourlooandClaude Sonnet 5 0de5abb879 Double desktop header logo, add English caption under each carousel title
- Header logo (desktop variant) doubled: 40/48px -> 80/96px.
- Each dynamic store-specials carousel now shows an English caption
  under its title at 50% opacity, derived from the group's real `key`
  (e.g. "top-selling" -> "TOP SELLING") rather than inventing a
  translation of the Persian title, which the API doesn't provide.
  New SectionTitle `enSubtitle` prop (separate from the existing
  inline `enTitle`) carries this.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 18:06:42 +03:30
amirhosein.ashourlooandClaude Sonnet 5 7c04316f3a Interleave banners between carousels, real "view all" links, bigger desktop logo
- StoreSpecialCarousels now inserts one of the two banner sections
  (TwoBanners/ThreeBanners, cycling) between every pair of carousels
  instead of stacking all carousels together with the banners fixed
  elsewhere on the page — works for any number of dynamic carousels.
- SectionTitle's action button is a real link now (actionHref), not an
  inert <button>. Each carousel's "مشاهده همه" goes to
  /products?specialId={group.id}, which renders that exact
  admin-curated group in the full product grid — store-specials groups
  have no pagination, so this is genuinely the complete set, not a
  truncated preview. Also fixed BlogCarousel's dead "وبلاگ" button the
  same way, now linking to /blog.
- Reduced FeaturedCategories' vertical padding (py-10/14 -> py-4/6).
- Desktop header logo was smaller than the mobile one (36px vs 46px) —
  bumped to 40/48px so it reads properly at desktop width.
- CONTEXT.md: document the banner-interleaving and specialId patterns.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 17:57:01 +03:30
amirhosein.ashourlooandClaude Sonnet 5 bc6cafaffa Make homepage carousels fully dynamic, drop brand carousels, dynamic logo/favicon
- Replace the two hardcoded homepage carousels (SpecialSaleCarousel
  pinned to store-specials group 0 only, TopSellingCarousel reading
  from an unrelated plain product query) with StoreSpecialCarousels:
  loops over every group GET /store-specials returns, using each
  group's own title. A new carousel created in the admin panel (e.g.
  the "بهترین‌ها از نگاه شما" one just added) shows up automatically,
  no code change needed.
- Remove the brand-groups integration and its "سامسونگ" carousel
  entirely (getBrandGroups, BrandGroupCarousels) — brand-name
  carousels aren't wanted on this site per explicit instruction.
- SectionTitle's enTitle is now optional, since dynamic store-specials
  groups only have one title string, not a Persian/English pair.
- Move featured categories above the hero-adjacent special carousels
  per the requested homepage order; drop the "دسته‌بندی‌های پرطرفدار"
  heading and its "همه دسته‌ها" button, per request.
- Wire the header/footer logo and the favicon to the real business
  data (logoUrl/logoDarkUrl/faviconUrl from GET business-info) instead
  of static files; removed src/app/favicon.ico, which was silently
  winning over the new dynamic <link rel="icon">.
- CONTEXT.md: document the dynamic-carousel contract and the
  logo/favicon wiring so neither regresses to hardcoded.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 17:41:46 +03:30
15 changed files with 300 additions and 247 deletions
+69 -1
View File
@@ -195,12 +195,80 @@ them in the dashboard; the code path just needs to fall back to whatever the
`FeaturedCategories.tsx` for the pattern: real slot images when present, `FeaturedCategories.tsx` for the pattern: real slot images when present,
else the pre-existing UI unchanged). else the pre-existing UI unchanged).
## Homepage product carousels — fully dynamic, don't hardcode a name/key
`GET /tenants/{domain}/store-specials` returns **every** admin-managed named
carousel as one flat list — e.g. "Special Sale", "پرفروش‌ها", "بهترین‌ها از
نگاه شما" all come back from this one call, each with its own `title` and
`items`. `StoreSpecialCarousels.tsx` maps over the **whole list** (sorted by
`sortOrder`, empty groups skipped) and renders one titled carousel section
per group, using `group.title` from the API as the heading — no group
id/key/title is special-cased in code. This means a brand-new carousel
created in the Meshkee dashboard (any name) appears on the homepage the next
time the page renders, with zero code changes.
**Do not**:
- Hardcode a Persian/English title pair for one of these carousels (that was
the old `SpecialSaleCarousel`/`TopSellingCarousel` mistake — two separate
components each pinned to one group, with a made-up title instead of the
API's own `title`, and `TopSellingCarousel` didn't even read from
store-specials, it re-queried the plain product list).
- Reintroduce a **brand-name carousel** (`GET /website/brand-groups` — used
to render a "سامسونگ" section). The user explicitly removed this: brand
carousels aren't wanted on this site at all. If similar per-brand curation
is wanted later, it should go through admin-managed store-specials groups
(a group can be curated by brand already), not a separate brand-groups
integration.
`SectionTitle`'s `enTitle` prop is optional for exactly this reason — these
dynamic groups only have one `title` string from the API, not a Persian/
English pair, so the inline English-caps subtitle just doesn't render for
them. Instead, each carousel shows an English caption *under* the title
(`SectionTitle`'s separate `enSubtitle` prop, `opacity-50`, its own line) —
`StoreSpecialCarousels.englishLabelFromKey()` formats the group's `key`
(e.g. `top-selling` → "TOP SELLING") rather than inventing a translation of
`title`; there's no real localized-English field to translate from, and a
made-up translation would be fabricated data. Skips rendering if the key
has no letters at all (fell back to a bare numeric id).
**Banner interleaving**: the two static-image banner sections (`TwoBanners`,
`ThreeBanners`) are rendered *inside* `StoreSpecialCarousels`, one inserted
between every pair of carousels (never before the first or after the last),
cycling through the two if there end up being more than two carousels. Don't
move them back out to `page.tsx` as fixed-position siblings — that only
works for a known, fixed carousel count, which this deliberately isn't.
**"مشاهده همه" (view all)**: each carousel's action button links to
`/products?specialId={group.id}`. `store-specials` groups are admin-curated,
already-complete lists (the endpoint has no pagination) — there's no
"more items than the carousel shows" to fetch, so `/products` with
`specialId` set just re-renders that exact group's `items` in the full grid
instead of calling `getProducts()`. This is why the pattern is safe to keep
fully generic (any group id works, forever) rather than needing a
per-carousel filter mapping — there isn't a filter to map to; it's the same
list either way.
## Branding: logo & favicon are dynamic, not static files
`BusinessInfo` (`getBusinessInfo()`, already fetched in `layout.tsx`) carries
`logoUrl`, `logoDarkUrl`, `faviconUrl` straight from the API — the same
values `GET /tenants/{domain}` and `GET .../website/favicon` also expose.
`Header`'s `Logo` takes a `src` prop (falls back to `public/images/logo.png`
only if the API value is null) threaded down from `layout.tsx`; `Footer`
prefers `logoDarkUrl` (falls back to `logoUrl`) since it sits on a dark
background. The favicon is set via `generateMetadata()`'s `icons` field —
**there is no `src/app/favicon.ico` file** (deliberately removed): Next.js's
static-file favicon convention and a dynamic `metadata.icons` entry both
render a `<link rel="icon">` and browsers aren't guaranteed to prefer the
second one, so keeping both was silently serving the stale static icon.
Don't add `app/favicon.ico` (or `public/favicon.ico`) back.
## Route map ## Route map
| Route | Notes | | Route | Notes |
|---|---| |---|---|
| `/` | Homepage — all the carousels/banners | | `/` | Homepage — all the carousels/banners |
| `/products`, `/products/[slug]` | Catalog browsing, category filter via `?categoryId=` | | `/products`, `/products/[slug]` | Catalog browsing, category filter via `?categoryId=`, one store-specials group via `?specialId=` (see above) |
| `/products/id/[id]` | Redirect-only resolver, see "Group-source id vs productId" above | | `/products/id/[id]` | Redirect-only resolver, see "Group-source id vs productId" above |
| `/store-items`, `/store-items/[id]` | Variant-level browsing/detail, see above | | `/store-items`, `/store-items/[id]` | Variant-level browsing/detail, see above |
| `/blog`, `/blog/[slug]` | Blog listing/detail | | `/blog`, `/blog/[slug]` | Blog listing/detail |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

+7 -3
View File
@@ -27,10 +27,14 @@ const syne = Syne({
weight: ["600", "700", "800"], weight: ["600", "700", "800"],
}); });
export const metadata: Metadata = { export async function generateMetadata(): Promise<Metadata> {
const businessInfo = await getBusinessInfo();
return {
title: "سانی‌هوم | فروشگاه لوازم خانگی", title: "سانی‌هوم | فروشگاه لوازم خانگی",
description: "فروشگاه آنلاین لوازم خانگی — یخچال، لباسشویی، تلویزیون و بیشتر", description: "فروشگاه آنلاین لوازم خانگی — یخچال، لباسشویی، تلویزیون و بیشتر",
}; icons: businessInfo?.faviconUrl ? [{ url: businessInfo.faviconUrl }] : undefined,
};
}
export default async function RootLayout({ children }: LayoutProps<"/">) { export default async function RootLayout({ children }: LayoutProps<"/">) {
const [categoryTree, categories, businessInfo] = await Promise.all([ const [categoryTree, categories, businessInfo] = await Promise.all([
@@ -48,7 +52,7 @@ export default async function RootLayout({ children }: LayoutProps<"/">) {
<body className="min-h-full flex flex-col"> <body className="min-h-full flex flex-col">
<Providers> <Providers>
<TopBar /> <TopBar />
<Header categoryTree={categoryTree} categories={categories} /> <Header categoryTree={categoryTree} categories={categories} logoUrl={businessInfo?.logoUrl} />
<main>{children}</main> <main>{children}</main>
<Footer businessInfo={businessInfo} categories={categories} /> <Footer businessInfo={businessInfo} categories={categories} />
</Providers> </Providers>
+10 -30
View File
@@ -1,19 +1,13 @@
import { FeatureBar } from "@/components/home/FeatureBar"; import { FeatureBar } from "@/components/home/FeatureBar";
import { HeroSlider } from "@/components/home/HeroSlider"; import { HeroSlider } from "@/components/home/HeroSlider";
import { FeaturedCategories } from "@/components/home/FeaturedCategories"; import { FeaturedCategories } from "@/components/home/FeaturedCategories";
import { SpecialSaleCarousel } from "@/components/home/SpecialSaleCarousel"; import { StoreSpecialCarousels } from "@/components/home/StoreSpecialCarousels";
import { TwoBanners } from "@/components/home/TwoBanners";
import { TopSellingCarousel } from "@/components/home/TopSellingCarousel";
import { ThreeBanners } from "@/components/home/ThreeBanners";
import { BrandGroupCarousels } from "@/components/home/BrandGroupCarousels";
import { AboutSanihome } from "@/components/home/AboutSanihome"; import { AboutSanihome } from "@/components/home/AboutSanihome";
import { BlogCarousel } from "@/components/home/BlogCarousel"; import { BlogCarousel } from "@/components/home/BlogCarousel";
import { import {
getBlogs, getBlogs,
getBusinessInfo, getBusinessInfo,
getCategories, getCategories,
getProducts,
getBrandGroups,
getStoreSpecials, getStoreSpecials,
getStaticImageSlots, getStaticImageSlots,
imagesForSlot, imagesForSlot,
@@ -21,26 +15,15 @@ import {
} from "@/lib/meshkee"; } from "@/lib/meshkee";
export default async function Home() { export default async function Home() {
const [ const [categories, storeSpecials, staticImageSlots, businessInfo, blogsResult] = await Promise.all([
categories,
storeSpecials,
productsResult,
brandGroups,
staticImageSlots,
businessInfo,
blogsResult,
] = await Promise.all([
getCategories(), getCategories(),
getStoreSpecials(), getStoreSpecials(),
getProducts({ pageSize: 12 }),
getBrandGroups(),
getStaticImageSlots(), getStaticImageSlots(),
getBusinessInfo(), getBusinessInfo(),
getBlogs({ pageSize: 8 }), getBlogs({ pageSize: 8 }),
]); ]);
const contactPhone = businessInfo?.phoneNumbers[0]; const contactPhone = businessInfo?.phoneNumbers[0];
const specialSaleProducts = storeSpecials[0]?.items ?? [];
const twoBannerSlot = staticImageSlots.find((slot) => slot.key === STATIC_IMAGE_KEYS.twoBanner); const twoBannerSlot = staticImageSlots.find((slot) => slot.key === STATIC_IMAGE_KEYS.twoBanner);
const threeBannerSlot = staticImageSlots.find( const threeBannerSlot = staticImageSlots.find(
(slot) => slot.key === STATIC_IMAGE_KEYS.threeBannerRow, (slot) => slot.key === STATIC_IMAGE_KEYS.threeBannerRow,
@@ -51,19 +34,16 @@ export default async function Home() {
return ( return (
<> <>
<HeroSlider images={heroImages} /> <HeroSlider images={heroImages} />
<SpecialSaleCarousel products={specialSaleProducts} contactPhone={contactPhone} />
<FeatureBar />
<FeaturedCategories categories={categories} images={featuredCategoryImages} /> <FeaturedCategories categories={categories} images={featuredCategoryImages} />
<TwoBanners <StoreSpecialCarousels
images={imagesForSlot(staticImageSlots, STATIC_IMAGE_KEYS.twoBanner)} groups={storeSpecials}
aspectRatio={twoBannerSlot?.aspectRatio} contactPhone={contactPhone}
twoBannerImages={imagesForSlot(staticImageSlots, STATIC_IMAGE_KEYS.twoBanner)}
twoBannerAspectRatio={twoBannerSlot?.aspectRatio}
threeBannerImages={imagesForSlot(staticImageSlots, STATIC_IMAGE_KEYS.threeBannerRow)}
threeBannerAspectRatio={threeBannerSlot?.aspectRatio}
/> />
<TopSellingCarousel products={productsResult.items} contactPhone={contactPhone} /> <FeatureBar />
<ThreeBanners
images={imagesForSlot(staticImageSlots, STATIC_IMAGE_KEYS.threeBannerRow)}
aspectRatio={threeBannerSlot?.aspectRatio}
/>
<BrandGroupCarousels brandGroups={brandGroups} contactPhone={contactPhone} />
<AboutSanihome /> <AboutSanihome />
<BlogCarousel posts={blogsResult.items} /> <BlogCarousel posts={blogsResult.items} />
</> </>
+49 -1
View File
@@ -6,6 +6,7 @@ import {
getBusinessInfo, getBusinessInfo,
getCategoryTree, getCategoryTree,
getProducts, getProducts,
getStoreSpecials,
productHref, productHref,
} from "@/lib/meshkee"; } from "@/lib/meshkee";
@@ -25,10 +26,57 @@ function findCategoryLabel(tree: CategoryTreeGroup[], id: string): string | unde
export default async function ProductsPage({ export default async function ProductsPage({
searchParams, searchParams,
}: { }: {
searchParams: Promise<{ categoryId?: string; q?: string; page?: string }>; searchParams: Promise<{ categoryId?: string; q?: string; page?: string; specialId?: string }>;
}) { }) {
const params = await searchParams; const params = await searchParams;
const page = Math.max(1, Number(params.page ?? 1) || 1); const page = Math.max(1, Number(params.page ?? 1) || 1);
// "مشاهده همه" from a homepage carousel — those are admin-curated,
// already-complete lists (GET /store-specials has no pagination), so show
// that exact group's items rather than re-querying the general catalog.
if (params.specialId) {
const groups = await getStoreSpecials();
const group = groups.find((g) => g.id === params.specialId);
const businessInfo = await getBusinessInfo();
const contactPhone = businessInfo?.phoneNumbers[0];
const items = group?.items ?? [];
return (
<div className="mx-auto max-w-7xl px-4 py-10">
<Breadcrumb
items={[
{ label: "خانه", href: "/" },
{ label: "محصولات", href: "/products" },
{ label: group?.title ?? "کروسل" },
]}
/>
<h1 className="mb-6 text-2xl font-bold text-red-dark">{group?.title ?? "محصولات"}</h1>
{items.length === 0 ? (
<EmptyState message="محصولی یافت نشد." />
) : (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
{items.map((product) => (
<ProductCard
key={product.id}
name={product.name}
brand={product.brand ?? ""}
price={product.price}
oldPrice={product.oldPrice}
discount={product.discount}
image={product.image ?? "/images/placeholder.svg"}
href={productHref(product)}
fluid
contactPhone={contactPhone}
variantId={product.variantId}
slug={product.slug}
/>
))}
</div>
)}
</div>
);
}
const [{ items, total }, businessInfo, categoryTree] = await Promise.all([ const [{ items, total }, businessInfo, categoryTree] = await Promise.all([
getProducts({ getProducts({
categoryId: params.categoryId, categoryId: params.categoryId,
+1
View File
@@ -17,6 +17,7 @@ export function BlogCarousel({ posts }: { posts: BlogSummary[] }) {
title="آخرین مطالب وبلاگ" title="آخرین مطالب وبلاگ"
enTitle="Latest Blog" enTitle="Latest Blog"
action="وبلاگ" action="وبلاگ"
actionHref="/blog"
/> />
{posts.length === 0 ? ( {posts.length === 0 ? (
<EmptyState message="هنوز مطلبی ثبت نشده است." /> <EmptyState message="هنوز مطلبی ثبت نشده است." />
@@ -1,54 +0,0 @@
import { Carousel } from "@/components/ui/Carousel";
import { ProductCard } from "@/components/ui/ProductCard";
import { SectionTitle } from "@/components/ui/SectionTitle";
import { EmptyState } from "@/components/ui/EmptyState";
import { productHref, type ProductGroup } from "@/lib/meshkee";
export function BrandGroupCarousels({
brandGroups,
contactPhone,
}: {
brandGroups: ProductGroup[];
contactPhone?: string;
}) {
if (brandGroups.length === 0) {
return (
<section className="py-10 md:py-14">
<div className="mx-auto max-w-7xl px-4">
<SectionTitle title="محصولات برندها" enTitle="Brand Collections" />
<EmptyState message="هنوز محصولی برای برندها ثبت نشده است." />
</div>
</section>
);
}
return (
<>
{brandGroups.map((group) => (
<section key={group.id} className="py-10 md:py-14">
<div className="mx-auto max-w-7xl px-4">
<SectionTitle title={group.title} enTitle={group.title} action="مشاهده همه" />
<Carousel ariaLabel={`کاروسل ${group.title}`}>
{group.items.map((product) => (
<ProductCard
key={product.id}
name={product.name}
brand={product.brand ?? ""}
price={product.price}
oldPrice={product.oldPrice}
discount={product.discount}
image={product.image ?? "/images/placeholder.svg"}
variant="brand"
href={productHref(product)}
contactPhone={contactPhone}
variantId={product.variantId}
slug={product.slug}
/>
))}
</Carousel>
</div>
</section>
))}
</>
);
}
+1 -7
View File
@@ -4,7 +4,6 @@ import { Swiper, SwiperSlide } from "swiper/react";
import { FreeMode } from "swiper/modules"; import { FreeMode } from "swiper/modules";
import "swiper/css"; import "swiper/css";
import "swiper/css/free-mode"; import "swiper/css/free-mode";
import { SectionTitle } from "@/components/ui/SectionTitle";
import { EmptyState } from "@/components/ui/EmptyState"; import { EmptyState } from "@/components/ui/EmptyState";
import type { Category, StaticImageWithUrl } from "@/lib/meshkee"; import type { Category, StaticImageWithUrl } from "@/lib/meshkee";
@@ -34,13 +33,8 @@ export function FeaturedCategories({
images?: StaticImageWithUrl[]; images?: StaticImageWithUrl[];
}) { }) {
return ( return (
<section className="py-10 md:py-14"> <section className="py-4 md:py-6">
<div className="mx-auto max-w-7xl px-4"> <div className="mx-auto max-w-7xl px-4">
<SectionTitle
title="دسته‌بندی‌های پرطرفدار"
enTitle="Featured Categories"
action="همه دسته‌ها"
/>
{images.length > 0 ? ( {images.length > 0 ? (
<Swiper <Swiper
modules={[FreeMode]} modules={[FreeMode]}
+9 -3
View File
@@ -32,12 +32,18 @@ export function Footer({
<div className="grid gap-8 md:grid-cols-4"> <div className="grid gap-8 md:grid-cols-4">
<div> <div>
<div className="mb-4 flex items-center gap-2"> <div className="mb-4 flex items-center gap-2">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-white text-base font-bold text-red-dark"> {businessInfo?.logoDarkUrl || businessInfo?.logoUrl ? (
S // eslint-disable-next-line @next/next/no-img-element
</div> <img
src={businessInfo.logoDarkUrl ?? businessInfo.logoUrl ?? undefined}
alt={businessInfo.nameFa || "سانی‌هوم"}
className="h-9 w-auto object-contain"
/>
) : (
<span className="font-display text-lg font-bold tracking-tight"> <span className="font-display text-lg font-bold tracking-tight">
SANIHOME SANIHOME
</span> </span>
)}
</div> </div>
{businessInfo?.about && ( {businessInfo?.about && (
<p className="text-sm leading-relaxed text-white/70">{businessInfo.about}</p> <p className="text-sm leading-relaxed text-white/70">{businessInfo.about}</p>
+11 -6
View File
@@ -17,17 +17,17 @@ const navLinks = [
const LOGIN_URL = "https://customer.sanihome.ir"; const LOGIN_URL = "https://customer.sanihome.ir";
function Logo({ large = false }: { large?: boolean }) { function Logo({ src, large = false }: { src?: string | null; large?: boolean }) {
return ( return (
<a href="/" className="flex shrink-0 items-center"> <a href="/" className="flex shrink-0 items-center">
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src="/images/logo.png" src={src || "/images/logo.png"}
alt="سانی‌هوم" alt="سانی‌هوم"
className={ className={
large large
? "h-[42px] w-auto object-contain md:h-[46px]" ? "h-[42px] w-auto object-contain md:h-[46px]"
: "h-8 w-auto md:h-9" : "h-20 w-auto object-contain md:h-24"
} }
/> />
</a> </a>
@@ -56,10 +56,12 @@ function MobileDrawer({
open, open,
onClose, onClose,
categoryTree, categoryTree,
logoUrl,
}: { }: {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
categoryTree: CategoryTreeGroup[]; categoryTree: CategoryTreeGroup[];
logoUrl?: string | null;
}) { }) {
const [categoriesOpen, setCategoriesOpen] = useState(false); const [categoriesOpen, setCategoriesOpen] = useState(false);
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
@@ -100,7 +102,7 @@ function MobileDrawer({
}`} }`}
> >
<div className="flex items-center justify-between border-b border-border p-5"> <div className="flex items-center justify-between border-b border-border p-5">
<Logo /> <Logo src={logoUrl} />
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
@@ -199,9 +201,11 @@ function MobileDrawer({
export function Header({ export function Header({
categoryTree, categoryTree,
categories, categories,
logoUrl,
}: { }: {
categoryTree: CategoryTreeGroup[]; categoryTree: CategoryTreeGroup[];
categories: Category[]; categories: Category[];
logoUrl?: string | null;
}) { }) {
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
@@ -232,13 +236,13 @@ export function Header({
<span aria-hidden>☰</span> <span aria-hidden>☰</span>
</button> </button>
<div className="hidden min-[1200px]:block"> <div className="hidden min-[1200px]:block">
<Logo /> <Logo src={logoUrl} />
</div> </div>
</div> </div>
<div className="flex items-center justify-center min-[1200px]:flex-1 min-[1200px]:px-6"> <div className="flex items-center justify-center min-[1200px]:flex-1 min-[1200px]:px-6">
<div className="min-[1200px]:hidden"> <div className="min-[1200px]:hidden">
<Logo large /> <Logo src={logoUrl} large />
</div> </div>
<div className="hidden w-full min-[1200px]:block"> <div className="hidden w-full min-[1200px]:block">
<SearchBar /> <SearchBar />
@@ -360,6 +364,7 @@ export function Header({
open={drawerOpen} open={drawerOpen}
onClose={() => setDrawerOpen(false)} onClose={() => setDrawerOpen(false)}
categoryTree={categoryTree} categoryTree={categoryTree}
logoUrl={logoUrl}
/> />
</header> </header>
); );
@@ -1,65 +0,0 @@
import { Carousel } from "@/components/ui/Carousel";
import { Placeholder } from "@/components/ui/Placeholder";
import { ProductCard } from "@/components/ui/ProductCard";
import { SectionTitle } from "@/components/ui/SectionTitle";
import { EmptyState } from "@/components/ui/EmptyState";
import { productHref, type Product } from "@/lib/meshkee";
function SpecialSaleBanner() {
return (
<a
href="#"
className="relative w-[220px] shrink-0 self-stretch overflow-hidden rounded-xl border border-border md:w-[240px]"
>
<Placeholder
src="/images/sale-banner.svg"
alt="فروش ویژه"
width="100%"
className="h-full min-h-[380px] w-full rounded-none border-0"
/>
</a>
);
}
export function SpecialSaleCarousel({
products,
contactPhone,
}: {
products: Product[];
contactPhone?: string;
}) {
return (
<section className="border-y border-red-light bg-red-light/30 py-10 md:py-14">
<div className="mx-auto max-w-7xl px-4">
<SectionTitle
title="فروش ویژه"
enTitle="Special Sale"
action="مشاهده همه"
/>
{products.length === 0 ? (
<EmptyState message="هنوز محصولی برای فروش ویژه ثبت نشده است." />
) : (
<Carousel ariaLabel="کاروسل فروش ویژه">
<SpecialSaleBanner />
{products.map((product) => (
<ProductCard
key={product.id}
name={product.name}
brand={product.brand ?? ""}
price={product.price}
oldPrice={product.oldPrice}
discount={product.discount}
image={product.image ?? "/images/placeholder.svg"}
variant="sale"
href={productHref(product)}
contactPhone={contactPhone}
variantId={product.variantId}
slug={product.slug}
/>
))}
</Carousel>
)}
</div>
</section>
);
}
@@ -0,0 +1,88 @@
import { Fragment } from "react";
import { Carousel } from "@/components/ui/Carousel";
import { ProductCard } from "@/components/ui/ProductCard";
import { SectionTitle } from "@/components/ui/SectionTitle";
import { TwoBanners } from "@/components/home/TwoBanners";
import { ThreeBanners } from "@/components/home/ThreeBanners";
import { productHref, type ProductGroup, type StaticImageWithUrl } from "@/lib/meshkee";
// The API only gives one `title` string per group (sometimes already
// English, e.g. "Special Sale", sometimes Persian) — there's no separate
// localized English field to pull a real translation from, and inventing
// one would be fabricated data. `key` is real data and usually a readable
// English slug (e.g. "top-selling"), so it's what gets formatted into the
// English caption under the title. Skip it if the key has no letters at
// all (e.g. it fell back to a bare numeric id).
function englishLabelFromKey(key: string): string | undefined {
const label = key.replace(/[-_]+/g, " ").trim();
return /[a-zA-Z]/.test(label) ? label.toUpperCase() : undefined;
}
// Renders every admin-managed carousel from GET /store-specials — whatever
// groups exist there, in their configured order. Nothing here is keyed off
// a specific group id/key/title, so a new carousel created in the Meshkee
// dashboard (any name) shows up on the homepage automatically, with no code
// change. Groups with no items are skipped (honest empty state — no heading
// over nothing).
//
// One of the two banner sections is inserted between every pair of
// carousels (never before the first or after the last), cycling if there
// end up being more than two carousels.
export function StoreSpecialCarousels({
groups,
contactPhone,
twoBannerImages,
twoBannerAspectRatio,
threeBannerImages,
threeBannerAspectRatio,
}: {
groups: ProductGroup[];
contactPhone?: string;
twoBannerImages?: StaticImageWithUrl[];
twoBannerAspectRatio?: string;
threeBannerImages?: StaticImageWithUrl[];
threeBannerAspectRatio?: string;
}) {
const visibleGroups = groups.filter((group) => group.items.length > 0);
const banners = [
<TwoBanners key="two" images={twoBannerImages} aspectRatio={twoBannerAspectRatio} />,
<ThreeBanners key="three" images={threeBannerImages} aspectRatio={threeBannerAspectRatio} />,
];
return (
<>
{visibleGroups.map((group, i) => (
<Fragment key={group.id}>
{i > 0 && banners[(i - 1) % banners.length]}
<section className="py-10 md:py-14">
<div className="mx-auto max-w-7xl px-4">
<SectionTitle
title={group.title}
enSubtitle={englishLabelFromKey(group.key)}
action="مشاهده همه"
actionHref={`/products?specialId=${group.id}`}
/>
<Carousel ariaLabel={`کاروسل ${group.title}`}>
{group.items.map((product) => (
<ProductCard
key={product.id}
name={product.name}
brand={product.brand ?? ""}
price={product.price}
oldPrice={product.oldPrice}
discount={product.discount}
image={product.image ?? "/images/placeholder.svg"}
href={productHref(product)}
contactPhone={contactPhone}
variantId={product.variantId}
slug={product.slug}
/>
))}
</Carousel>
</div>
</section>
</Fragment>
))}
</>
);
}
@@ -1,46 +0,0 @@
import { Carousel } from "@/components/ui/Carousel";
import { ProductCard } from "@/components/ui/ProductCard";
import { SectionTitle } from "@/components/ui/SectionTitle";
import { EmptyState } from "@/components/ui/EmptyState";
import { productHref, type Product } from "@/lib/meshkee";
export function TopSellingCarousel({
products,
contactPhone,
}: {
products: Product[];
contactPhone?: string;
}) {
return (
<section className="py-10 md:py-14">
<div className="mx-auto max-w-7xl px-4">
<SectionTitle
title="پرفروش‌ترین‌ها"
enTitle="Top Selling"
action="مشاهده همه"
/>
{products.length === 0 ? (
<EmptyState message="هنوز محصولی ثبت نشده است." />
) : (
<Carousel ariaLabel="کاروسل پرفروش‌ترین‌ها">
{products.map((product) => (
<ProductCard
key={product.id}
name={product.name}
brand={product.brand ?? ""}
price={product.price}
oldPrice={product.oldPrice}
discount={product.discount}
image={product.image ?? "/images/placeholder.svg"}
href={productHref(product)}
contactPhone={contactPhone}
variantId={product.variantId}
slug={product.slug}
/>
))}
</Carousel>
)}
</div>
</section>
);
}
+28 -6
View File
@@ -1,16 +1,28 @@
type SectionTitleProps = { type SectionTitleProps = {
title: string; title: string;
enTitle: string; enTitle?: string;
// Shown on its own line under the title (not inline like enTitle), at 50%
// opacity — used for the homepage's dynamic store-specials carousels.
enSubtitle?: string;
subtitle?: string; subtitle?: string;
action?: string; action?: string;
actionHref?: string;
}; };
export function SectionTitle({ title, enTitle, subtitle, action }: SectionTitleProps) { export function SectionTitle({
title,
enTitle,
enSubtitle,
subtitle,
action,
actionHref,
}: SectionTitleProps) {
return ( return (
<div className="mb-6 flex items-end justify-between gap-4"> <div className="mb-6 flex items-end justify-between gap-4">
<div> <div>
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1"> <div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<h2 className="text-xl font-bold text-red-dark md:text-2xl">{title}</h2> <h2 className="text-xl font-bold text-red-dark md:text-2xl">{title}</h2>
{enTitle && (
<span <span
className="font-display text-sm font-semibold tracking-wide text-red-dark/30 uppercase md:text-base" className="font-display text-sm font-semibold tracking-wide text-red-dark/30 uppercase md:text-base"
lang="en" lang="en"
@@ -18,18 +30,28 @@ export function SectionTitle({ title, enTitle, subtitle, action }: SectionTitleP
> >
{enTitle} {enTitle}
</span> </span>
)}
</div> </div>
{enSubtitle && (
<p
className="font-display mt-1 text-xs font-semibold tracking-wide text-foreground uppercase opacity-50 md:text-sm"
lang="en"
dir="ltr"
>
{enSubtitle}
</p>
)}
{subtitle && ( {subtitle && (
<p className="mt-1 text-sm text-muted">{subtitle}</p> <p className="mt-1 text-sm text-muted">{subtitle}</p>
)} )}
</div> </div>
{action && ( {action && actionHref && (
<button <a
type="button" href={actionHref}
className="shrink-0 text-sm font-medium text-red transition-colors hover:text-red-dark" className="shrink-0 text-sm font-medium text-red transition-colors hover:text-red-dark"
> >
{action} ← {action} ←
</button> </a>
)} )}
</div> </div>
); );
+11 -9
View File
@@ -51,6 +51,7 @@ export type BusinessInfo = {
about: string; about: string;
vision: string; vision: string;
logoUrl: string | null; logoUrl: string | null;
logoDarkUrl: string | null;
faviconUrl: string | null; faviconUrl: string | null;
emails: string[]; emails: string[];
phoneNumbers: string[]; phoneNumbers: string[];
@@ -490,27 +491,28 @@ export type ProductGroup = {
id: string; id: string;
key: string; key: string;
title: string; title: string;
sortOrder: number;
items: Product[]; items: Product[];
}; };
function normalizeProductGroups(items: Record<string, unknown>[] | undefined): ProductGroup[] { function normalizeProductGroups(items: Record<string, unknown>[] | undefined): ProductGroup[] {
return (items ?? []).map((group) => ({ return (items ?? [])
.map((group) => ({
id: String(group.id ?? group.key ?? ""), id: String(group.id ?? group.key ?? ""),
key: String(group.key ?? group.id ?? ""), key: String(group.key ?? group.id ?? ""),
title: String(group.title ?? ""), title: String(group.title ?? ""),
sortOrder: Number(group.sortOrder ?? 0),
items: Array.isArray(group.items) items: Array.isArray(group.items)
? (group.items as Record<string, unknown>[]).map(normalizeProduct) ? (group.items as Record<string, unknown>[]).map(normalizeProduct)
: [], : [],
})); }))
} .sort((a, b) => a.sortOrder - b.sortOrder);
export async function getBrandGroups(): Promise<ProductGroup[]> {
const res = await apiGet<{ items: Record<string, unknown>[] }>(
`/tenants/${DOMAIN}/website/brand-groups`,
);
return normalizeProductGroups(res?.items);
} }
// Admin-managed, named product carousels ("Special Sale", "پرفروش‌ها",
// "بهترین‌ها از نگاه شما", ...) — the whole list, not a fixed set of known
// keys, so a new one created in the dashboard shows up on the homepage
// automatically. See StoreSpecialCarousels.tsx.
export async function getStoreSpecials(): Promise<ProductGroup[]> { export async function getStoreSpecials(): Promise<ProductGroup[]> {
const res = await apiGet<{ items: Record<string, unknown>[] }>( const res = await apiGet<{ items: Record<string, unknown>[] }>(
`/tenants/${DOMAIN}/store-specials`, `/tenants/${DOMAIN}/store-specials`,