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,
|
`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 |
+6
-2
@@ -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
@@ -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} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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]}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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`,
|
||||||
|
|||||||
Reference in New Issue
Block a user