Fix category-slot matching: identity by linkUrl slug, not array position
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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
db7d00e473
commit
2249a07dee
@@ -126,7 +126,7 @@ each is consumed:
|
||||
| Key | Kind | Consumed by |
|
||||
|---|---|---|
|
||||
| `slider` | list | `HeroSlider.tsx` — wins over `FALLBACK_SLIDES` when it has images |
|
||||
| `categories` | list | `CategoryCarousel.tsx` — images matched **positionally** to the category list (business fills them in the same order); a category past the end of the slot keeps its SVG placeholder |
|
||||
| `categories` | list | `CategoryCarousel.tsx` — **fully slot-driven**: iterates `slot.images` and resolves each one's category via the `category` query param on its own `linkUrl` (`categorySlugFromLinkUrl()` in `src/lib/slug.ts`, matched against `ApiCategory.slug` with `findNodeBySlug()`). A category with no matching image simply isn't shown — **no placeholder fallback here**, per an explicit product decision (2026-09-06). Position and `titleFa` are **not** used for matching — both were tried and found unreliable in practice (see the history note below) |
|
||||
| `one-banner-bg` | single | `PromoBanners.tsx` → `OneBanner` |
|
||||
| `two-banner-bg` | list (2) | `PromoBanners.tsx` → `TwoBanners` |
|
||||
| `three-banner-bg` | list (3) | `PromoBanners.tsx` → `ThreeBanners` |
|
||||
@@ -136,6 +136,17 @@ what the component actually renders. If you change a banner's size or ratio,
|
||||
update its slot entry in the same commit — that file is the only thing
|
||||
telling the dashboard (and the designer) what to upload.
|
||||
|
||||
**Why `categories` matches by `linkUrl` slug, not position:** the first version
|
||||
matched `slot.images[i]` to `categoryList[i]` by array index, assuming the
|
||||
business's upload order lines up with this site's category sort order. It
|
||||
doesn't, and there's no contract that it ever would — live data showed a
|
||||
laptop photo (`linkUrl` correctly said `?category=laptop`) landing on
|
||||
"موبایل" and "لوازم برقی" appearing with no photo uploaded for it at all,
|
||||
purely because of where it fell in the array. `titleFa` isn't reliable
|
||||
either — the same laptop image was titled "تبلت" (stale/copy-pasted text),
|
||||
contradicting its own `linkUrl`. The `linkUrl` query param was the one field
|
||||
that matched the actual photo content every time it was checked.
|
||||
|
||||
Homepage banner placement (mirrors the logilook.com reference given for this
|
||||
layout): hero → categories → hot deals → **three-banner-bg** → موبایل
|
||||
carousel → **two-banner-bg** → لوازم جانبی → لپ تاپ → **one-banner-bg** →
|
||||
@@ -154,10 +165,12 @@ carousel → **two-banner-bg** → لوازم جانبی → لپ تاپ → **o
|
||||
needed for a query-based embed).
|
||||
- `/installments` — flat, brand-colored hero (no photo, no gradient — this
|
||||
site stays flat) instead of the reference design's photographic banner;
|
||||
category highlights reuse the same `categories` static-image slot images
|
||||
as the homepage carousel, matched by the same positional index. One
|
||||
reference category ("خانه هوشمند") has no equivalent on this tenant and is
|
||||
swapped for "لوازم برقی" — see `HIGHLIGHT_IDS` in the page for the mapping.
|
||||
category highlights are a fixed editorial list (`HIGHLIGHT_IDS`), unlike
|
||||
the homepage carousel — a highlight with no matching `categories`-slot
|
||||
image (by `linkUrl` slug, same lookup as the carousel) keeps its SVG
|
||||
placeholder rather than disappearing, since this page always shows the
|
||||
same curated set. One reference category ("خانه هوشمند") has no equivalent
|
||||
on this tenant and is swapped for "لوازم برقی".
|
||||
|
||||
## Product / blog pages
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ import Breadcrumbs from "@/components/Breadcrumbs";
|
||||
import TrustBadges from "@/components/ui/TrustBadges";
|
||||
import { ChevronLeft, LockIcon, RotateIcon, ShieldIcon, TruckIcon, WalletIcon } from "@/components/icons";
|
||||
import { findSlot, getCategories, getStaticImageSlots, safe } from "@/lib/api";
|
||||
import { buildCategoryTree, carouselCategories, findNode } from "@/lib/categoryTree";
|
||||
import { categoryHref } from "@/lib/slug";
|
||||
import { buildCategoryTree, findNode } from "@/lib/categoryTree";
|
||||
import { categoryHref, categorySlugFromLinkUrl } from "@/lib/slug";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "فروش اقساطی",
|
||||
@@ -32,19 +32,25 @@ export default async function InstallmentsPage() {
|
||||
safe(getCategories().then(buildCategoryTree), []),
|
||||
safe(getStaticImageSlots("home"), []),
|
||||
]);
|
||||
const level2 = carouselCategories(roots);
|
||||
const categoriesSlot = findSlot(slots, "categories");
|
||||
const slotImages = categoriesSlot?.images ?? [];
|
||||
|
||||
// Match by the slug in each image's own linkUrl (…?category=mobile), not
|
||||
// by array position — position assumes the business's upload order
|
||||
// matches this page's HIGHLIGHT_IDS order, which isn't a real contract and
|
||||
// was proven wrong in practice (a laptop photo landing on "موبایل"). See
|
||||
// AGENTS.md "Static-image slots".
|
||||
const artBySlug = new Map<string, string>();
|
||||
for (const img of categoriesSlot?.images ?? []) {
|
||||
const slug = categorySlugFromLinkUrl(img.linkUrl);
|
||||
if (slug) artBySlug.set(slug, img.url);
|
||||
}
|
||||
|
||||
const highlights = HIGHLIGHT_IDS.map((id) => {
|
||||
// findNode searches the whole tree, not just level2 — "هندزفری و هدفون"
|
||||
// (1232) is a level-3 category, not level-2, so level2.find missed it.
|
||||
// The "categories" slot only has art for the level-2 list though, so a
|
||||
// deeper id like this one always keeps its own placeholder.
|
||||
// (1232) is a level-3 category, not level-2.
|
||||
const cat = findNode(roots, id);
|
||||
if (!cat) return null;
|
||||
const index = level2.findIndex((c) => c.id === id);
|
||||
const art = (index >= 0 && slotImages[index]?.url) || `/images/categories/${id}.svg`;
|
||||
const art = artBySlug.get(cat.slug) || `/images/categories/${id}.svg`;
|
||||
return { id, label: cat.nameFa || cat.name, art, href: categoryHref(id, cat.nameFa, cat.name) };
|
||||
}).filter((x): x is NonNullable<typeof x> => Boolean(x));
|
||||
|
||||
|
||||
+2
-4
@@ -14,7 +14,7 @@ import {
|
||||
getStoreSpecials,
|
||||
safe,
|
||||
} from "@/lib/api";
|
||||
import { buildCategoryTree, carouselCategories, findNode, subtreeIds } from "@/lib/categoryTree";
|
||||
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";
|
||||
@@ -57,8 +57,6 @@ export default async function HomePage() {
|
||||
safe(getStaticImageSlots("home"), []),
|
||||
]);
|
||||
|
||||
const carouselCats = carouselCategories(categories);
|
||||
|
||||
// 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");
|
||||
@@ -141,7 +139,7 @@ export default async function HomePage() {
|
||||
<>
|
||||
<h1 className="sr-only">فروشگاه موبایل معلم | خرید موبایل، لپتاپ و لوازم جانبی</h1>
|
||||
<HeroSlider slot={sliderSlot} slides={slides} />
|
||||
<CategoryCarousel categories={carouselCats} slot={categoriesSlot} />
|
||||
<CategoryCarousel roots={categories} slot={categoriesSlot} />
|
||||
<HotDeals products={deals} endsAt={endOfDay.getTime() + 2 * 86_400_000} />
|
||||
<ThreeBanners slot={threeBannerSlot} />
|
||||
{mobileCarousel && (
|
||||
|
||||
@@ -6,24 +6,34 @@ import { FreeMode } from "swiper/modules";
|
||||
import { Swiper, SwiperSlide } from "swiper/react";
|
||||
import "swiper/css";
|
||||
import "swiper/css/free-mode";
|
||||
import { categoryHref } from "@/lib/slug";
|
||||
import { findNodeBySlug } from "@/lib/categoryTree";
|
||||
import { categoryHref, categorySlugFromLinkUrl } from "@/lib/slug";
|
||||
import type { CategoryNode, StaticImageSlot } from "@/lib/types";
|
||||
|
||||
// Circular category thumbnails. The "categories" static-image slot has no
|
||||
// per-category id, so its images (kind: list, duplicatable) are matched
|
||||
// positionally to the category list, in the same order the business fills
|
||||
// them in — each still links to its real category page, only the art swaps.
|
||||
// A category past the end of the slot, or the whole thing when the slot is
|
||||
// empty, keeps its placeholder at public/images/categories/{id}.svg.
|
||||
// Fully slot-driven: each image in the "categories" slot carries its own
|
||||
// linkUrl (e.g. "...?category=mobile"), whose `category` param is the real
|
||||
// DB slug — that's what identifies which category a photo is for, not the
|
||||
// image's position in the list or its (business-typed, sometimes wrong)
|
||||
// titleFa. A category the business hasn't uploaded a photo for simply isn't
|
||||
// shown here — no placeholder, per the "only what's in the panel" brief.
|
||||
// See AGENTS.md "Static-image slots" for the mismatch this replaced.
|
||||
export default function CategoryCarousel({
|
||||
categories,
|
||||
roots,
|
||||
slot,
|
||||
}: {
|
||||
categories: CategoryNode[];
|
||||
roots: CategoryNode[];
|
||||
slot?: StaticImageSlot;
|
||||
}) {
|
||||
if (categories.length === 0) return null;
|
||||
const slotImages = slot?.images ?? [];
|
||||
const items = (slot?.images ?? [])
|
||||
.map((img) => {
|
||||
const catSlug = categorySlugFromLinkUrl(img.linkUrl);
|
||||
const cat = catSlug ? findNodeBySlug(roots, catSlug) : undefined;
|
||||
if (!cat) return null;
|
||||
return { key: img.id, art: img.url, cat };
|
||||
})
|
||||
.filter((x): x is { key: string; art: string; cat: CategoryNode } => Boolean(x));
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="boxed mt-8" aria-label="دستهبندی محصولات">
|
||||
@@ -39,11 +49,10 @@ export default function CategoryCarousel({
|
||||
1024: { slidesPerView: 7 },
|
||||
}}
|
||||
>
|
||||
{categories.map((cat, i) => {
|
||||
{items.map(({ key, art, cat }) => {
|
||||
const label = cat.nameFa || cat.name;
|
||||
const art = slotImages[i]?.url || `/images/categories/${cat.id}.svg`;
|
||||
return (
|
||||
<SwiperSlide key={cat.id}>
|
||||
<SwiperSlide key={key}>
|
||||
<Link href={categoryHref(cat.id, cat.nameFa, cat.name)} className="group flex flex-col items-center gap-2">
|
||||
<span className="relative size-[104px] overflow-hidden rounded-full bg-surface transition group-hover:bg-brand-soft sm:size-[124px] lg:size-[140px]">
|
||||
<Image src={art} alt={label} fill sizes="140px" className="object-contain p-3" />
|
||||
|
||||
@@ -35,6 +35,15 @@ export function findNode(nodes: CategoryNode[], id: string): CategoryNode | unde
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function findNodeBySlug(nodes: CategoryNode[], slug: string): CategoryNode | undefined {
|
||||
for (const node of nodes) {
|
||||
if (node.slug === slug) return node;
|
||||
const hit = findNodeBySlug(node.children, slug);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Products are filed against leaf categories, so a parent like "لوازم جانبی"
|
||||
// matches almost nothing on its own — a carousel for it has to ask for the
|
||||
// whole subtree. Capped for the homepage's small carousels; a full category
|
||||
|
||||
@@ -22,3 +22,18 @@ export function categoryHref(id: string, nameFa?: string | null, name?: string |
|
||||
export function blogHref(id: string, title?: string | null): string {
|
||||
return `/blog/${id}/${faSlug(title)}`;
|
||||
}
|
||||
|
||||
// Static-image-slot items carry a business-picked linkUrl like
|
||||
// "https://moalem.shop/products?category=mobile" — the `category` query
|
||||
// param is the DB slug (ApiCategory.slug), the one reliable way to identify
|
||||
// which category an uploaded photo is actually for. Position in the list
|
||||
// and the item's own titleFa are NOT reliable — see AGENTS.md "Static-image
|
||||
// slots" for the real mismatch this was built to fix.
|
||||
export function categorySlugFromLinkUrl(linkUrl: string | null | undefined): string | null {
|
||||
if (!linkUrl) return null;
|
||||
try {
|
||||
return new URL(linkUrl).searchParams.get("category");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user