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 |
|---|---|---|
| `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
+15 -9
View File
@@ -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
View File
@@ -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 && (
+23 -14
View File
@@ -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" />
+9
View File
@@ -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
+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 {
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;
}
}