diff --git a/CONTEXT.md b/CONTEXT.md index 64cd20c..f0b1473 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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, 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 `` 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 | diff --git a/src/app/favicon.ico b/src/app/favicon.ico deleted file mode 100644 index 718d6fe..0000000 Binary files a/src/app/favicon.ico and /dev/null differ diff --git a/src/app/layout.tsx b/src/app/layout.tsx index a42697e..d5910ad 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -27,10 +27,14 @@ const syne = Syne({ weight: ["600", "700", "800"], }); -export const metadata: Metadata = { - title: "سانی‌هوم | فروشگاه لوازم خانگی", - description: "فروشگاه آنلاین لوازم خانگی — یخچال، لباسشویی، تلویزیون و بیشتر", -}; +export async function generateMetadata(): Promise { + 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<"/">) { -
+
{children}
diff --git a/src/app/page.tsx b/src/app/page.tsx index 8ee47cb..55f5a3e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,19 +1,15 @@ 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 { 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 { BlogCarousel } from "@/components/home/BlogCarousel"; import { getBlogs, getBusinessInfo, getCategories, - getProducts, - getBrandGroups, getStoreSpecials, getStaticImageSlots, imagesForSlot, @@ -21,26 +17,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 +36,17 @@ export default async function Home() { return ( <> - - + + - - diff --git a/src/components/home/FeaturedCategories.tsx b/src/components/home/FeaturedCategories.tsx index a7c89c3..9cecae2 100644 --- a/src/components/home/FeaturedCategories.tsx +++ b/src/components/home/FeaturedCategories.tsx @@ -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"; @@ -36,11 +35,6 @@ export function FeaturedCategories({ return (
- {images.length > 0 ? (
-
- S -
- - SANIHOME - + {businessInfo?.logoDarkUrl || businessInfo?.logoUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {businessInfo.nameFa + ) : ( + + SANIHOME + + )}
{businessInfo?.about && (

{businessInfo.about}

diff --git a/src/components/home/Header.tsx b/src/components/home/Header.tsx index 483213d..e8c2488 100644 --- a/src/components/home/Header.tsx +++ b/src/components/home/Header.tsx @@ -17,12 +17,12 @@ 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 ( {/* eslint-disable-next-line @next/next/no-img-element */} سانی‌هوم void; categoryTree: CategoryTreeGroup[]; + logoUrl?: string | null; }) { const [categoriesOpen, setCategoriesOpen] = useState(false); const [mounted, setMounted] = useState(false); @@ -100,7 +102,7 @@ function MobileDrawer({ }`} >
- +
- +
- +
@@ -360,6 +364,7 @@ export function Header({ open={drawerOpen} onClose={() => setDrawerOpen(false)} categoryTree={categoryTree} + logoUrl={logoUrl} />
); diff --git a/src/components/home/SpecialSaleCarousel.tsx b/src/components/home/SpecialSaleCarousel.tsx deleted file mode 100644 index 66ff990..0000000 --- a/src/components/home/SpecialSaleCarousel.tsx +++ /dev/null @@ -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 ( - - - - ); -} - -export function SpecialSaleCarousel({ - products, - contactPhone, -}: { - products: Product[]; - contactPhone?: string; -}) { - return ( -
-
- - {products.length === 0 ? ( - - ) : ( - - - {products.map((product) => ( - - ))} - - )} -
-
- ); -} diff --git a/src/components/home/BrandGroupCarousels.tsx b/src/components/home/StoreSpecialCarousels.tsx similarity index 63% rename from src/components/home/BrandGroupCarousels.tsx rename to src/components/home/StoreSpecialCarousels.tsx index 6174403..717fc3d 100644 --- a/src/components/home/BrandGroupCarousels.tsx +++ b/src/components/home/StoreSpecialCarousels.tsx @@ -1,33 +1,29 @@ 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, +// 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). +export function StoreSpecialCarousels({ + groups, contactPhone, }: { - brandGroups: ProductGroup[]; + groups: ProductGroup[]; contactPhone?: string; }) { - if (brandGroups.length === 0) { - return ( -
-
- - -
-
- ); - } + const visibleGroups = groups.filter((group) => group.items.length > 0); return ( <> - {brandGroups.map((group) => ( + {visibleGroups.map((group) => (
- + {group.items.map((product) => ( -
- - {products.length === 0 ? ( - - ) : ( - - {products.map((product) => ( - - ))} - - )} -
-
- ); -} diff --git a/src/components/ui/SectionTitle.tsx b/src/components/ui/SectionTitle.tsx index 12047d2..4339b4f 100644 --- a/src/components/ui/SectionTitle.tsx +++ b/src/components/ui/SectionTitle.tsx @@ -1,6 +1,6 @@ type SectionTitleProps = { title: string; - enTitle: string; + enTitle?: string; subtitle?: string; action?: string; }; @@ -11,13 +11,15 @@ export function SectionTitle({ title, enTitle, subtitle, action }: SectionTitleP

{title}

- - {enTitle} - + {enTitle && ( + + {enTitle} + + )}
{subtitle && (

{subtitle}

diff --git a/src/lib/meshkee.ts b/src/lib/meshkee.ts index 37b1847..e3b7ca9 100644 --- a/src/lib/meshkee.ts +++ b/src/lib/meshkee.ts @@ -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[] | undefined): ProductGroup[] { - return (items ?? []).map((group) => ({ - id: String(group.id ?? group.key ?? ""), - key: String(group.key ?? group.id ?? ""), - title: String(group.title ?? ""), - items: Array.isArray(group.items) - ? (group.items as Record[]).map(normalizeProduct) - : [], - })); -} - -export async function getBrandGroups(): Promise { - const res = await apiGet<{ items: Record[] }>( - `/tenants/${DOMAIN}/website/brand-groups`, - ); - return normalizeProductGroups(res?.items); + 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[]).map(normalizeProduct) + : [], + })) + .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 { const res = await apiGet<{ items: Record[] }>( `/tenants/${DOMAIN}/store-specials`,