@mohasinac/appkit 4.49.1 β†’ 4.51.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.
Files changed (44) hide show
  1. package/dist/.build-stamp.json +3 -3
  2. package/dist/_internal/server/features/franchises/data.d.ts +32 -0
  3. package/dist/_internal/server/features/franchises/data.js +33 -0
  4. package/dist/_internal/server/features/franchises/index.d.ts +2 -0
  5. package/dist/_internal/server/features/franchises/index.js +2 -0
  6. package/dist/_internal/server/features/franchises/og.d.ts +31 -0
  7. package/dist/_internal/server/features/franchises/og.js +11 -0
  8. package/dist/_internal/server/features/products/list-public.d.ts +9 -0
  9. package/dist/_internal/server/features/products/list-public.js +3 -0
  10. package/dist/constants/field-names.d.ts +3 -0
  11. package/dist/constants/field-names.js +11 -0
  12. package/dist/features/categories/components/FranchiseDetailPageView.d.ts +25 -0
  13. package/dist/features/categories/components/FranchiseDetailPageView.js +43 -0
  14. package/dist/features/categories/components/FranchisesIndexView.d.ts +14 -0
  15. package/dist/features/categories/components/FranchisesIndexView.js +33 -0
  16. package/dist/features/categories/repository/categories.repository.d.ts +15 -0
  17. package/dist/features/categories/repository/categories.repository.js +23 -0
  18. package/dist/features/categories/types/index.d.ts +1 -1
  19. package/dist/features/collections/schemas/index.d.ts +2 -2
  20. package/dist/features/pre-orders/schemas/index.d.ts +2 -2
  21. package/dist/features/products/repository/products.repository.d.ts +4 -0
  22. package/dist/features/products/repository/products.repository.js +5 -0
  23. package/dist/features/products/schemas/firestore.d.ts +13 -0
  24. package/dist/index.d.ts +1 -0
  25. package/dist/index.js +3 -0
  26. package/dist/next/routing/route-map.d.ts +4 -0
  27. package/dist/next/routing/route-map.js +9 -0
  28. package/dist/seed/_helpers/category-images-generated.d.ts +21 -1
  29. package/dist/seed/_helpers/category-images-generated.js +330 -1
  30. package/dist/seed/_helpers/franchise-rows.d.ts +43 -0
  31. package/dist/seed/_helpers/franchise-rows.js +181 -0
  32. package/dist/seed/_helpers/part-colours-generated.d.ts +18 -0
  33. package/dist/seed/_helpers/part-colours-generated.js +273 -0
  34. package/dist/seed/categories-seed-data.js +13 -0
  35. package/dist/seed/index.d.ts +2 -0
  36. package/dist/seed/index.js +2 -0
  37. package/dist/seed/media-assets-seed-data.d.ts +1 -1
  38. package/dist/seed/media-assets-seed-data.js +3091 -1
  39. package/dist/seed/products-catalogue-seed-data.d.ts +4 -4
  40. package/dist/seed/products-catalogue-seed-data.js +16796 -8776
  41. package/dist/server.d.ts +1 -0
  42. package/dist/server.js +1 -0
  43. package/package.json +1 -1
  44. package/scripts/seed-cli.mjs +32 -2
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "stampVersion": 1,
3
- "version": "4.49.1",
4
- "head": "bf2ce556216b8dcd3e3e5a009d83979d992a565b",
3
+ "version": "4.51.0",
4
+ "head": "8e22d42a3cf524b03f860462c5fe567bfbb621de",
5
5
  "branch": "main",
6
6
  "dirty": false,
7
- "builtAt": "2026-10-14T04:52:23.154Z"
7
+ "builtAt": "2026-10-14T12:00:13.325Z"
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,2 @@
1
+ export { getFranchiseForDetail, listActiveFranchises } from "./data";
2
+ export { renderFranchiseOg } from "./og";
@@ -0,0 +1,2 @@
1
+ export { getFranchiseForDetail, listActiveFranchises } from "./data.js";
2
+ export { renderFranchiseOg } from "./og.js";
@@ -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",
@@ -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
+ }
@@ -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.
@@ -1022,6 +1022,29 @@ export class CategoriesRepository extends BaseRepository {
1022
1022
  .get();
1023
1023
  return snap.docs.map((d) => this.mapDoc(d));
1024
1024
  }
