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:
amirhosein.ashourloo
2026-09-06 07:36:24 +03:30
co-authored by Claude Sonnet 5
parent db7d00e473
commit 2249a07dee
6 changed files with 82 additions and 32 deletions
+18 -5
View File
@@ -126,7 +126,7 @@ each is consumed:
| Key | Kind | Consumed by | | Key | Kind | Consumed by |
|---|---|---| |---|---|---|
| `slider` | list | `HeroSlider.tsx` — wins over `FALLBACK_SLIDES` when it has images | | `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` | | `one-banner-bg` | single | `PromoBanners.tsx` → `OneBanner` |
| `two-banner-bg` | list (2) | `PromoBanners.tsx` → `TwoBanners` | | `two-banner-bg` | list (2) | `PromoBanners.tsx` → `TwoBanners` |
| `three-banner-bg` | list (3) | `PromoBanners.tsx` → `ThreeBanners` | | `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 update its slot entry in the same commit — that file is the only thing
telling the dashboard (and the designer) what to upload. 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 Homepage banner placement (mirrors the logilook.com reference given for this
layout): hero → categories → hot deals → **three-banner-bg** → موبایل layout): hero → categories → hot deals → **three-banner-bg** → موبایل
carousel → **two-banner-bg** → لوازم جانبی → لپ تاپ → **one-banner-bg** → carousel → **two-banner-bg** → لوازم جانبی → لپ تاپ → **one-banner-bg** →
@@ -154,10 +165,12 @@ carousel → **two-banner-bg** → لوازم جانبی → لپ تاپ → **o
needed for a query-based embed). needed for a query-based embed).
- `/installments` — flat, brand-colored hero (no photo, no gradient — this - `/installments` — flat, brand-colored hero (no photo, no gradient — this
site stays flat) instead of the reference design's photographic banner; site stays flat) instead of the reference design's photographic banner;
category highlights reuse the same `categories` static-image slot images category highlights are a fixed editorial list (`HIGHLIGHT_IDS`), unlike
as the homepage carousel, matched by the same positional index. One the homepage carousel — a highlight with no matching `categories`-slot
reference category ("خانه هوشمند") has no equivalent on this tenant and is image (by `linkUrl` slug, same lookup as the carousel) keeps its SVG
swapped for "لوازم برقی" — see `HIGHLIGHT_IDS` in the page for the mapping. 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 ## Product / blog pages
+15 -9
View File
@@ -5,8 +5,8 @@ import Breadcrumbs from "@/components/Breadcrumbs";
import TrustBadges from "@/components/ui/TrustBadges"; import TrustBadges from "@/components/ui/TrustBadges";
import { ChevronLeft, LockIcon, RotateIcon, ShieldIcon, TruckIcon, WalletIcon } from "@/components/icons"; import { ChevronLeft, LockIcon, RotateIcon, ShieldIcon, TruckIcon, WalletIcon } from "@/components/icons";
import { findSlot, getCategories, getStaticImageSlots, safe } from "@/lib/api"; import { findSlot, getCategories, getStaticImageSlots, safe } from "@/lib/api";
import { buildCategoryTree, carouselCategories, findNode } from "@/lib/categoryTree"; import { buildCategoryTree, findNode } from "@/lib/categoryTree";
import { categoryHref } from "@/lib/slug"; import { categoryHref, categorySlugFromLinkUrl } from "@/lib/slug";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "فروش اقساطی", title: "فروش اقساطی",
@@ -32,19 +32,25 @@ export default async function InstallmentsPage() {
safe(getCategories().then(buildCategoryTree), []), safe(getCategories().then(buildCategoryTree), []),
safe(getStaticImageSlots("home"), []), safe(getStaticImageSlots("home"), []),
]); ]);
const level2 = carouselCategories(roots);
const categoriesSlot = findSlot(slots, "categories"); 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) => { const highlights = HIGHLIGHT_IDS.map((id) => {
// findNode searches the whole tree, not just level2 — "هندزفری و هدفون" // findNode searches the whole tree, not just level2 — "هندزفری و هدفون"
// (1232) is a level-3 category, not level-2, so level2.find missed it. // (1232) is a level-3 category, not level-2.
// The "categories" slot only has art for the level-2 list though, so a
// deeper id like this one always keeps its own placeholder.
const cat = findNode(roots, id); const cat = findNode(roots, id);
if (!cat) return null; if (!cat) return null;
const index = level2.findIndex((c) => c.id === id); const art = artBySlug.get(cat.slug) || `/images/categories/${id}.svg`;
const art = (index >= 0 && slotImages[index]?.url) || `/images/categories/${id}.svg`;
return { id, label: cat.nameFa || cat.name, art, href: categoryHref(id, cat.nameFa, cat.name) }; return { id, label: cat.nameFa || cat.name, art, href: categoryHref(id, cat.nameFa, cat.name) };
}).filter((x): x is NonNullable<typeof x> => Boolean(x)); }).filter((x): x is NonNullable<typeof x> => Boolean(x));
+2 -4
View File
@@ -14,7 +14,7 @@ import {
getStoreSpecials, getStoreSpecials,
safe, safe,
} from "@/lib/api"; } 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 { productToCard, specialToCards } from "@/lib/cards";
import { blogHref, categoryHref } from "@/lib/slug"; import { blogHref, categoryHref } from "@/lib/slug";
import { HOME_CAROUSELS } from "@/data/site"; import { HOME_CAROUSELS } from "@/data/site";
@@ -57,8 +57,6 @@ export default async function HomePage() {
safe(getStaticImageSlots("home"), []), safe(getStaticImageSlots("home"), []),
]); ]);
const carouselCats = carouselCategories(categories);
// Empty or failing slots fall through to each section's own placeholder — // Empty or failing slots fall through to each section's own placeholder —
// findSlot() already returns undefined for an empty/missing key. // findSlot() already returns undefined for an empty/missing key.
const sliderSlot = findSlot(imageSlots, "slider"); const sliderSlot = findSlot(imageSlots, "slider");
@@ -141,7 +139,7 @@ export default async function HomePage() {
<> <>
<h1 className="sr-only">فروشگاه موبایل معلم | خرید موبایل، لپ‌تاپ و لوازم جانبی</h1> <h1 className="sr-only">فروشگاه موبایل معلم | خرید موبایل، لپ‌تاپ و لوازم جانبی</h1>
<HeroSlider slot={sliderSlot} slides={slides} /> <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} /> <HotDeals products={deals} endsAt={endOfDay.getTime() + 2 * 86_400_000} />
<ThreeBanners slot={threeBannerSlot} /> <ThreeBanners slot={threeBannerSlot} />
{mobileCarousel && ( {mobileCarousel && (
+23 -14
View File
@@ -6,24 +6,34 @@ import { FreeMode } from "swiper/modules";
import { Swiper, SwiperSlide } from "swiper/react"; import { Swiper, SwiperSlide } from "swiper/react";
import "swiper/css"; import "swiper/css";
import "swiper/css/free-mode"; 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"; import type { CategoryNode, StaticImageSlot } from "@/lib/types";
// Circular category thumbnails. The "categories" static-image slot has no // Fully slot-driven: each image in the "categories" slot carries its own
// per-category id, so its images (kind: list, duplicatable) are matched // linkUrl (e.g. "...?category=mobile"), whose `category` param is the real
// positionally to the category list, in the same order the business fills // DB slug — that's what identifies which category a photo is for, not the
// them in — each still links to its real category page, only the art swaps. // image's position in the list or its (business-typed, sometimes wrong)
// A category past the end of the slot, or the whole thing when the slot is // titleFa. A category the business hasn't uploaded a photo for simply isn't
// empty, keeps its placeholder at public/images/categories/{id}.svg. // 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({ export default function CategoryCarousel({
categories, roots,
slot, slot,
}: { }: {
categories: CategoryNode[]; roots: CategoryNode[];
slot?: StaticImageSlot; slot?: StaticImageSlot;
}) { }) {
if (categories.length === 0) return null; const items = (slot?.images ?? [])
const slotImages = 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 ( return (
<section className="boxed mt-8" aria-label="دسته‌بندی محصولات"> <section className="boxed mt-8" aria-label="دسته‌بندی محصولات">
@@ -39,11 +49,10 @@ export default function CategoryCarousel({
1024: { slidesPerView: 7 }, 1024: { slidesPerView: 7 },
}} }}
> >
{categories.map((cat, i) => { {items.map(({ key, art, cat }) => {
const label = cat.nameFa || cat.name; const label = cat.nameFa || cat.name;
const art = slotImages[i]?.url || `/images/categories/${cat.id}.svg`;
return ( 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"> <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]"> <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" /> <Image src={art} alt={label} fill sizes="140px" className="object-contain p-3" />
+9
View File
@@ -35,6 +35,15 @@ export function findNode(nodes: CategoryNode[], id: string): CategoryNode | unde
return undefined; 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 "لوازم جانبی" // 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 // 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 // whole subtree. Capped for the homepage's small carousels; a full category
+15
View File
@@ -22,3 +22,18 @@ export function categoryHref(id: string, nameFa?: string | null, name?: string |
export function blogHref(id: string, title?: string | null): string { export function blogHref(id: string, title?: string | null): string {
return `/blog/${id}/${faSlug(title)}`; 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;
}
}