The homepage category carousel and installments highlights matched slot.images[i] to categoryList[i] by array index, assuming the business's CMS upload order lines up with this site's own category sort order. It doesn't - live data showed a laptop photo landing on "موبایل" and "لوازم برقی" appearing on the site despite no photo ever being uploaded for it, purely from index luck. titleFa isn't reliable either: the same laptop image was titled "تبلت" in the CMS, contradicting its own linkUrl. Fix: resolve each image's category from the `category` query param on its own linkUrl (categorySlugFromLinkUrl() in slug.ts, matched against ApiCategory.slug via the new findNodeBySlug()). The homepage carousel is now fully slot-driven - it iterates slot.images (in the CMS's own sortOrder) and simply omits any category with no matching image, per explicit product direction, rather than falling back to a placeholder. Installments keeps its curated HIGHLIGHT_IDS list with a placeholder for uncovered categories, since that page always shows the same fixed set, but now uses the same identity-based lookup instead of position. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
183 lines
6.4 KiB
TypeScript
183 lines
6.4 KiB
TypeScript
import HeroSlider, { type Slide } from "@/components/HeroSlider";
|
|
import CategoryCarousel from "@/components/CategoryCarousel";
|
|
import HotDeals from "@/components/HotDeals";
|
|
import ProductCarousel from "@/components/ProductCarousel";
|
|
import { OneBanner, ThreeBanners, TwoBanners } from "@/components/PromoBanners";
|
|
import BlogCarousel, { type BlogCardData } from "@/components/BlogCarousel";
|
|
import {
|
|
findSlot,
|
|
getBlogs,
|
|
getCategories,
|
|
getProducts,
|
|
getSliders,
|
|
getStaticImageSlots,
|
|
getStoreSpecials,
|
|
safe,
|
|
} from "@/lib/api";
|
|
import { buildCategoryTree, findNode, subtreeIds } from "@/lib/categoryTree";
|
|
import { productToCard, specialToCards } from "@/lib/cards";
|
|
import { blogHref, categoryHref } from "@/lib/slug";
|
|
import { HOME_CAROUSELS } from "@/data/site";
|
|
import { FALLBACK_BLOGS } from "@/data/fallbackBlogs";
|
|
|
|
// The slider slot is empty on this tenant, so the homepage falls back to the
|
|
// campaign banners already running on moalem.shop (same art, same targets).
|
|
const FALLBACK_SLIDES: Slide[] = [
|
|
{
|
|
id: "laptop",
|
|
image: "/images/banners/9WYorEOTwn.jpg",
|
|
mobileImage: "/images/banners/WRbs72TDPR.jpg",
|
|
href: categoryHref("1254", "لپ تاپ"),
|
|
alt: "لپ تاپ",
|
|
},
|
|
{
|
|
id: "console",
|
|
image: "/images/banners/OiNl9v5gTf.jpg",
|
|
mobileImage: "/images/banners/ieYGotL2qd.jpg",
|
|
href: categoryHref("1256", "کنسول بازی"),
|
|
alt: "کنسول بازی",
|
|
},
|
|
{
|
|
id: "iphone-17",
|
|
image: "/images/banners/06SQinO4sG.jpg",
|
|
mobileImage: "/images/banners/lAGRh3ks32.jpg",
|
|
href: categoryHref("1251", "موبایل"),
|
|
alt: "آیفون ۱۷ پرو مکس",
|
|
},
|
|
];
|
|
|
|
const EMPTY_PAGE = { items: [], total: 0, page: 1, pageSize: 12 };
|
|
|
|
export default async function HomePage() {
|
|
const [categories, sliders, specials, blogs, imageSlots] = await Promise.all([
|
|
safe(getCategories().then(buildCategoryTree), []),
|
|
safe(getSliders(), []),
|
|
safe(getStoreSpecials(), []),
|
|
safe(getBlogs({ pageSize: 10 }), { items: [], total: 0, page: 1, pageSize: 10 }),
|
|
safe(getStaticImageSlots("home"), []),
|
|
]);
|
|
|
|
// Empty or failing slots fall through to each section's own placeholder —
|
|
// findSlot() already returns undefined for an empty/missing key.
|
|
const sliderSlot = findSlot(imageSlots, "slider");
|
|
const categoriesSlot = findSlot(imageSlots, "categories");
|
|
const oneBannerSlot = findSlot(imageSlots, "one-banner-bg");
|
|
const twoBannerSlot = findSlot(imageSlots, "two-banner-bg");
|
|
const threeBannerSlot = findSlot(imageSlots, "three-banner-bg");
|
|
|
|
const slides: Slide[] =
|
|
sliders.length > 0
|
|
? sliders
|
|
.filter((s) => s.imageUrl)
|
|
.map((s, i) => ({
|
|
id: s.id ?? `slide-${i}`,
|
|
image: s.imageUrl!,
|
|
mobileImage: s.mobileImageUrl ?? undefined,
|
|
href: s.linkUrl ?? "/products",
|
|
alt: s.title ?? "بنر فروشگاه موبایل معلم",
|
|
}))
|
|
: FALLBACK_SLIDES;
|
|
|
|
// "تخفیفهای داغ" prefers the curated special-discount group; until it is
|
|
// filled in the dashboard it falls back to the newest catalog products.
|
|
const discountGroup =
|
|
specials.find((s) => s.key === "special-discount" && s.isActive) ?? specials[0];
|
|
const dealsFromCms = discountGroup ? specialToCards(discountGroup) : [];
|
|
const fallbackDeals = dealsFromCms.length
|
|
? []
|
|
: (await safe(getProducts({ pageSize: 12 }), EMPTY_PAGE)).items.map(productToCard);
|
|
const deals = dealsFromCms.length ? dealsFromCms : fallbackDeals;
|
|
|
|
const carousels = await Promise.all(
|
|
HOME_CAROUSELS.map(async (section) => {
|
|
const cat = findNode(categories, section.categoryId);
|
|
const ids = cat ? subtreeIds(cat) : [section.categoryId];
|
|
const pages = await Promise.all(
|
|
ids.map((categoryId) => safe(getProducts({ categoryId, pageSize: 12 }), EMPTY_PAGE))
|
|
);
|
|
|
|
const seen = new Set<string>();
|
|
const products = pages
|
|
.flatMap((page) => page.items)
|
|
.filter((product) => !seen.has(product.id) && seen.add(product.id))
|
|
.slice(0, 12)
|
|
.map(productToCard);
|
|
|
|
return {
|
|
...section,
|
|
href: categoryHref(section.categoryId, cat?.nameFa ?? section.title, cat?.name),
|
|
products,
|
|
};
|
|
})
|
|
);
|
|
|
|
const posts: BlogCardData[] =
|
|
blogs.items.length > 0
|
|
? blogs.items.map((b) => ({
|
|
id: b.id,
|
|
href: blogHref(b.id, b.title ?? b.nameFa),
|
|
title: b.title ?? b.nameFa ?? "",
|
|
image: b.thumbnailUrl ?? b.image ?? null,
|
|
category: b.categoryNameFa ?? b.categoryName ?? undefined,
|
|
}))
|
|
: FALLBACK_BLOGS.map((b) => ({
|
|
id: b.id,
|
|
href: "/blog",
|
|
title: b.title,
|
|
image: b.image,
|
|
category: b.category,
|
|
}));
|
|
|
|
// The deals countdown runs to the end of the current day, recomputed on each
|
|
// server render so the timer never shows a stale target.
|
|
const endOfDay = new Date();
|
|
endOfDay.setHours(23, 59, 59, 999);
|
|
|
|
const [mobileCarousel, accessoriesCarousel, laptopCarousel, consoleCarousel] = carousels;
|
|
|
|
return (
|
|
<>
|
|
<h1 className="sr-only">فروشگاه موبایل معلم | خرید موبایل، لپتاپ و لوازم جانبی</h1>
|
|
<HeroSlider slot={sliderSlot} slides={slides} />
|
|
<CategoryCarousel roots={categories} slot={categoriesSlot} />
|
|
<HotDeals products={deals} endsAt={endOfDay.getTime() + 2 * 86_400_000} />
|
|
<ThreeBanners slot={threeBannerSlot} />
|
|
{mobileCarousel && (
|
|
<ProductCarousel
|
|
title={mobileCarousel.title}
|
|
desc={mobileCarousel.desc}
|
|
href={mobileCarousel.href}
|
|
products={mobileCarousel.products}
|
|
/>
|
|
)}
|
|
<TwoBanners slot={twoBannerSlot} />
|
|
{accessoriesCarousel && (
|
|
<ProductCarousel
|
|
title={accessoriesCarousel.title}
|
|
desc={accessoriesCarousel.desc}
|
|
href={accessoriesCarousel.href}
|
|
products={accessoriesCarousel.products}
|
|
/>
|
|
)}
|
|
{laptopCarousel && (
|
|
<ProductCarousel
|
|
title={laptopCarousel.title}
|
|
desc={laptopCarousel.desc}
|
|
href={laptopCarousel.href}
|
|
products={laptopCarousel.products}
|
|
/>
|
|
)}
|
|
<OneBanner slot={oneBannerSlot} />
|
|
{consoleCarousel && (
|
|
<ProductCarousel
|
|
title={consoleCarousel.title}
|
|
desc={consoleCarousel.desc}
|
|
href={consoleCarousel.href}
|
|
products={consoleCarousel.products}
|
|
/>
|
|
)}
|
|
<BlogCarousel posts={posts} />
|
|
</>
|
|
);
|
|
}
|