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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
5152b16d61
commit
bc6cafaffa
+44
@@ -195,6 +195,50 @@ 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 small English-caps subtitle just doesn't render for them.
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
+9
-5
@@ -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> {
|
||||||
title: "سانیهوم | فروشگاه لوازم خانگی",
|
const businessInfo = await getBusinessInfo();
|
||||||
description: "فروشگاه آنلاین لوازم خانگی — یخچال، لباسشویی، تلویزیون و بیشتر",
|
return {
|
||||||
};
|
title: "سانیهوم | فروشگاه لوازم خانگی",
|
||||||
|
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>
|
||||||
|
|||||||
+4
-21
@@ -1,19 +1,15 @@
|
|||||||
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 { TwoBanners } from "@/components/home/TwoBanners";
|
||||||
import { TopSellingCarousel } from "@/components/home/TopSellingCarousel";
|
|
||||||
import { ThreeBanners } from "@/components/home/ThreeBanners";
|
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 +17,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 +36,17 @@ 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} />
|
||||||
|
<StoreSpecialCarousels groups={storeSpecials} contactPhone={contactPhone} />
|
||||||
|
<FeatureBar />
|
||||||
<TwoBanners
|
<TwoBanners
|
||||||
images={imagesForSlot(staticImageSlots, STATIC_IMAGE_KEYS.twoBanner)}
|
images={imagesForSlot(staticImageSlots, STATIC_IMAGE_KEYS.twoBanner)}
|
||||||
aspectRatio={twoBannerSlot?.aspectRatio}
|
aspectRatio={twoBannerSlot?.aspectRatio}
|
||||||
/>
|
/>
|
||||||
<TopSellingCarousel products={productsResult.items} contactPhone={contactPhone} />
|
|
||||||
<ThreeBanners
|
<ThreeBanners
|
||||||
images={imagesForSlot(staticImageSlots, STATIC_IMAGE_KEYS.threeBannerRow)}
|
images={imagesForSlot(staticImageSlots, STATIC_IMAGE_KEYS.threeBannerRow)}
|
||||||
aspectRatio={threeBannerSlot?.aspectRatio}
|
aspectRatio={threeBannerSlot?.aspectRatio}
|
||||||
/>
|
/>
|
||||||
<BrandGroupCarousels brandGroups={brandGroups} contactPhone={contactPhone} />
|
|
||||||
<AboutSanihome />
|
<AboutSanihome />
|
||||||
<BlogCarousel posts={blogsResult.items} />
|
<BlogCarousel posts={blogsResult.items} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
@@ -36,11 +35,6 @@ export function FeaturedCategories({
|
|||||||
return (
|
return (
|
||||||
<section className="py-10 md:py-14">
|
<section className="py-10 md:py-14">
|
||||||
<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]}
|
||||||
|
|||||||
@@ -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
|
||||||
<span className="font-display text-lg font-bold tracking-tight">
|
src={businessInfo.logoDarkUrl ?? businessInfo.logoUrl ?? undefined}
|
||||||
SANIHOME
|
alt={businessInfo.nameFa || "سانیهوم"}
|
||||||
</span>
|
className="h-9 w-auto object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="font-display text-lg font-bold tracking-tight">
|
||||||
|
SANIHOME
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -17,12 +17,12 @@ 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
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+12
-17
@@ -1,33 +1,29 @@
|
|||||||
import { Carousel } from "@/components/ui/Carousel";
|
import { Carousel } from "@/components/ui/Carousel";
|
||||||
import { ProductCard } from "@/components/ui/ProductCard";
|
import { ProductCard } from "@/components/ui/ProductCard";
|
||||||
import { SectionTitle } from "@/components/ui/SectionTitle";
|
import { SectionTitle } from "@/components/ui/SectionTitle";
|
||||||
import { EmptyState } from "@/components/ui/EmptyState";
|
|
||||||
import { productHref, type ProductGroup } from "@/lib/meshkee";
|
import { productHref, type ProductGroup } from "@/lib/meshkee";
|
||||||
|
|
||||||
export function BrandGroupCarousels({
|
// Renders every admin-managed carousel from GET /store-specials — whatever
|
||||||
brandGroups,
|
// 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).
|
||||||
|
export function StoreSpecialCarousels({
|
||||||
|
groups,
|
||||||
contactPhone,
|
contactPhone,
|
||||||
}: {
|
}: {
|
||||||
brandGroups: ProductGroup[];
|
groups: ProductGroup[];
|
||||||
contactPhone?: string;
|
contactPhone?: string;
|
||||||
}) {
|
}) {
|
||||||
if (brandGroups.length === 0) {
|
const visibleGroups = groups.filter((group) => group.items.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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{brandGroups.map((group) => (
|
{visibleGroups.map((group) => (
|
||||||
<section key={group.id} className="py-10 md:py-14">
|
<section key={group.id} className="py-10 md:py-14">
|
||||||
<div className="mx-auto max-w-7xl px-4">
|
<div className="mx-auto max-w-7xl px-4">
|
||||||
<SectionTitle title={group.title} enTitle={group.title} action="مشاهده همه" />
|
<SectionTitle title={group.title} />
|
||||||
<Carousel ariaLabel={`کاروسل ${group.title}`}>
|
<Carousel ariaLabel={`کاروسل ${group.title}`}>
|
||||||
{group.items.map((product) => (
|
{group.items.map((product) => (
|
||||||
<ProductCard
|
<ProductCard
|
||||||
@@ -38,7 +34,6 @@ export function BrandGroupCarousels({
|
|||||||
oldPrice={product.oldPrice}
|
oldPrice={product.oldPrice}
|
||||||
discount={product.discount}
|
discount={product.discount}
|
||||||
image={product.image ?? "/images/placeholder.svg"}
|
image={product.image ?? "/images/placeholder.svg"}
|
||||||
variant="brand"
|
|
||||||
href={productHref(product)}
|
href={productHref(product)}
|
||||||
contactPhone={contactPhone}
|
contactPhone={contactPhone}
|
||||||
variantId={product.variantId}
|
variantId={product.variantId}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
type SectionTitleProps = {
|
type SectionTitleProps = {
|
||||||
title: string;
|
title: string;
|
||||||
enTitle: string;
|
enTitle?: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
action?: string;
|
action?: string;
|
||||||
};
|
};
|
||||||
@@ -11,13 +11,15 @@ export function SectionTitle({ title, enTitle, subtitle, action }: SectionTitleP
|
|||||||
<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>
|
||||||
<span
|
{enTitle && (
|
||||||
className="font-display text-sm font-semibold tracking-wide text-red-dark/30 uppercase md:text-base"
|
<span
|
||||||
lang="en"
|
className="font-display text-sm font-semibold tracking-wide text-red-dark/30 uppercase md:text-base"
|
||||||
dir="ltr"
|
lang="en"
|
||||||
>
|
dir="ltr"
|
||||||
{enTitle}
|
>
|
||||||
</span>
|
{enTitle}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{subtitle && (
|
{subtitle && (
|
||||||
<p className="mt-1 text-sm text-muted">{subtitle}</p>
|
<p className="mt-1 text-sm text-muted">{subtitle}</p>
|
||||||
|
|||||||
+17
-15
@@ -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 ?? [])
|
||||||
id: String(group.id ?? group.key ?? ""),
|
.map((group) => ({
|
||||||
key: String(group.key ?? group.id ?? ""),
|
id: String(group.id ?? group.key ?? ""),
|
||||||
title: String(group.title ?? ""),
|
key: String(group.key ?? group.id ?? ""),
|
||||||
items: Array.isArray(group.items)
|
title: String(group.title ?? ""),
|
||||||
? (group.items as Record<string, unknown>[]).map(normalizeProduct)
|
sortOrder: Number(group.sortOrder ?? 0),
|
||||||
: [],
|
items: Array.isArray(group.items)
|
||||||
}));
|
? (group.items as Record<string, unknown>[]).map(normalizeProduct)
|
||||||
}
|
: [],
|
||||||
|
}))
|
||||||
export async function getBrandGroups(): Promise<ProductGroup[]> {
|
.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||||
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`,
|
||||||
|
|||||||
Reference in New Issue
Block a user