@mohasinac/appkit 4.50.0 → 4.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.build-stamp.json +3 -3
- package/dist/_internal/server/features/franchises/data.d.ts +32 -0
- package/dist/_internal/server/features/franchises/data.js +33 -0
- package/dist/_internal/server/features/franchises/index.d.ts +2 -0
- package/dist/_internal/server/features/franchises/index.js +2 -0
- package/dist/_internal/server/features/franchises/og.d.ts +31 -0
- package/dist/_internal/server/features/franchises/og.js +11 -0
- package/dist/_internal/server/features/products/list-public.d.ts +9 -0
- package/dist/_internal/server/features/products/list-public.js +3 -0
- package/dist/constants/field-names.d.ts +3 -0
- package/dist/constants/field-names.js +11 -0
- package/dist/features/categories/components/CategoriesIndexListing.js +25 -12
- package/dist/features/categories/components/CategoryGrid.js +2 -1
- package/dist/features/categories/components/FranchiseDetailPageView.d.ts +25 -0
- package/dist/features/categories/components/FranchiseDetailPageView.js +43 -0
- package/dist/features/categories/components/FranchisesIndexView.d.ts +14 -0
- package/dist/features/categories/components/FranchisesIndexView.js +33 -0
- package/dist/features/categories/constants/category-tabs.d.ts +64 -0
- package/dist/features/categories/constants/category-tabs.js +88 -0
- package/dist/features/categories/hooks/useCategories.d.ts +14 -0
- package/dist/features/categories/hooks/useCategories.js +2 -0
- package/dist/features/categories/repository/categories.repository.d.ts +15 -0
- package/dist/features/categories/repository/categories.repository.js +23 -0
- package/dist/features/categories/types/index.d.ts +13 -1
- package/dist/features/collections/schemas/index.d.ts +2 -2
- package/dist/features/pre-orders/schemas/index.d.ts +2 -2
- package/dist/features/products/repository/products.repository.d.ts +4 -0
- package/dist/features/products/repository/products.repository.js +5 -0
- package/dist/features/products/schemas/firestore.d.ts +13 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -0
- package/dist/next/routing/route-map.d.ts +4 -0
- package/dist/next/routing/route-map.js +9 -0
- package/dist/seed/_helpers/franchise-rows.d.ts +43 -0
- package/dist/seed/_helpers/franchise-rows.js +181 -0
- package/dist/seed/categories-seed-data.js +42 -1
- package/dist/seed/index.d.ts +1 -0
- package/dist/seed/index.js +1 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +1 -0
- package/package.json +1 -1
- package/scripts/seed-cli.mjs +32 -2
package/dist/.build-stamp.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"stampVersion": 1,
|
|
3
|
-
"version": "4.
|
|
4
|
-
"head": "
|
|
3
|
+
"version": "4.52.0",
|
|
4
|
+
"head": "5e6e4777891284de76aa0d646e556f8aee0ff76e",
|
|
5
5
|
"branch": "main",
|
|
6
6
|
"dirty": false,
|
|
7
|
-
"builtAt": "2026-10-
|
|
7
|
+
"builtAt": "2026-10-14T12:39:32.737Z"
|
|
8
8
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { CategoryDocument } from "../../../../features/categories/schemas";
|
|
2
|
+
/**
|
|
3
|
+
* SSR reads for the FRANCHISE unit — what a product is FROM, where a brand is
|
|
4
|
+
* who made it.
|
|
5
|
+
*
|
|
6
|
+
* 🛑 Wrapped in `React.cache` because each of these is called by BOTH the page
|
|
7
|
+
* body and its `generateMetadata`. Without it that is two identical Firestore
|
|
8
|
+
* reads per render, on routes that are meant to be cheap (CLAUDE.md, "React.cache
|
|
9
|
+
* discipline").
|
|
10
|
+
*
|
|
11
|
+
* The argument list is the cache key, so these take the slug and nothing else —
|
|
12
|
+
* adding a viewer parameter here would give every call site its own entry and
|
|
13
|
+
* dedupe nothing.
|
|
14
|
+
*
|
|
15
|
+
* @tag domain:categories
|
|
16
|
+
* @tag layer:server
|
|
17
|
+
* @tag access:server-only
|
|
18
|
+
* @tag sideEffects:firestore-read
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* One franchise by slug.
|
|
22
|
+
*
|
|
23
|
+
* 🛑 Deliberately NOT filtered on `isActive`. Seven of the eight seeded
|
|
24
|
+
* franchises are inactive because they have no stock yet, and the entire point
|
|
25
|
+
* of seeding them is that `/franchise/marvel` resolves instead of 404ing — the
|
|
26
|
+
* eighteen `CharacterHotspot` links pointed at a route that did not exist. The
|
|
27
|
+
* grid is where inactive rows are hidden (`findActiveFranchises`); the detail
|
|
28
|
+
* page renders them as an honest "nothing here yet".
|
|
29
|
+
*/
|
|
30
|
+
export declare const getFranchiseForDetail: (slug: string) => Promise<CategoryDocument | null>;
|
|
31
|
+
/** Every franchise a visitor may browse — active only, so no dead facets. */
|
|
32
|
+
export declare const listActiveFranchises: () => Promise<CategoryDocument[]>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { cache } from "react";
|
|
2
|
+
import { categoriesRepository } from "../../../../features/categories/repository/categories.repository.js";
|
|
3
|
+
/**
|
|
4
|
+
* SSR reads for the FRANCHISE unit — what a product is FROM, where a brand is
|
|
5
|
+
* who made it.
|
|
6
|
+
*
|
|
7
|
+
* 🛑 Wrapped in `React.cache` because each of these is called by BOTH the page
|
|
8
|
+
* body and its `generateMetadata`. Without it that is two identical Firestore
|
|
9
|
+
* reads per render, on routes that are meant to be cheap (CLAUDE.md, "React.cache
|
|
10
|
+
* discipline").
|
|
11
|
+
*
|
|
12
|
+
* The argument list is the cache key, so these take the slug and nothing else —
|
|
13
|
+
* adding a viewer parameter here would give every call site its own entry and
|
|
14
|
+
* dedupe nothing.
|
|
15
|
+
*
|
|
16
|
+
* @tag domain:categories
|
|
17
|
+
* @tag layer:server
|
|
18
|
+
* @tag access:server-only
|
|
19
|
+
* @tag sideEffects:firestore-read
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* One franchise by slug.
|
|
23
|
+
*
|
|
24
|
+
* 🛑 Deliberately NOT filtered on `isActive`. Seven of the eight seeded
|
|
25
|
+
* franchises are inactive because they have no stock yet, and the entire point
|
|
26
|
+
* of seeding them is that `/franchise/marvel` resolves instead of 404ing — the
|
|
27
|
+
* eighteen `CharacterHotspot` links pointed at a route that did not exist. The
|
|
28
|
+
* grid is where inactive rows are hidden (`findActiveFranchises`); the detail
|
|
29
|
+
* page renders them as an honest "nothing here yet".
|
|
30
|
+
*/
|
|
31
|
+
export const getFranchiseForDetail = cache(async (slug) => categoriesRepository.findBySlugAndType(slug, "franchise"));
|
|
32
|
+
/** Every franchise a visitor may browse — active only, so no dead facets. */
|
|
33
|
+
export const listActiveFranchises = cache(async () => categoriesRepository.findActiveFranchises());
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* OG card for a FRANCHISE page.
|
|
4
|
+
*
|
|
5
|
+
* 🛑 Delegates to `renderBrandOgImage` rather than copying its layout. That
|
|
6
|
+
* renderer already takes a generic `{ name, description, logoUrl }` and a
|
|
7
|
+
* franchise card is the same shape as a brand card — a cover image, a title and
|
|
8
|
+
* a line of copy. Forking it would give the two surfaces independent drift for
|
|
9
|
+
* no visual difference, which is the duplication the Rule of Three exists to
|
|
10
|
+
* prevent (two copies is observation; this is the second).
|
|
11
|
+
*
|
|
12
|
+
* If a franchise card ever needs its own treatment, the split happens at THAT
|
|
13
|
+
* point, with a reason — not pre-emptively.
|
|
14
|
+
*
|
|
15
|
+
* @tag domain:categories
|
|
16
|
+
* @tag layer:server
|
|
17
|
+
* @tag access:server-only
|
|
18
|
+
*/
|
|
19
|
+
interface FranchiseDocLike {
|
|
20
|
+
name?: string | null;
|
|
21
|
+
description?: string | null;
|
|
22
|
+
/** Same field as brands — `CategoryDocument` has no dedicated logo field. */
|
|
23
|
+
display?: {
|
|
24
|
+
coverImage?: string | null;
|
|
25
|
+
} | null;
|
|
26
|
+
}
|
|
27
|
+
export declare function renderFranchiseOg(doc: FranchiseDocLike | null | undefined, opts: {
|
|
28
|
+
siteName: string;
|
|
29
|
+
baseUrl?: string;
|
|
30
|
+
}): ReactElement;
|
|
31
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { resolveOgImageUrl } from "../seo/og.js";
|
|
2
|
+
import { renderBrandOgImage } from "../brands/og.js";
|
|
3
|
+
export function renderFranchiseOg(doc, opts) {
|
|
4
|
+
const name = doc?.name ?? "Franchise";
|
|
5
|
+
return renderBrandOgImage({
|
|
6
|
+
name,
|
|
7
|
+
description: doc?.description?.slice(0, 120) ??
|
|
8
|
+
`Shop ${name} collectibles on ${opts.siteName}.`,
|
|
9
|
+
logoUrl: resolveOgImageUrl(doc?.display?.coverImage ?? null, opts.baseUrl),
|
|
10
|
+
}, opts.siteName);
|
|
11
|
+
}
|
|
@@ -45,6 +45,15 @@ export interface PublicProductListInput {
|
|
|
45
45
|
q?: string;
|
|
46
46
|
category?: string;
|
|
47
47
|
brand?: string;
|
|
48
|
+
/**
|
|
49
|
+
* The IP the product is from, matched on `franchiseSlug`.
|
|
50
|
+
*
|
|
51
|
+
* 🛑 On the SLUG, not the display name — unlike `brand` directly above,
|
|
52
|
+
* which filters on `PRODUCT_FIELDS.BRAND` (the display name) and therefore
|
|
53
|
+
* orphans a brand's whole catalogue the moment anyone renames it. Franchise
|
|
54
|
+
* is new, so it gets the stable key rather than inheriting that trap.
|
|
55
|
+
*/
|
|
56
|
+
franchiseSlug?: string;
|
|
48
57
|
/** Pipe-joined multi-select (`new|used`) — emitted as an OR-group, never as AND. */
|
|
49
58
|
condition?: string;
|
|
50
59
|
storeId?: string;
|
|
@@ -267,6 +267,9 @@ function buildFirestoreSafeFilters(input, opts) {
|
|
|
267
267
|
}
|
|
268
268
|
if (input.brand)
|
|
269
269
|
parts.push(sieveFilter(PRODUCT_FIELDS.BRAND, SIEVE_OP.EQ, input.brand));
|
|
270
|
+
if (input.franchiseSlug) {
|
|
271
|
+
parts.push(sieveFilter(PRODUCT_FIELDS.FRANCHISE_SLUG, SIEVE_OP.EQ, input.franchiseSlug));
|
|
272
|
+
}
|
|
270
273
|
if (input.condition) {
|
|
271
274
|
// Pipe-joined OR-group, NOT `sieveMultiEq` — that emits
|
|
272
275
|
// `condition==new,condition==used`, an AND of two equalities on one field,
|
|
@@ -42,6 +42,8 @@ export declare const PRODUCT_FIELDS: {
|
|
|
42
42
|
readonly SUBCATEGORY: "subcategory";
|
|
43
43
|
readonly BRAND: "brand";
|
|
44
44
|
readonly BRAND_SLUG: "brandSlug";
|
|
45
|
+
readonly FRANCHISE: "franchise";
|
|
46
|
+
readonly FRANCHISE_SLUG: "franchiseSlug";
|
|
45
47
|
readonly PRICE: "price";
|
|
46
48
|
readonly CURRENCY: "currency";
|
|
47
49
|
readonly STOCK_QUANTITY: "stockQuantity";
|
|
@@ -529,6 +531,7 @@ export declare const CATEGORY_FIELDS: {
|
|
|
529
531
|
readonly BRAND: "brand";
|
|
530
532
|
readonly SUBLISTING: "sublisting";
|
|
531
533
|
readonly BUNDLE: "bundle";
|
|
534
|
+
readonly FRANCHISE: "franchise";
|
|
532
535
|
};
|
|
533
536
|
readonly BUNDLE_STOCK_STATUS_VALUES: {
|
|
534
537
|
readonly AVAILABLE: "available";
|
|
@@ -49,6 +49,10 @@ export const PRODUCT_FIELDS = {
|
|
|
49
49
|
SUBCATEGORY: "subcategory",
|
|
50
50
|
BRAND: "brand",
|
|
51
51
|
BRAND_SLUG: "brandSlug",
|
|
52
|
+
/* The IP a product is from. Matched on the SLUG, never the display name —
|
|
53
|
+
* see the note on `ProductDocument.franchiseSlug`. */
|
|
54
|
+
FRANCHISE: "franchise",
|
|
55
|
+
FRANCHISE_SLUG: "franchiseSlug",
|
|
52
56
|
PRICE: "price",
|
|
53
57
|
CURRENCY: "currency",
|
|
54
58
|
STOCK_QUANTITY: "stockQuantity",
|
|
@@ -604,6 +608,13 @@ export const CATEGORY_FIELDS = {
|
|
|
604
608
|
BRAND: "brand",
|
|
605
609
|
SUBLISTING: "sublisting",
|
|
606
610
|
BUNDLE: "bundle",
|
|
611
|
+
/*
|
|
612
|
+
* The IP a product belongs to — Beyblade, Pokémon, Marvel. A sibling of
|
|
613
|
+
* BRAND, not of CATEGORY: both are tier-0 rows outside the forest's DFS
|
|
614
|
+
* numbering that a product points AT, rather than nodes it lives under.
|
|
615
|
+
* A brand is who MADE it (Takara-Tomy); a franchise is what it IS FROM.
|
|
616
|
+
*/
|
|
617
|
+
FRANCHISE: "franchise",
|
|
607
618
|
},
|
|
608
619
|
BUNDLE_STOCK_STATUS_VALUES: {
|
|
609
620
|
AVAILABLE: "available",
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useState, useCallback, useMemo } from "react";
|
|
4
4
|
import { useCategoriesFiltered } from "../hooks/useCategories.js";
|
|
5
|
+
import { CATEGORY_TABS, DEFAULT_CATEGORY_TAB, matchesCategoryTab, } from "../constants/category-tabs.js";
|
|
5
6
|
import { ROUTES } from "../../../next/routing/route-map.js";
|
|
6
7
|
import { Button, Div, Grid, ListingFilterDrawer, ListingToolbar, Pagination, Row, Stack, Text, StickyToolbar } from "../../../ui/index.js";
|
|
7
8
|
import { CategoryCard } from "./CategoryGrid.js";
|
|
@@ -20,11 +21,16 @@ const PAGE_SIZE = 24;
|
|
|
20
21
|
function renderCategoryGrid(props) {
|
|
21
22
|
const { isLoading, categories, view, activeSearch, activeTab, brandsOnly } = props;
|
|
22
23
|
const isBrandView = activeTab === "brands" || brandsOnly;
|
|
24
|
+
/* The empty state names the tab the visitor is actually on. It said
|
|
25
|
+
* "categories" on all five, so an empty Parts tab reported the wrong thing. */
|
|
26
|
+
const noun = brandsOnly
|
|
27
|
+
? "brands"
|
|
28
|
+
: (CATEGORY_TABS.find((t) => t.key === activeTab)?.label ?? "categories").toLowerCase();
|
|
23
29
|
if (isLoading) {
|
|
24
30
|
return (_jsx(Grid, { cols: "categoryCards", gap: "md", children: Array.from({ length: 10 }).map((_, i) => (_jsxs(Div, { className: `${__O.hidden} animate-pulse`, border: "subtle", rounded: "xl", children: [_jsx(Div, { className: "aspect-[4/3]", surface: "subtle" }), _jsxs(Stack, { className: `${__P.p3}.5`, gap: "sm", children: [_jsx(Div, { className: "h-3.5 w-3/4", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-3 w-full", surface: "subtle", rounded: "default" })] })] }, i))) }));
|
|
25
31
|
}
|
|
26
32
|
if (categories.length === 0) {
|
|
27
|
-
return (_jsx(Text, { paddingY: "3xl", color: "muted", size: "sm", align: "start", children: activeSearch ? `No ${
|
|
33
|
+
return (_jsx(Text, { paddingY: "3xl", color: "muted", size: "sm", align: "start", children: activeSearch ? `No ${noun} matching "${activeSearch}"` : `No ${noun} found` }));
|
|
28
34
|
}
|
|
29
35
|
if (view === "list") {
|
|
30
36
|
return (_jsx(Stack, { className: "divide-y divide-zinc-100 divide-[var(--appkit-color-border-subtle)]", border: "subtle", rounded: "xl", children: categories.map((category) => _jsx(CategoryCard, { category: category, href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(category.slug)) }, category.id)) }));
|
|
@@ -131,12 +137,21 @@ export function CategoriesIndexListing({ brandsOnly = false }) {
|
|
|
131
137
|
const commitSearch = useCallback(() => {
|
|
132
138
|
table.set(TABLE_KEYS.QUERY, searchInput.trim());
|
|
133
139
|
}, [searchInput, table]);
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
+
/*
|
|
141
|
+
* Tab state. Five tabs, no "All" — see `category-tabs.ts`: "All" was the
|
|
142
|
+
* default and it was what rendered 1,185 undifferentiated rows.
|
|
143
|
+
*/
|
|
144
|
+
const rawTab = brandsOnly ? "brands" : table.get(TABLE_KEYS.TAB) || DEFAULT_CATEGORY_TAB;
|
|
145
|
+
const activeTab = (CATEGORY_TABS.some((t) => t.key === rawTab)
|
|
146
|
+
? rawTab
|
|
147
|
+
: DEFAULT_CATEGORY_TAB);
|
|
148
|
+
/*
|
|
149
|
+
* 🛑 The fetch stays unfiltered (`?flat=true`) and the TABS partition that one
|
|
150
|
+
* cached response — a per-tab query would turn one request into five for the
|
|
151
|
+
* same data. `isBrand` is still sent for the brands tab because the API has a
|
|
152
|
+
* dedicated clause for it; everything else is partitioned below.
|
|
153
|
+
*/
|
|
154
|
+
const isBrandParam = activeTab === "brands" ? true : undefined;
|
|
140
155
|
const switchTab = (key) => {
|
|
141
156
|
table.set(TABLE_KEYS.TAB, key);
|
|
142
157
|
};
|
|
@@ -144,6 +159,8 @@ export function CategoriesIndexListing({ brandsOnly = false }) {
|
|
|
144
159
|
q: table.get(TABLE_KEYS.QUERY) || undefined,
|
|
145
160
|
isFeatured: table.get(TABLE_KEYS.IS_FEATURED) === "true" || undefined,
|
|
146
161
|
isBrand: isBrandParam,
|
|
162
|
+
// The tab partition — applied before pagination inside the hook.
|
|
163
|
+
predicate: (c) => matchesCategoryTab(c, activeTab),
|
|
147
164
|
rootOnly: table.get("rootOnly") === "true" || undefined,
|
|
148
165
|
tier: getNumericParam(table, "tier"),
|
|
149
166
|
minProductCount: getNumericParam(table, "minItemCount"),
|
|
@@ -153,11 +170,7 @@ export function CategoriesIndexListing({ brandsOnly = false }) {
|
|
|
153
170
|
pageSize: PAGE_SIZE,
|
|
154
171
|
});
|
|
155
172
|
const activeSearch = table.get(TABLE_KEYS.QUERY) || "";
|
|
156
|
-
const TABS =
|
|
157
|
-
{ key: "all", label: "All" },
|
|
158
|
-
{ key: "categories", label: "Categories" },
|
|
159
|
-
{ key: "brands", label: "Brands" },
|
|
160
|
-
];
|
|
173
|
+
const TABS = CATEGORY_TABS;
|
|
161
174
|
return (_jsxs(Div, { className: "min-h-screen", children: [!brandsOnly && (_jsx(Row, { gap: "xs", border: "default", className: "border-b mb-2", children: TABS.map((tab) => (_jsx(Button, { variant: "ghost", type: "button", onClick: () => switchTab(tab.key), paddingX: "md", paddingY: "y-xs-tall", textSize: "sm", weight: "medium", className: [
|
|
162
175
|
"transition-colors border-b-2 -mb-px rounded-none",
|
|
163
176
|
activeTab === tab.key
|
|
@@ -4,6 +4,7 @@ import { ArrowRight } from "lucide-react";
|
|
|
4
4
|
import { Div, Grid, Row, Span, Stack, Text } from "../../../ui/index.js";
|
|
5
5
|
import { DynamicBgDiv } from "../../../ui/components/DynamicBgDiv.js";
|
|
6
6
|
import { MediaImage } from "../../media/MediaImage.js";
|
|
7
|
+
import { ImageMosaic, MOSAIC_MIN } from "../../../ui/components/ImageMosaic.js";
|
|
7
8
|
const __P = {
|
|
8
9
|
p3: "p-[var(--appkit-space-3)]",
|
|
9
10
|
};
|
|
@@ -17,7 +18,7 @@ export function CategoryCard({ category, href, onClick, className = "", }) {
|
|
|
17
18
|
// own-node-only productCount so a parent category's card shows the same
|
|
18
19
|
// "N products" a visitor actually sees once they open the page.
|
|
19
20
|
const productCount = category.metrics?.totalProductCount ?? category.metrics?.productCount ?? category.productCount ?? 0;
|
|
20
|
-
const inner = (_jsxs(Stack, { className: "h-full", children: [_jsxs(Div, { surface: "muted", className: `relative aspect-[4/3] w-full ${__O.hidden} flex-shrink-0`, children: [category.display?.coverImage ? (_jsx(MediaImage, { src: category.display.coverImage, alt: category.name, size: "card", className: "transition-transform duration-300 group-hover:scale-105" })) : category.display?.color ? (_jsx(DynamicBgDiv, { color: category.display.color, className: "h-full w-full opacity-80" })) : null, category.display?.icon && (_jsx(Row, { textSize: "4xl", className: "absolute inset-0", align: "center", justify: "center", children: category.display.icon })), category.isFeatured && (_jsx(Span, { size: "xs", className: CLS_FEATURED_DOT, children: "\u2605" }))] }), _jsxs(Stack, { className: `flex-1 ${__P.p3}.5`, children: [_jsx(Text, { color: "inverse", className: `leading-snug text-[var(--appkit-color-text)] dark:`, truncate: 2, size: "sm", weight: "semibold", children: category.name }), category.description && (_jsx(Text, { className: `mt-1 flex-1`, color: "muted", truncate: 2, size: "xs", children: category.description })), _jsxs(Row, { className: "mt-2", align: "center", justify: "between", gap: "sm", children: [_jsxs(Text, { size: "xs", color: "faint", children: [productCount.toLocaleString(), " ", productCount === 1 ? "item" : "items"] }), _jsxs(Span, { layout: "inline-flex", gap: "xs", size: "xs", weight: "medium", border: "default", className: "group-hover:bg-primary group-hover:border-primary group-hover:text-white transition-colors", rounded: "md", padding: "pill-sm-tall", color: "muted", children: ["Browse ", _jsx(ArrowRight, { className: "h-3 w-3" })] })] })] })] }));
|
|
21
|
+
const inner = (_jsxs(Stack, { className: "h-full", children: [_jsxs(Div, { surface: "muted", className: `relative aspect-[4/3] w-full ${__O.hidden} flex-shrink-0`, children: [(category.display?.mosaicImages?.length ?? 0) >= MOSAIC_MIN ? (_jsx(ImageMosaic, { images: category.display.mosaicImages, alt: category.name, className: "h-full w-full" })) : category.display?.coverImage ? (_jsx(MediaImage, { src: category.display.coverImage, alt: category.name, size: "card", className: "transition-transform duration-300 group-hover:scale-105" })) : category.display?.color ? (_jsx(DynamicBgDiv, { color: category.display.color, className: "h-full w-full opacity-80" })) : null, category.display?.icon && (_jsx(Row, { textSize: "4xl", className: "absolute inset-0", align: "center", justify: "center", children: category.display.icon })), category.isFeatured && (_jsx(Span, { size: "xs", className: CLS_FEATURED_DOT, children: "\u2605" }))] }), _jsxs(Stack, { className: `flex-1 ${__P.p3}.5`, children: [_jsx(Text, { color: "inverse", className: `leading-snug text-[var(--appkit-color-text)] dark:`, truncate: 2, size: "sm", weight: "semibold", children: category.name }), category.description && (_jsx(Text, { className: `mt-1 flex-1`, color: "muted", truncate: 2, size: "xs", children: category.description })), _jsxs(Row, { className: "mt-2", align: "center", justify: "between", gap: "sm", children: [_jsxs(Text, { size: "xs", color: "faint", children: [productCount.toLocaleString(), " ", productCount === 1 ? "item" : "items"] }), _jsxs(Span, { layout: "inline-flex", gap: "xs", size: "xs", weight: "medium", border: "default", className: "group-hover:bg-primary group-hover:border-primary group-hover:text-white transition-colors", rounded: "md", padding: "pill-sm-tall", color: "muted", children: ["Browse ", _jsx(ArrowRight, { className: "h-3 w-3" })] })] })] })] }));
|
|
21
22
|
const cardClass = `group relative flex flex-col overflow-hidden rounded-xl border border-neutral-200 bg-[var(--appkit-color-surface)] border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] shadow-sm transition hover:shadow-md h-full ${className}`;
|
|
22
23
|
if (href) {
|
|
23
24
|
return (_jsx(Link, { href: href, className: cardClass, children: inner }));
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { CategoryDocument } from "../schemas";
|
|
2
|
+
/**
|
|
3
|
+
* A FRANCHISE page — every listing from one IP.
|
|
4
|
+
*
|
|
5
|
+
* 🛑 Products are matched on `franchiseSlug`, NOT on the display name. That is
|
|
6
|
+
* the one place this deliberately diverges from `BrandDetailPageView`, which
|
|
7
|
+
* filters `sieveFilter("brand", EQ, brandName)` and therefore orphans a brand's
|
|
8
|
+
* entire catalogue the moment anyone renames it (`_helpers/brand-rows.ts`,
|
|
9
|
+
* trap 1). Franchise is new, so it starts with the stable key.
|
|
10
|
+
*
|
|
11
|
+
* 🛑 An INACTIVE franchise still renders. Seven of the eight seeded rows have
|
|
12
|
+
* no stock and are inactive precisely so they stay out of the grid — but their
|
|
13
|
+
* pages must resolve, because `CharacterHotspot` ships eighteen `/franchise/*`
|
|
14
|
+
* links that were 404ing. An inactive row renders an honest "nothing listed
|
|
15
|
+
* yet" rather than a dead end or a lie.
|
|
16
|
+
*
|
|
17
|
+
* @tag domain:categories
|
|
18
|
+
* @tag layer:view
|
|
19
|
+
* @tag access:server-only
|
|
20
|
+
*/
|
|
21
|
+
export interface FranchiseDetailPageViewProps {
|
|
22
|
+
slug: string;
|
|
23
|
+
franchise: CategoryDocument;
|
|
24
|
+
}
|
|
25
|
+
export declare function FranchiseDetailPageView({ slug, franchise, }: FranchiseDetailPageViewProps): Promise<import("react").JSX.Element>;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { listPublicProducts } from "../../../_internal/server/features/products/list-public.js";
|
|
3
|
+
import { toProductItem } from "../../../_internal/shared/features/products/to-product-item.js";
|
|
4
|
+
import { safeRead } from "../../../errors/safe-read.js";
|
|
5
|
+
import { hidePublicTestData } from "../../../_internal/server/features/tester/visibility.js";
|
|
6
|
+
import { Container, Row, Stack } from "../../../ui/components/Layout.js";
|
|
7
|
+
import { Main, Section } from "../../../ui/components/Semantic.js";
|
|
8
|
+
import { Heading, Text } from "../../../ui/components/Typography.js";
|
|
9
|
+
import { MediaImage } from "../../media/MediaImage.js";
|
|
10
|
+
import { ProductGrid } from "../../products/components/ProductGrid.js";
|
|
11
|
+
import { Alert } from "../../../ui/components/Alert.js";
|
|
12
|
+
const PAGE_SIZE = 24;
|
|
13
|
+
export async function FranchiseDetailPageView({ slug, franchise, }) {
|
|
14
|
+
const result = await safeRead(() => listPublicProducts({
|
|
15
|
+
franchiseSlug: slug,
|
|
16
|
+
page: 1,
|
|
17
|
+
pageSize: PAGE_SIZE,
|
|
18
|
+
}), { route: "/franchise/[slug]", key: "franchise.products", fallback: null });
|
|
19
|
+
/*
|
|
20
|
+
* Every scalar read off `result` happens HERE, before the narrowing below —
|
|
21
|
+
* `audit-projection-shadow` is right that reading the raw result after
|
|
22
|
+
* taking a projection of it is the shape that lets a repository document
|
|
23
|
+
* leak onward (Root Cause #70). These three are the only things the view
|
|
24
|
+
* needs from it.
|
|
25
|
+
*/
|
|
26
|
+
const readFailed = result === null;
|
|
27
|
+
const truncated = result?.truncated === true;
|
|
28
|
+
const reportedTotal = result?.total;
|
|
29
|
+
/*
|
|
30
|
+
* `hidePublicTestData` widens to its own `TestDataFlagged` shape, which has
|
|
31
|
+
* no index signature and so does not satisfy `toProductItem`'s
|
|
32
|
+
* `FirestoreDocument` parameter. The cast is at the adapter boundary and
|
|
33
|
+
* restores the type the repository actually returned — not a claim about
|
|
34
|
+
* unknown data.
|
|
35
|
+
*/
|
|
36
|
+
const rows = hidePublicTestData(result?.items ?? []);
|
|
37
|
+
const products = rows.map((r) => toProductItem(r));
|
|
38
|
+
const total = reportedTotal ?? products.length;
|
|
39
|
+
const cover = franchise.display?.coverImage;
|
|
40
|
+
return (_jsx(Main, { children: _jsx(Section, { padding: "y-2xl", children: _jsxs(Container, { size: "xl", children: [_jsxs(Row, { align: "center", gap: "comfortable", className: "mb-8", children: [cover ? (_jsx(MediaImage, { src: cover, alt: franchise.name ?? slug, size: "thumbnail", rounded: "lg" })) : null, _jsxs(Stack, { gap: "dense", children: [_jsx(Heading, { level: 1, color: "primary", size: "3xl", weight: "semibold", children: franchise.name }), franchise.description ? (_jsx(Text, { color: "muted", className: "max-w-2xl", children: franchise.description })) : null] })] }), franchise.isActive === false ? (_jsxs(Alert, { variant: "info", className: "mb-6", children: ["We are not stocking ", franchise.name, " yet. This page is here so the link works \u2014 check back, or browse what we do carry."] })) : null, readFailed ? (_jsxs(Alert, { variant: "warning", children: ["We could not load ", franchise.name, " listings just now. Please try again."] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { color: "muted", className: "mb-4", children: total === 0
|
|
41
|
+
? "No listings yet."
|
|
42
|
+
: `${total}${truncated ? "+" : ""} listing${total === 1 ? "" : "s"}` }), _jsx(ProductGrid, { products: products, emptyLabel: "No listings yet." })] }))] }) }) }));
|
|
43
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The FRANCHISE index — the IPs we carry.
|
|
3
|
+
*
|
|
4
|
+
* 🛑 ACTIVE ONLY, and that is the dead-facet guard. `brand-rows.ts` trap 3:
|
|
5
|
+
* "a row with no product is a DEAD FACET, and worse than absent — it renders
|
|
6
|
+
* as a card in the grid that opens an empty page." Seven of the eight seeded
|
|
7
|
+
* franchises have no stock yet; they stay out of here until one flag flips,
|
|
8
|
+
* while their detail pages still resolve for the links that point at them.
|
|
9
|
+
*
|
|
10
|
+
* @tag domain:categories
|
|
11
|
+
* @tag layer:view
|
|
12
|
+
* @tag access:server-only
|
|
13
|
+
*/
|
|
14
|
+
export declare function FranchisesIndexView(): Promise<import("react").JSX.Element>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { listActiveFranchises } from "../../../_internal/server/features/franchises/data.js";
|
|
3
|
+
import { safeRead } from "../../../errors/safe-read.js";
|
|
4
|
+
import { hidePublicTestData } from "../../../_internal/server/features/tester/visibility.js";
|
|
5
|
+
import { Container, Grid } from "../../../ui/components/Layout.js";
|
|
6
|
+
import { Main, Section } from "../../../ui/components/Semantic.js";
|
|
7
|
+
import { Heading, Text } from "../../../ui/components/Typography.js";
|
|
8
|
+
import { Div } from "../../../ui/components/Div.js";
|
|
9
|
+
import { TextLink } from "../../../ui/components/TextLink.js";
|
|
10
|
+
import { MediaImage } from "../../media/MediaImage.js";
|
|
11
|
+
import { Alert } from "../../../ui/components/Alert.js";
|
|
12
|
+
import { ROUTES } from "../../../next/routing/route-map.js";
|
|
13
|
+
/**
|
|
14
|
+
* The FRANCHISE index — the IPs we carry.
|
|
15
|
+
*
|
|
16
|
+
* 🛑 ACTIVE ONLY, and that is the dead-facet guard. `brand-rows.ts` trap 3:
|
|
17
|
+
* "a row with no product is a DEAD FACET, and worse than absent — it renders
|
|
18
|
+
* as a card in the grid that opens an empty page." Seven of the eight seeded
|
|
19
|
+
* franchises have no stock yet; they stay out of here until one flag flips,
|
|
20
|
+
* while their detail pages still resolve for the links that point at them.
|
|
21
|
+
*
|
|
22
|
+
* @tag domain:categories
|
|
23
|
+
* @tag layer:view
|
|
24
|
+
* @tag access:server-only
|
|
25
|
+
*/
|
|
26
|
+
export async function FranchisesIndexView() {
|
|
27
|
+
const franchises = await safeRead(() => listActiveFranchises().then(hidePublicTestData), {
|
|
28
|
+
route: "/franchise",
|
|
29
|
+
key: "franchises.list",
|
|
30
|
+
fallback: null,
|
|
31
|
+
});
|
|
32
|
+
return (_jsx(Main, { children: _jsx(Section, { padding: "y-2xl", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 1, className: "mb-2", color: "primary", size: "3xl", weight: "semibold", children: "Franchises" }), _jsx(Text, { color: "muted", className: "mb-8", children: "The universes we stock. A franchise is what a collectible is from \u2014 a brand is who made it." }), franchises === null ? (_jsx(Alert, { variant: "warning", children: "We could not load the franchise list just now. Please try again." })) : franchises.length === 0 ? (_jsx(Alert, { variant: "info", children: "No franchises are live yet." })) : (_jsx(Grid, { cols: "cards", gap: "comfortable", children: franchises.map((f) => (_jsx(TextLink, { href: ROUTES.PUBLIC.FRANCHISE_DETAIL(String(f.slug ?? f.id)), children: _jsxs(Div, { surface: "card", padding: "card", rounded: "lg", border: "default", children: [f.display?.coverImage ? (_jsx(MediaImage, { src: f.display.coverImage, alt: f.name ?? "", size: "card", rounded: "md", className: "mb-3" })) : null, _jsx(Heading, { level: 2, size: "lg", weight: "semibold", children: f.name }), f.description ? (_jsx(Text, { color: "muted", size: "sm", className: "mt-1", truncate: 2, children: f.description })) : null] }) }, f.id))) }))] }) }) }));
|
|
33
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How the categories index splits into tabs — ONE definition of each predicate.
|
|
3
|
+
*
|
|
4
|
+
* ## 🛑 The index shows ROOTS, not everything
|
|
5
|
+
*
|
|
6
|
+
* `/categories` used to render the whole flat forest: 1,185 rows, of which 752
|
|
7
|
+
* are part leaves and 220 are model leaves. A visitor met a wall of names with
|
|
8
|
+
* no sense of where they were. The Categories tab now shows the SIX roots and
|
|
9
|
+
* you walk down — parent to child to leaf — which is how every catalogue of
|
|
10
|
+
* this shape is browsed, and what the category detail page already supports
|
|
11
|
+
* (it fetches every descendant and renders them tier-grouped).
|
|
12
|
+
*
|
|
13
|
+
* ## 🛑 Parts cannot be matched on `categoryType`
|
|
14
|
+
*
|
|
15
|
+
* Brands, franchises and bundles each carry a discriminator. Parts deliberately
|
|
16
|
+
* DO NOT — `gen-category-parts.mjs` omits `categoryType` because 92 files read
|
|
17
|
+
* it and a new value opts a row out of every path that works. So a part is
|
|
18
|
+
* identified by its ANCESTRY: a leaf whose chain passes through one of the four
|
|
19
|
+
* `*-parts` nodes. That is a fact about where it lives, not a flag that can
|
|
20
|
+
* drift from it.
|
|
21
|
+
*
|
|
22
|
+
* ## Why the tabs partition ONE payload
|
|
23
|
+
*
|
|
24
|
+
* The listing already fetches `?flat=true` once. Deriving all five tabs from
|
|
25
|
+
* that single cached response costs no extra request — a per-tab query would
|
|
26
|
+
* turn one fetch into five for the same data.
|
|
27
|
+
*
|
|
28
|
+
* @tag domain:categories
|
|
29
|
+
* @tag layer:constants
|
|
30
|
+
* @tag pattern:shared-literal
|
|
31
|
+
* @tag sideEffects:none
|
|
32
|
+
*/
|
|
33
|
+
/** The tier-2 nodes every part leaf hangs beneath. */
|
|
34
|
+
export declare const PART_PARENT_IDS: readonly string[];
|
|
35
|
+
export type CategoryTabKey = "categories" | "brands" | "franchises" | "parts" | "bundles";
|
|
36
|
+
export interface CategoryTabDef {
|
|
37
|
+
key: CategoryTabKey;
|
|
38
|
+
label: string;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* 🛑 No "All" tab. It was the default and it was the problem — it is what
|
|
42
|
+
* rendered 1,185 undifferentiated rows. Every tab here answers a question
|
|
43
|
+
* ("which parts do you have?"); "all" answers none of them.
|
|
44
|
+
*/
|
|
45
|
+
export declare const CATEGORY_TABS: readonly CategoryTabDef[];
|
|
46
|
+
export declare const DEFAULT_CATEGORY_TAB: CategoryTabKey;
|
|
47
|
+
/** The minimum shape these predicates read. */
|
|
48
|
+
interface TabbableCategory {
|
|
49
|
+
categoryType?: string | null;
|
|
50
|
+
tier?: number | null;
|
|
51
|
+
parentIds?: readonly string[] | null;
|
|
52
|
+
isLeaf?: boolean | null;
|
|
53
|
+
}
|
|
54
|
+
/** A leaf whose ancestry passes through a `*-parts` node. */
|
|
55
|
+
export declare function isPartCategory(c: TabbableCategory): boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Does this row belong on the given tab?
|
|
58
|
+
*
|
|
59
|
+
* 🛑 The Categories tab is ROOTS ONLY (`tier === 0`). Brands, franchises and
|
|
60
|
+
* bundles are tier-0 too but carry a discriminator, so the type check has to
|
|
61
|
+
* come first or they would appear under Categories as well.
|
|
62
|
+
*/
|
|
63
|
+
export declare function matchesCategoryTab(c: TabbableCategory, tab: CategoryTabKey): boolean;
|
|
64
|
+
export {};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How the categories index splits into tabs — ONE definition of each predicate.
|
|
3
|
+
*
|
|
4
|
+
* ## 🛑 The index shows ROOTS, not everything
|
|
5
|
+
*
|
|
6
|
+
* `/categories` used to render the whole flat forest: 1,185 rows, of which 752
|
|
7
|
+
* are part leaves and 220 are model leaves. A visitor met a wall of names with
|
|
8
|
+
* no sense of where they were. The Categories tab now shows the SIX roots and
|
|
9
|
+
* you walk down — parent to child to leaf — which is how every catalogue of
|
|
10
|
+
* this shape is browsed, and what the category detail page already supports
|
|
11
|
+
* (it fetches every descendant and renders them tier-grouped).
|
|
12
|
+
*
|
|
13
|
+
* ## 🛑 Parts cannot be matched on `categoryType`
|
|
14
|
+
*
|
|
15
|
+
* Brands, franchises and bundles each carry a discriminator. Parts deliberately
|
|
16
|
+
* DO NOT — `gen-category-parts.mjs` omits `categoryType` because 92 files read
|
|
17
|
+
* it and a new value opts a row out of every path that works. So a part is
|
|
18
|
+
* identified by its ANCESTRY: a leaf whose chain passes through one of the four
|
|
19
|
+
* `*-parts` nodes. That is a fact about where it lives, not a flag that can
|
|
20
|
+
* drift from it.
|
|
21
|
+
*
|
|
22
|
+
* ## Why the tabs partition ONE payload
|
|
23
|
+
*
|
|
24
|
+
* The listing already fetches `?flat=true` once. Deriving all five tabs from
|
|
25
|
+
* that single cached response costs no extra request — a per-tab query would
|
|
26
|
+
* turn one fetch into five for the same data.
|
|
27
|
+
*
|
|
28
|
+
* @tag domain:categories
|
|
29
|
+
* @tag layer:constants
|
|
30
|
+
* @tag pattern:shared-literal
|
|
31
|
+
* @tag sideEffects:none
|
|
32
|
+
*/
|
|
33
|
+
/** The tier-2 nodes every part leaf hangs beneath. */
|
|
34
|
+
export const PART_PARENT_IDS = [
|
|
35
|
+
"category-original-parts",
|
|
36
|
+
"category-metal-parts",
|
|
37
|
+
"category-burst-parts",
|
|
38
|
+
"category-x-parts",
|
|
39
|
+
];
|
|
40
|
+
/**
|
|
41
|
+
* 🛑 No "All" tab. It was the default and it was the problem — it is what
|
|
42
|
+
* rendered 1,185 undifferentiated rows. Every tab here answers a question
|
|
43
|
+
* ("which parts do you have?"); "all" answers none of them.
|
|
44
|
+
*/
|
|
45
|
+
export const CATEGORY_TABS = [
|
|
46
|
+
{ key: "categories", label: "Categories" },
|
|
47
|
+
{ key: "brands", label: "Brands" },
|
|
48
|
+
{ key: "franchises", label: "Franchises" },
|
|
49
|
+
{ key: "parts", label: "Parts" },
|
|
50
|
+
{ key: "bundles", label: "Bundles" },
|
|
51
|
+
];
|
|
52
|
+
export const DEFAULT_CATEGORY_TAB = "categories";
|
|
53
|
+
const typeOf = (c) => String(c.categoryType ?? "");
|
|
54
|
+
/** A leaf whose ancestry passes through a `*-parts` node. */
|
|
55
|
+
export function isPartCategory(c) {
|
|
56
|
+
if (typeOf(c))
|
|
57
|
+
return false; // brand / bundle / franchise / sublisting
|
|
58
|
+
return (c.parentIds ?? []).some((p) => PART_PARENT_IDS.includes(String(p)));
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Does this row belong on the given tab?
|
|
62
|
+
*
|
|
63
|
+
* 🛑 The Categories tab is ROOTS ONLY (`tier === 0`). Brands, franchises and
|
|
64
|
+
* bundles are tier-0 too but carry a discriminator, so the type check has to
|
|
65
|
+
* come first or they would appear under Categories as well.
|
|
66
|
+
*/
|
|
67
|
+
export function matchesCategoryTab(c, tab) {
|
|
68
|
+
const type = typeOf(c);
|
|
69
|
+
switch (tab) {
|
|
70
|
+
case "brands":
|
|
71
|
+
return type === "brand";
|
|
72
|
+
case "franchises":
|
|
73
|
+
return type === "franchise";
|
|
74
|
+
case "bundles":
|
|
75
|
+
return type === "bundle";
|
|
76
|
+
case "parts":
|
|
77
|
+
return isPartCategory(c);
|
|
78
|
+
case "categories":
|
|
79
|
+
/*
|
|
80
|
+
* A plain, top-level category. Parts are excluded by the type check
|
|
81
|
+
* being empty AND tier 0 — no part leaf is tier 0 — but a reader should
|
|
82
|
+
* not have to derive that, so the root test carries the meaning.
|
|
83
|
+
*/
|
|
84
|
+
return type === "" && Number(c.tier ?? -1) === 0;
|
|
85
|
+
default:
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -20,6 +20,20 @@ export interface CategoriesFilteredParams {
|
|
|
20
20
|
sort?: string;
|
|
21
21
|
page?: number;
|
|
22
22
|
pageSize?: number;
|
|
23
|
+
/**
|
|
24
|
+
* Tab partition, applied BEFORE pagination.
|
|
25
|
+
*
|
|
26
|
+
* 🛑 Before, not after — the hook paginates internally, so filtering the
|
|
27
|
+
* returned page would show a short grid and a page count for a different,
|
|
28
|
+
* larger set. It is a predicate rather than another flag because the Parts
|
|
29
|
+
* tab cannot be expressed as one: a part carries no `categoryType` and is
|
|
30
|
+
* identified by its ancestry (`category-tabs.ts`).
|
|
31
|
+
*
|
|
32
|
+
* Not part of the react-query key, deliberately — the FETCH is the same
|
|
33
|
+
* unfiltered `?flat=true` for every tab, so keying on it would mint five
|
|
34
|
+
* cache entries for one response.
|
|
35
|
+
*/
|
|
36
|
+
predicate?: (c: CategoryItem) => boolean;
|
|
23
37
|
}
|
|
24
38
|
/** API-driven for structured filters; text search + pagination done in hook (Firestore substring limitation). */
|
|
25
39
|
export declare function useCategoriesFiltered(params?: CategoriesFilteredParams): {
|
|
@@ -45,6 +45,8 @@ export function useCategoriesFiltered(params = {}) {
|
|
|
45
45
|
items = items.filter((c) => c.name.toLowerCase().includes(lq) || (c.description ?? "").toLowerCase().includes(lq));
|
|
46
46
|
if (params.isBrand === false)
|
|
47
47
|
items = items.filter((c) => !c.isBrand);
|
|
48
|
+
if (params.predicate)
|
|
49
|
+
items = items.filter(params.predicate);
|
|
48
50
|
if (minProductCount !== undefined)
|
|
49
51
|
items = items.filter((c) => (c.productCount ?? 0) >= minProductCount);
|
|
50
52
|
if (maxProductCount !== undefined)
|
|
@@ -303,6 +303,21 @@ export declare class CategoriesRepository extends BaseRepository<CategoryDocumen
|
|
|
303
303
|
* Replaces the old `brandsRepository.findActive()` call site.
|
|
304
304
|
*/
|
|
305
305
|
findActiveBrands(): Promise<CategoryDocument[]>;
|
|
306
|
+
/**
|
|
307
|
+
* The FRANCHISE rows a visitor may browse — what a product is FROM, where a
|
|
308
|
+
* brand is who made it.
|
|
309
|
+
*
|
|
310
|
+
* 🛑 `isActive == true` is the whole guard against a dead facet. Seven of the
|
|
311
|
+
* eight seeded franchises have no stock yet and are seeded INACTIVE on
|
|
312
|
+
* purpose: their pages resolve (so `/franchise/marvel` is not a 404) but they
|
|
313
|
+
* are kept out of the grid, because a card that opens an empty shop is worse
|
|
314
|
+
* than no card — the rule `_helpers/brand-rows.ts` states as trap 3.
|
|
315
|
+
*
|
|
316
|
+
* Same query shape as `findActiveBrands`, so it rides the SAME
|
|
317
|
+
* (categoryType, isActive, order) composite index rather than needing a new
|
|
318
|
+
* one.
|
|
319
|
+
*/
|
|
320
|
+
findActiveFranchises(): Promise<CategoryDocument[]>;
|
|
306
321
|
/**
|
|
307
322
|
* SB-UNI-B — derive the canonical `sublisting-{slug}` ID from a
|
|
308
323
|
* human-entered category name.
|