1025
+ /**
1026
+ * The FRANCHISE rows a visitor may browse β€” what a product is FROM, where a
1027
+ * brand is who made it.
1028
+ *
1029
+ * πŸ›‘ `isActive == true` is the whole guard against a dead facet. Seven of the
1030
+ * eight seeded franchises have no stock yet and are seeded INACTIVE on
1031
+ * purpose: their pages resolve (so `/franchise/marvel` is not a 404) but they
1032
+ * are kept out of the grid, because a card that opens an empty shop is worse
1033
+ * than no card β€” the rule `_helpers/brand-rows.ts` states as trap 3.
1034
+ *
1035
+ * Same query shape as `findActiveBrands`, so it rides the SAME
1036
+ * (categoryType, isActive, order) composite index rather than needing a new
1037
+ * one.
1038
+ */
1039
+ async findActiveFranchises() {
1040
+ const snap = await this.db
1041
+ .collection(this.collection)
1042
+ .where(CATEGORY_FIELDS.CATEGORY_TYPE, "==", "franchise")
1043
+ .where(CATEGORY_FIELDS.IS_ACTIVE, "==", true)
1044
+ .orderBy(CATEGORY_FIELDS.ORDER, "asc")
1045
+ .get();
1046
+ return snap.docs.map((d) => this.mapDoc(d));
1047
+ }
1025
1048
  /*
1026
1049
  * πŸ›‘ `incrementViewCount` DELETED 2026-10-10 β€” zero callers since
1027
1050
  * 2026-08-31, when it was removed from the category-detail render path (a
@@ -1,4 +1,4 @@
1
- export type CategoryType = "category" | "sublisting" | "brand" | "bundle";
1
+ export type CategoryType = "category" | "sublisting" | "brand" | "bundle" | "franchise";
2
2
  export interface CategorySeo {
3
3
  title?: string;
4
4
  description?: string;
@@ -33,9 +33,9 @@ export declare const collectionItemSchema: z.ZodObject<{
33
33
  brandSlug?: string | undefined;
34
34
  productCount?: number | undefined;
35
35
  description?: string | undefined;
36
+ franchiseSlug?: string | undefined;
36
37
  subtitle?: string | undefined;
37
38
  bannerImage?: string | undefined;
38
- franchiseSlug?: string | undefined;
39
39
  manualProductIds?: string[] | undefined;
40
40
  }, {
41
41
  title: string;
@@ -46,10 +46,10 @@ export declare const collectionItemSchema: z.ZodObject<{
46
46
  productCount?: number | undefined;
47
47
  active?: boolean | undefined;
48
48
  description?: string | undefined;
49
+ franchiseSlug?: string | undefined;
49
50
  subtitle?: string | undefined;
50
51
  sortOrder?: number | undefined;
51
52
  bannerImage?: string | undefined;
52
- franchiseSlug?: string | undefined;
53
53
  manualProductIds?: string[] | undefined;
54
54
  }>;
55
55
  export declare const collectionListItemSchema: z.ZodObject<{
@@ -66,8 +66,8 @@ export declare const preorderItemSchema: z.ZodObject<{
66
66
  youtubeId?: string | undefined;
67
67
  duration?: number | undefined;
68
68
  }[] | undefined;
69
- images?: string[] | undefined;
70
69
  franchise?: string | undefined;
70
+ images?: string[] | undefined;
71
71
  preorderShipDate?: string | undefined;
72
72
  }, {
73
73
  id: string;
@@ -87,8 +87,8 @@ export declare const preorderItemSchema: z.ZodObject<{
87
87
  youtubeId?: string | undefined;
88
88
  duration?: number | undefined;
89
89
  }[] | undefined;
90
+ franchise?: string | undefined;
90
91
  images?: string[] | undefined;
91
92
  isFeatured?: boolean | undefined;
92
- franchise?: string | undefined;
93
93
  preorderShipDate?: string | undefined;
94
94
  }>;
@@ -398,6 +398,10 @@ export declare class ProductRepository extends BaseRepository<ProductDocument> {
398
398
  canFilter: boolean;
399
399
  canSort: boolean;
400
400
  };
401
+ franchiseSlug: {
402
+ canFilter: boolean;
403
+ canSort: boolean;
404
+ };
401
405
  sublistingCategoryId: {
402
406
  canFilter: boolean;
403
407
  canSort: boolean;
@@ -1519,6 +1519,11 @@ ProductRepository.SIEVE_FIELDS = {
1519
1519
  "physicalLocation.shelf": { canFilter: true, canSort: false },
1520
1520
  "physicalLocation.bin": { canFilter: true, canSort: false },
1521
1521
  brandSlug: { canFilter: true, canSort: false },
1522
+ /* πŸ›‘ Must be declared here or the clause is SILENTLY DROPPED β€” sieve.ts
1523
+ * sets `throwExceptions: false`, so an unallowlisted field does not raise,
1524
+ * it just vanishes and the franchise page returns the whole catalogue
1525
+ * (Root Cause #62). */
1526
+ franchiseSlug: { canFilter: true, canSort: false },
1522
1527
  sublistingCategoryId: { canFilter: true, canSort: false },
