Compare commits
3
Commits
5152b16d61
...
0de5abb879
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0de5abb879 | ||
|
|
7c04316f3a | ||
|
|
bc6cafaffa |
+69
-1
@@ -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,
|
||||
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 | Notes |
|
||||
|---|---|
|
||||
| `/` | 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 |
|
||||
| `/store-items`, `/store-items/[id]` | Variant-level browsing/detail, see above |
|
||||
| `/blog`, `/blog/[slug]` | Blog listing/detail |
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
+6
-2
@@ -27,10 +27,14 @@ const syne = Syne({
|
||||
weight: ["600", "700", "800"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const businessInfo = await getBusinessInfo();
|
||||
return {
|
||||
title: "سانیهوم | فروشگاه لوازم خانگی",
|
||||
description: "فروشگاه آنلاین لوازم خانگی — یخچال، لباسشویی، تلویزیون و بیشتر",
|
||||
icons: businessInfo?.faviconUrl ? [{ url: businessInfo.faviconUrl }] : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function RootLayout({ children }: LayoutProps<"/">) {
|
||||
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">
|
||||
<Providers>
|
||||
<TopBar />
|
||||
<Header categoryTree={categoryTree} categories={categories} />
|
||||
<Header categoryTree={categoryTree} categories={categories} logoUrl={businessInfo?.logoUrl} />
|
||||
<main>{children}</main>
|
||||
<Footer businessInfo={businessInfo} categories={categories} />
|
||||
</Providers>
|
||||
|
||||
+10
-30
@@ -1,19 +1,13 @@
|
||||
import { FeatureBar } from "@/components/home/FeatureBar";
|
||||
import { HeroSlider } from "@/components/home/HeroSlider";
|
||||
import { FeaturedCategories } from "@/components/home/FeaturedCategories";
|
||||
import { SpecialSaleCarousel } from "@/components/home/SpecialSaleCarousel";
|
||||
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 { StoreSpecialCarousels } from "@/components/home/StoreSpecialCarousels";
|
||||
import { AboutSanihome } from "@/components/home/AboutSanihome";
|
||||
import { BlogCarousel } from "@/components/home/BlogCarousel";
|
||||
import {
|
||||
getBlogs,
|
||||
getBusinessInfo,
|
||||
getCategories,
|
||||
getProducts,
|
||||
getBrandGroups,
|
||||
getStoreSpecials,
|
||||
getStaticImageSlots,
|
||||
imagesForSlot,
|
||||
@@ -21,26 +15,15 @@ import {
|
||||
} from "@/lib/meshkee";
|
||||
|
||||
export default async function Home() {
|
||||
const [
|
||||
categories,
|
||||
storeSpecials,
|
||||
productsResult,
|
||||
brandGroups,
|
||||
staticImageSlots,
|
||||
businessInfo,
|
||||
blogsResult,
|
||||
] = await Promise.all([
|
||||
const [categories, storeSpecials, staticImageSlots, businessInfo, blogsResult] = await Promise.all([
|
||||
getCategories(),
|
||||
getStoreSpecials(),
|
||||
getProducts({ pageSize: 12 }),
|
||||
getBrandGroups(),
|
||||
getStaticImageSlots(),
|
||||
getBusinessInfo(),
|
||||
getBlogs({ pageSize: 8 }),
|
||||
]);
|
||||
|
||||
const contactPhone = businessInfo?.phoneNumbers[0];
|
||||
const specialSaleProducts = storeSpecials[0]?.items ?? [];
|
||||
const twoBannerSlot = staticImageSlots.find((slot) => slot.key === STATIC_IMAGE_KEYS.twoBanner);
|
||||
const threeBannerSlot = staticImageSlots.find(
|
||||
(slot) => slot.key === STATIC_IMAGE_KEYS.threeBannerRow,
|
||||
@@ -51,19 +34,16 @@ export default async function Home() {
|
||||
return (
|
||||
<>
|
||||
<HeroSlider images={heroImages} />
|
||||
<SpecialSaleCarousel products={specialSaleProducts} contactPhone={contactPhone} />
|
||||
<FeatureBar />
|
||||
<FeaturedCategories categories={categories} images={featuredCategoryImages} />
|
||||
<TwoBanners
|
||||
images={imagesForSlot(staticImageSlots, STATIC_IMAGE_KEYS.twoBanner)}
|
||||
aspectRatio={twoBannerSlot?.aspectRatio}
|
||||
<StoreSpecialCarousels
|
||||
groups={storeSpecials}
|
||||
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} />
|
||||
<ThreeBanners
|
||||
images={imagesForSlot(staticImageSlots, STATIC_IMAGE_KEYS.threeBannerRow)}
|
||||
aspectRatio={threeBannerSlot?.aspectRatio}
|
||||
/>
|
||||
<BrandGroupCarousels brandGroups={brandGroups} contactPhone={contactPhone} />
|
||||
<FeatureBar />
|
||||
<AboutSanihome />
|
||||
<BlogCarousel posts={blogsResult.items} />
|
||||
</>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
getBusinessInfo,
|
||||
getCategoryTree,
|
||||
getProducts,
|
||||
getStoreSpecials,
|
||||
productHref,
|
||||
} from "@/lib/meshkee";
|
||||
|
||||
@@ -25,10 +26,57 @@ function findCategoryLabel(tree: CategoryTreeGroup[], id: string): string | unde
|
||||
export default async function ProductsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ categoryId?: string; q?: string; page?: string }>;
|
||||
searchParams: Promise<{ categoryId?: string; q?: string; page?: string; specialId?: string }>;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
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([
|
||||
getProducts({
|
||||
categoryId: params.categoryId,
|
||||
|
||||
@@ -17,6 +17,7 @@ export function BlogCarousel({ posts }: { posts: BlogSummary[] }) {
|
||||
title="آخرین مطالب وبلاگ"
|
||||
enTitle="Latest Blog"
|
||||
action="وبلاگ"
|
||||
actionHref="/blog"
|
||||
/>
|
||||
{posts.length === 0 ? (
|
||||
<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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { Swiper, SwiperSlide } from "swiper/react";
|
||||
import { FreeMode } from "swiper/modules";
|
||||
import "swiper/css";
|
||||
import "swiper/css/free-mode";
|
||||
import { SectionTitle } from "@/components/ui/SectionTitle";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import type { Category, StaticImageWithUrl } from "@/lib/meshkee";
|
||||
|
||||
@@ -34,13 +33,8 @@ export function FeaturedCategories({
|
||||
images?: StaticImageWithUrl[];
|
||||
}) {
|
||||
return (
|
||||
<section className="py-10 md:py-14">
|
||||
<section className="py-4 md:py-6">
|
||||
<div className="mx-auto max-w-7xl px-4">
|
||||
<SectionTitle
|
||||
title="دستهبندیهای پرطرفدار"
|
||||
enTitle="Featured Categories"
|
||||
action="همه دستهها"
|
||||
/>
|
||||
{images.length > 0 ? (
|
||||
<Swiper
|
||||
modules={[FreeMode]}
|
||||
|
||||
@@ -32,12 +32,18 @@ export function Footer({
|
||||
<div className="grid gap-8 md:grid-cols-4">
|
||||
<div>
|
||||
<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">
|
||||
S
|
||||
</div>
|
||||
{businessInfo?.logoDarkUrl || businessInfo?.logoUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<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">
|
||||
SANIHOME
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{businessInfo?.about && (
|
||||
<p className="text-sm leading-relaxed text-white/70">{businessInfo.about}</p>
|
||||
|
||||
@@ -17,17 +17,17 @@ const navLinks = [
|
||||
|
||||
const LOGIN_URL = "https://customer.sanihome.ir";
|
||||
|
||||
function Logo({ large = false }: { large?: boolean }) {
|
||||
function Logo({ src, large = false }: { src?: string | null; large?: boolean }) {
|
||||
return (
|
||||
<a href="/" className="flex shrink-0 items-center">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src="/images/logo.png"
|
||||
src={src || "/images/logo.png"}
|
||||
alt="سانیهوم"
|
||||
className={
|
||||
large
|
||||
? "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>
|
||||
@@ -56,10 +56,12 @@ function MobileDrawer({
|
||||
open,
|
||||
onClose,
|
||||
categoryTree,
|
||||
logoUrl,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
categoryTree: CategoryTreeGroup[];
|
||||
logoUrl?: string | null;
|
||||
}) {
|
||||
const [categoriesOpen, setCategoriesOpen] = 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">
|
||||
<Logo />
|
||||
<Logo src={logoUrl} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
@@ -199,9 +201,11 @@ function MobileDrawer({
|
||||
export function Header({
|
||||
categoryTree,
|
||||
categories,
|
||||
logoUrl,
|
||||
}: {
|
||||
categoryTree: CategoryTreeGroup[];
|
||||
categories: Category[];
|
||||
logoUrl?: string | null;
|
||||
}) {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
@@ -232,13 +236,13 @@ export function Header({
|
||||
<span aria-hidden>☰</span>
|
||||
</button>
|
||||
<div className="hidden min-[1200px]:block">
|
||||
<Logo />
|
||||
<Logo src={logoUrl} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center min-[1200px]:flex-1 min-[1200px]:px-6">
|
||||
<div className="min-[1200px]:hidden">
|
||||
<Logo large />
|
||||
<Logo src={logoUrl} large />
|
||||
</div>
|
||||
<div className="hidden w-full min-[1200px]:block">
|
||||
<SearchBar />
|
||||
@@ -360,6 +364,7 @@ export function Header({
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
categoryTree={categoryTree}
|
||||
logoUrl={logoUrl}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,28 @@
|
||||
type SectionTitleProps = {
|
||||
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;
|
||||
action?: string;
|
||||
actionHref?: string;
|
||||
};
|
||||
|
||||
export function SectionTitle({ title, enTitle, subtitle, action }: SectionTitleProps) {
|
||||
export function SectionTitle({
|
||||
title,
|
||||
enTitle,
|
||||
enSubtitle,
|
||||
subtitle,
|
||||
action,
|
||||
actionHref,
|
||||
}: SectionTitleProps) {
|
||||
return (
|
||||
<div className="mb-6 flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<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>
|
||||
{enTitle && (
|
||||
<span
|
||||
className="font-display text-sm font-semibold tracking-wide text-red-dark/30 uppercase md:text-base"
|
||||
lang="en"
|
||||
@@ -18,18 +30,28 @@ export function SectionTitle({ title, enTitle, subtitle, action }: SectionTitleP
|
||||
>
|
||||
{enTitle}
|
||||
</span>
|
||||
)}
|
||||
</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 && (
|
||||
<p className="mt-1 text-sm text-muted">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
{action && (
|
||||
<button
|
||||
type="button"
|
||||
{action && actionHref && (
|
||||
<a
|
||||
href={actionHref}
|
||||
className="shrink-0 text-sm font-medium text-red transition-colors hover:text-red-dark"
|
||||
>
|
||||
{action} ←
|
||||
</button>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
+11
-9
@@ -51,6 +51,7 @@ export type BusinessInfo = {
|
||||
about: string;
|
||||
vision: string;
|
||||
logoUrl: string | null;
|
||||
logoDarkUrl: string | null;
|
||||
faviconUrl: string | null;
|
||||
emails: string[];
|
||||
phoneNumbers: string[];
|
||||
@@ -490,27 +491,28 @@ export type ProductGroup = {
|
||||
id: string;
|
||||
key: string;
|
||||
title: string;
|
||||
sortOrder: number;
|
||||
items: Product[];
|
||||
};
|
||||
|
||||
function normalizeProductGroups(items: Record<string, unknown>[] | undefined): ProductGroup[] {
|
||||
return (items ?? []).map((group) => ({
|
||||
return (items ?? [])
|
||||
.map((group) => ({
|
||||
id: String(group.id ?? group.key ?? ""),
|
||||
key: String(group.key ?? group.id ?? ""),
|
||||
title: String(group.title ?? ""),
|
||||
sortOrder: Number(group.sortOrder ?? 0),
|
||||
items: Array.isArray(group.items)
|
||||
? (group.items as Record<string, unknown>[]).map(normalizeProduct)
|
||||
: [],
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getBrandGroups(): Promise<ProductGroup[]> {
|
||||
const res = await apiGet<{ items: Record<string, unknown>[] }>(
|
||||
`/tenants/${DOMAIN}/website/brand-groups`,
|
||||
);
|
||||
return normalizeProductGroups(res?.items);
|
||||
}))
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
}
|
||||
|
||||
// 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[]> {
|
||||
const res = await apiGet<{ items: Record<string, unknown>[] }>(
|
||||
`/tenants/${DOMAIN}/store-specials`,
|
||||
|
||||
Reference in New Issue
Block a user