diff --git a/AGENTS.md b/AGENTS.md index 16b231a..7593b11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/src/app/installments/page.tsx b/src/app/installments/page.tsx index aa0413d..f19d7ad 100644 --- a/src/app/installments/page.tsx +++ b/src/app/installments/page.tsx @@ -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(); + 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 => Boolean(x)); diff --git a/src/app/page.tsx b/src/app/page.tsx index 76fe88b..ba3b1b6 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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() { <>

فروشگاه موبایل معلم | خرید موبایل، لپ‌تاپ و لوازم جانبی

- + {mobileCarousel && ( diff --git a/src/components/CategoryCarousel.tsx b/src/components/CategoryCarousel.tsx index a13109e..96a6875 100644 --- a/src/components/CategoryCarousel.tsx +++ b/src/components/CategoryCarousel.tsx @@ -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 (
@@ -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 ( - + {label} diff --git a/src/lib/categoryTree.ts b/src/lib/categoryTree.ts index 0b56603..dc7b27b 100644 --- a/src/lib/categoryTree.ts +++ b/src/lib/categoryTree.ts @@ -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 diff --git a/src/lib/slug.ts b/src/lib/slug.ts index 7c88be5..1abc75d 100644 --- a/src/lib/slug.ts +++ b/src/lib/slug.ts @@ -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; + } +}