1523
1528
  createdAt: { canFilter: true, canSort: true, parseValue: parseSieveDateValue },
1524
1529
  updatedAt: { canFilter: true, canSort: true, parseValue: parseSieveDateValue },
@@ -216,6 +216,19 @@ export interface ProductDocument extends BaseDocument {
216
216
  subcategory?: string;
217
217
  brand?: string;
218
218
  brandSlug?: string;
219
+ /**
220
+ * The IP this product is FROM β€” "Beyblade", "PokΓ©mon". A sibling of `brand`
221
+ * (who MADE it), matching a `categoryType: "franchise"` row.
222
+ *
223
+ * πŸ›‘ `franchiseSlug` is the match key here, NOT the display name β€” the
224
+ * opposite of `brand`, which `BrandDetailPageView` filters on by display name
225
+ * and which therefore orphans its whole catalogue on a rename (see
226
+ * `_helpers/brand-rows.ts`, trap 1). Franchise is new, so it gets the
227
+ * correct shape from the start rather than inheriting that trap; `franchise`
228
+ * is the denormalised label for display only.
229
+ */
230
+ franchise?: string;
231
+ franchiseSlug?: string;
219
232
  price: number;
220
233
  /**
221
234
  * The strike-through "was β‚ΉX", for a listing the seller is discounting.
package/dist/index.d.ts CHANGED
@@ -651,6 +651,7 @@ export { historySeedData } from "./seed/index";
651
651
  export { groupedListingsSeedData } from "./seed/index";
652
652
  export { mediaAssetsSeedData, MEDIA_ASSET_SEED_COUNT } from "./seed/index";
653
653
  export { productsCatalogueSeedData, CATALOGUE_LISTING_COUNT, } from "./seed/index";
654
+ export { FRANCHISE_ROW_COUNT, FRANCHISE_SLUGS, FRANCHISE_BY_CATEGORY_ROOT, } from "./seed/index";
654
655
  export type { CouponDiscountType } from "./seed/index";
655
656
  export type { FirestoreIndexConfig } from "./seed/index";
656
657
  export type { HomepageSectionType } from "./seed/index";
package/dist/index.js CHANGED
@@ -1375,6 +1375,9 @@ export { mediaAssetsSeedData, MEDIA_ASSET_SEED_COUNT } from "./seed/index.js";
1375
1375
  // Same Root Cause #18 shape as mediaAssetsSeedData above: seed-cli.mjs imports
1376
1376
  // the bare "@mohasinac/appkit" package, which resolves through THIS file.
1377
1377
  export { productsCatalogueSeedData, CATALOGUE_LISTING_COUNT, } from "./seed/index.js";
1378
+ // Franchise β€” the IP a product is FROM (brand is who MADE it). Re-exported
1379
+ // from the outer barrel because seed-cli.mjs imports the bare package.
1380
+ export { FRANCHISE_ROW_COUNT, FRANCHISE_SLUGS, FRANCHISE_BY_CATEGORY_ROOT, } from "./seed/index.js";
1378
1381
  export { NOTIFICATION_TYPE_VALUES, NOTIFICATION_RELATED_TYPE_VALUES } from "./features/admin/schemas/firestore.js";
1379
1382
  // [UTIL]-Pure utility Ò€" no framework or runtime dependency; safe to import from any environment.
1380
1383
  // ErrorCategory - Shared export for error category.
@@ -54,6 +54,8 @@ export declare const DEFAULT_ROUTE_MAP: {
54
54
  readonly CATEGORY_DETAIL: (slug: string) => string;
55
55
  readonly BRANDS: "/brands";
56
56
  readonly BRAND_DETAIL: (slug: string) => string;
57
+ readonly FRANCHISE: "/franchise";
58
+ readonly FRANCHISE_DETAIL: (slug: string) => string;
57
59
  readonly REVIEWS: "/reviews";
58
60
  readonly REVIEW_DETAIL: (id: string) => string;
59
61
  readonly SEARCH: "/search";
@@ -468,6 +470,8 @@ export declare const ROUTES: {
468
470
  readonly CATEGORY_DETAIL: (slug: string) => string;
469
471
  readonly BRANDS: "/brands";
470
472
  readonly BRAND_DETAIL: (slug: string) => string;
473
+ readonly FRANCHISE: "/franchise";
474
+ readonly FRANCHISE_DETAIL: (slug: string) => string;
471
475
  readonly REVIEWS: "/reviews";
472
476
  readonly REVIEW_DETAIL: (id: string) => string;
473
477
  readonly SEARCH: "/search";
@@ -47,6 +47,15 @@ export const DEFAULT_ROUTE_MAP = {
47
47
  CATEGORY_DETAIL: (slug) => `/categories/${slug}`,
48
48
  BRANDS: "/brands",
49
49
  BRAND_DETAIL: (slug) => `/brands/${slug}`,
50
+ /*
51
+ * FRANCHISE β€” what a collectible is FROM, where a brand is who MADE it.
52
+ * Singular in the path because `CharacterHotspot` already ships eighteen
53
+ * `/franchise/<slug>` links; matching them is what turns those 404s into
54
+ * real pages, and changing the segment to `/franchises` would just move
55
+ * the breakage.
56
+ */
57
+ FRANCHISE: "/franchise",
58
+ FRANCHISE_DETAIL: (slug) => `/franchise/${slug}`,
50
59
  REVIEWS: "/reviews",
51
60
  REVIEW_DETAIL: (id) => `/reviews/${id}`,
52
61
  SEARCH: "/search",
@@ -7,12 +7,32 @@
7
7
  * a third-party URL (Root Cause #104 β€” that adds an upstream fetch to every
8
8
  * single render).
9
9
  *
10
+ * Covers BOTH complete-model leaves and PART leaves β€” they share one id space
11
+ * (`category-*`), so one map serves the category cover and, through
12
+ * `gen-catalogue-listings.mjs`, that leaf's product listing image too.
13
+ *
10
14
  * A hand-authored `display.coverImage` always wins over this map; a row absent
11
15
  * from it keeps no image rather than being given a placeholder. See
12
16
  * `withGeneratedCoverImage` in `categories-seed-data.ts`.
13
17
  *
18
+ * ── Replacing one of these with your own photograph ───────────────────────
19
+ *
20
+ * `CategoryForm` already edits `display.coverImage` (upload -> `/media/<id>`),
21
+ * so any of these can be swapped for an original at runtime.
22
+ *
23
+ * πŸ›‘ But a RUNTIME edit does not survive `appkit-seed load` β€” the loader
24
+ * merge-writes `display`, so the generated value comes back. To replace one
25
+ * permanently, set `display.coverImage` in the category SOURCE (the forest or
26
+ * the seed row); the hand-authored value then wins here forever. That is the
27
+ * deliberate trade for "the seed can always rebuild the database".
28
+ *
29
+ * Deleting them entirely: remove the `media/categories/` Storage prefix, delete
30
+ * this file and `media-assets-seed-data.ts`, re-run the category seed. Bucket
31
+ * versioning and soft-delete are both OFF, so a deleted object leaves no
32
+ * recoverable copy and no residual billing.
33
+ *
14
34
  * Source and licensing for every file:
15
35
  * `docs/research/bey-corpus-2026-10/image-provenance.json`.
16
36
  */
17
37
  export declare const GENERATED_CATEGORY_IMAGES: Readonly<Record<string, string>>;
18
- export declare const GENERATED_CATEGORY_IMAGE_COUNT = 195;
38
+ export declare const GENERATED_CATEGORY_IMAGE_COUNT = 504;