@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.
Files changed (42) 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/CategoriesIndexListing.js +25 -12
  13. package/dist/features/categories/components/CategoryGrid.js +2 -1
  14. package/dist/features/categories/components/FranchiseDetailPageView.d.ts +25 -0
  15. package/dist/features/categories/components/FranchiseDetailPageView.js +43 -0
  16. package/dist/features/categories/components/FranchisesIndexView.d.ts +14 -0
  17. package/dist/features/categories/components/FranchisesIndexView.js +33 -0
  18. package/dist/features/categories/constants/category-tabs.d.ts +64 -0
  19. package/dist/features/categories/constants/category-tabs.js +88 -0
  20. package/dist/features/categories/hooks/useCategories.d.ts +14 -0
  21. package/dist/features/categories/hooks/useCategories.js +2 -0
  22. package/dist/features/categories/repository/categories.repository.d.ts +15 -0
  23. package/dist/features/categories/repository/categories.repository.js +23 -0
  24. package/dist/features/categories/types/index.d.ts +13 -1
  25. package/dist/features/collections/schemas/index.d.ts +2 -2
  26. package/dist/features/pre-orders/schemas/index.d.ts +2 -2
  27. package/dist/features/products/repository/products.repository.d.ts +4 -0
  28. package/dist/features/products/repository/products.repository.js +5 -0
  29. package/dist/features/products/schemas/firestore.d.ts +13 -0
  30. package/dist/index.d.ts +1 -0
  31. package/dist/index.js +3 -0
  32. package/dist/next/routing/route-map.d.ts +4 -0
  33. package/dist/next/routing/route-map.js +9 -0
  34. package/dist/seed/_helpers/franchise-rows.d.ts +43 -0
  35. package/dist/seed/_helpers/franchise-rows.js +181 -0
  36. package/dist/seed/categories-seed-data.js +42 -1
  37. package/dist/seed/index.d.ts +1 -0
  38. package/dist/seed/index.js +1 -0
  39. package/dist/server.d.ts +1 -0
  40. package/dist/server.js +1 -0
  41. package/package.json +1 -1
  42. package/scripts/seed-cli.mjs +32 -2
@@ -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;
@@ -10,6 +10,18 @@ export interface CategoryDisplay {
10
10
  coverImage?: string;
11
11
  color?: string;
12
12
  showInMenu?: boolean;
13
+ /**
14
+ * Member images for a collage, most important first — what a BUNDLE actually
15
+ * looks like, which is its contents rather than one tile.
16
+ *
17
+ * 🛑 Derived at SEED time from the members' own `mainImage`, not fetched. A
18
+ * card cannot resolve `bundleProductIds` into images without one query per
19
+ * bundle per render, and a grid of five bundles would then be five extra
20
+ * reads for decoration. `<ImageMosaic>` renders nothing below `MOSAIC_MIN`
21
+ * (3), so a short list degrades to the cover image rather than a broken
22
+ * collage.
23
+ */
24
+ mosaicImages?: string[];
13
25
  }
14
26
  export interface CategoryMetrics {
15
27
  productCount: number;
@@ -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",
@@ -0,0 +1,43 @@
1
+ import type { CategoryDocument } from "../../features/categories/schemas";
2
+ export interface FranchiseSeedInput {
3
+ /** Without the `franchise-` prefix. Products match on this, never the name. */
4
+ slug: string;
5
+ name: string;
6
+ description: string;
7
+ /** Live only when real stock exists — see the dead-facet rule above. */
8
+ active: boolean;
9
+ featured?: boolean;
10
+ keywords: string[];
11
+ seoDescription: string;
12
+ }
13
+ export declare function franchiseRow(input: FranchiseSeedInput, order: number, stamps: {
14
+ createdBy: string;
15
+ createdAt: Date;
16
+ updatedAt: Date;
17
+ }): Partial<CategoryDocument>;
18
+ export declare function buildFranchiseRows(stamps: {
19
+ createdBy: string;
20
+ createdAt: Date;
21
+ updatedAt: Date;
22
+ }): Partial<CategoryDocument>[];
23
+ export declare const FRANCHISE_ROW_COUNT: number;
24
+ /** The slugs a product's `franchiseSlug` may point at. */
25
+ export declare const FRANCHISE_SLUGS: readonly string[];
26
+ /**
27
+ * Category ROOT -> the franchise everything beneath it belongs to.
28
+ *
29
+ * Derived rather than stamped per product: a product already carries its full
30
+ * ancestor chain, so the root is free and cannot drift from the taxonomy the
31
+ * way a hand-written field would (Root Cause #42).
32
+ *
33
+ * 🛑 Deliberately PARTIAL. `category-living-collectibles` is absent because a
34
+ * juniper bonsai and a leucistic axolotl are not from an IP — they are living
35
+ * things. Mapping every root for completeness' sake would assert a franchise
36
+ * that does not exist, and "no franchise" is the correct answer for 4 of the
37
+ * 1,308 seeded products. A root with no entry yields no franchise, not a
38
+ * fallback.
39
+ */
40
+ export declare const FRANCHISE_BY_CATEGORY_ROOT: Readonly<Record<string, {
41
+ slug: string;
42
+ name: string;
43
+ }>>;
@@ -0,0 +1,181 @@
1
+ /*
2
+ * WHY: a product's BRAND is who made it (Takara-Tomy); its FRANCHISE is what
3
+ * it is from (Beyblade). The site had no way to express the second, while
4
+ * `CharacterHotspot` shipped eighteen `/franchise/*` links to a route that
5
+ * did not exist. This is that unit, modelled on brands per the explicit
6
+ * instruction — "like brands make franchise its own separate unit".
7
+ *
8
+ * WHAT: one factory plus eight declarations.
9
+ *
10
+ * EXPORTS:
11
+ * FRANCHISE_ROWS — Partial<CategoryDocument>[], categoryType:"franchise"
12
+ * FRANCHISE_ROW_COUNT — asserted by the seed so a silent drop is loud
13
+ *
14
+ * ── 🛑 THE DEAD-FACET RULE, AND HOW THESE OBEY IT ─────────────────────────
15
+ *
16
+ * `brand-rows.ts` trap 3 states it exactly: *"A brand row with no product is a
17
+ * DEAD FACET, and worse than absent — it renders as a card in the grid that
18
+ * opens an empty page."* Seven of the eight rows below have no stock today,
19
+ * because this catalogue sells Beyblade.
20
+ *
21
+ * So they are seeded **`isActive: false`**. That is not a soft delete — the row
22
+ * exists, it is editable in the admin category editor, its page resolves, and
23
+ * `/franchise/marvel` stops being a 404. It is excluded from the grid and from
24
+ * search until it has something to sell, at which point one flag flips.
25
+ *
26
+ * The alternative readings were both worse: omitting them leaves eighteen links
27
+ * broken, and seeding them active puts seven empty shopfronts on a live grid.
28
+ *
29
+ * 🛑 `isActive` is therefore load-bearing here, not boilerplate. A future
30
+ * franchise row should be `false` until a product carries its `franchiseSlug`.
31
+ *
32
+ * ── Why tier-0 and outside the forest ─────────────────────────────────────
33
+ *
34
+ * Same as brands: `position` is NOT set, because these live outside the
35
+ * category forest's DFS numbering and a position inside it would corrupt the
36
+ * ranges `onCategoryWrite` shifts on insert.
37
+ *
38
+ * @tag domain:categories
39
+ * @tag layer:seed
40
+ * @tag pattern:shared-literal
41
+ * @tag access:server-only
42
+ * @tag consumers:seed/categories-seed-data.ts
43
+ * @tag sideEffects:none
44
+ */
45
+ /* 🛑 `constants/field-names`, NOT the schema copy — there are two
46
+ * `CATEGORY_FIELDS` and only this one carries `CATEGORY_TYPE_VALUES`.
47
+ * `categories-seed-data.ts` imports this one; matching it is what keeps the
48
+ * discriminator a single spelling. Same reasoning as `brand-rows.ts`. */
49
+ import { CATEGORY_FIELDS } from "../../constants/field-names.js";
50
+ import { seedPhoto } from "./media.js";
51
+ export function franchiseRow(input, order, stamps) {
52
+ const id = `franchise-${input.slug}`;
53
+ return {
54
+ id,
55
+ slug: id,
56
+ name: input.name,
57
+ categoryType: CATEGORY_FIELDS.CATEGORY_TYPE_VALUES.FRANCHISE,
58
+ description: input.description,
59
+ rootId: id,
60
+ parentIds: [],
61
+ childrenIds: [],
62
+ ancestors: [],
63
+ tier: 0,
64
+ path: id,
65
+ isLeaf: true,
66
+ order,
67
+ /* `icon` deliberately unset — the brands grid reads `display.icon` first
68
+ * and an emoji there would beat a real cover image (brand-rows trap 2). */
69
+ display: {
70
+ coverImage: seedPhoto(`franchise-cover-${input.slug}-20261014`, 800, 800),
71
+ showInMenu: false,
72
+ showInFooter: false,
73
+ },
74
+ isFeatured: input.featured ?? false,
75
+ isActive: input.active,
76
+ isSearchable: input.active,
77
+ createdBy: stamps.createdBy,
78
+ createdAt: stamps.createdAt,
79
+ updatedAt: stamps.updatedAt,
80
+ seo: {
81
+ title: `${input.name} | LetItRip`,
82
+ description: input.seoDescription,
83
+ keywords: input.keywords,
84
+ },
85
+ };
86
+ }
87
+ /*
88
+ * The eight. Beyblade is the catalogue; the other seven are the IPs
89
+ * `CharacterHotspot` already links to, seeded inactive so those links resolve
90
+ * without advertising an empty shop.
91
+ */
92
+ const INPUTS = [
93
+ {
94
+ slug: "beyblade",
95
+ name: "Beyblade",
96
+ description: "The spinning-top battling franchise — every generation from the original Spin Gear tops through Metal Fight, Burst and Beyblade X. Complete tops, loose parts, stadiums and accessories.",
97
+ active: true,
98
+ featured: true,
99
+ keywords: ["beyblade", "spinning tops", "burst", "metal fight", "beyblade x"],
100
+ seoDescription: "Shop Beyblade across every generation — tops, parts, stadiums and spares, new and pre-owned.",
101
+ },
102
+ {
103
+ slug: "marvel",
104
+ name: "Marvel",
105
+ description: "Figures, collectibles and merchandise from the Marvel universe.",
106
+ active: false,
107
+ keywords: ["marvel", "comics", "superhero", "figures"],
108
+ seoDescription: "Marvel collectibles and figures.",
109
+ },
110
+ {
111
+ slug: "dc-comics",
112
+ name: "DC Comics",
113
+ description: "Figures, collectibles and merchandise from the DC universe.",
114
+ active: false,
115
+ keywords: ["dc", "dc comics", "superhero", "figures"],
116
+ seoDescription: "DC Comics collectibles and figures.",
117
+ },
118
+ {
119
+ slug: "dragon-ball",
120
+ name: "Dragon Ball",
121
+ description: "Figures and collectibles from the Dragon Ball series.",
122
+ active: false,
123
+ keywords: ["dragon ball", "anime", "figures", "shonen"],
124
+ seoDescription: "Dragon Ball collectibles and figures.",
125
+ },
126
+ {
127
+ slug: "naruto",
128
+ name: "Naruto",
129
+ description: "Figures and collectibles from the Naruto series.",
130
+ active: false,
131
+ keywords: ["naruto", "anime", "figures", "shonen"],
132
+ seoDescription: "Naruto collectibles and figures.",
133
+ },
134
+ {
135
+ slug: "bleach",
136
+ name: "Bleach",
137
+ description: "Figures and collectibles from the Bleach series.",
138
+ active: false,
139
+ keywords: ["bleach", "anime", "figures", "shonen"],
140
+ seoDescription: "Bleach collectibles and figures.",
141
+ },
142
+ {
143
+ slug: "one-piece",
144
+ name: "One Piece",
145
+ description: "Figures and collectibles from the One Piece series.",
146
+ active: false,
147
+ keywords: ["one piece", "anime", "figures", "shonen"],
148
+ seoDescription: "One Piece collectibles and figures.",
149
+ },
150
+ {
151
+ slug: "one-punch-man",
152
+ name: "One Punch Man",
153
+ description: "Figures and collectibles from the One Punch Man series.",
154
+ active: false,
155
+ keywords: ["one punch man", "anime", "figures"],
156
+ seoDescription: "One Punch Man collectibles and figures.",
157
+ },
158
+ ];
159
+ export function buildFranchiseRows(stamps) {
160
+ return INPUTS.map((input, i) => franchiseRow(input, i + 1, stamps));
161
+ }
162
+ export const FRANCHISE_ROW_COUNT = INPUTS.length;
163
+ /** The slugs a product's `franchiseSlug` may point at. */
164
+ export const FRANCHISE_SLUGS = INPUTS.map((i) => `franchise-${i.slug}`);
165
+ /**
166
+ * Category ROOT -> the franchise everything beneath it belongs to.
167
+ *
168
+ * Derived rather than stamped per product: a product already carries its full
169
+ * ancestor chain, so the root is free and cannot drift from the taxonomy the
170
+ * way a hand-written field would (Root Cause #42).
171
+ *
172
+ * 🛑 Deliberately PARTIAL. `category-living-collectibles` is absent because a
173
+ * juniper bonsai and a leucistic axolotl are not from an IP — they are living
174
+ * things. Mapping every root for completeness' sake would assert a franchise
175
+ * that does not exist, and "no franchise" is the correct answer for 4 of the
176
+ * 1,308 seeded products. A root with no entry yields no franchise, not a
177
+ * fallback.
178
+ */
179
+ export const FRANCHISE_BY_CATEGORY_ROOT = {
180
+ "category-spinning-tops": { slug: "franchise-beyblade", name: "Beyblade" },
181
+ };
@@ -56,6 +56,7 @@ import { buildCategoryTree } from "./_helpers/category-tree.js";
56
56
  import { withCategorySearchTxt } from "./_helpers/search-txt-wrappers.js";
57
57
  import { CATEGORY_FOREST } from "./_helpers/category-forest.js";
58
58
  import { BRAND_ROW_COUNT, buildBrandRows } from "./_helpers/brand-rows.js";
59
+ import { FRANCHISE_ROW_COUNT, buildFranchiseRows } from "./_helpers/franchise-rows.js";
59
60
  import { MODEL_LEAF_COUNT } from "./_helpers/category-models.js";
60
61
  import { PART_LEAF_COUNT } from "./_helpers/category-parts.js";
61
62
  import { MARKET_BAND_LEAF_IDS } from "./_helpers/category-content.js";
@@ -195,6 +196,14 @@ const brandRows = buildBrandRows({
195
196
  createdAt: daysAgo(300),
196
197
  updatedAt: daysAgo(30),
197
198
  });
199
+ // FRANCHISE rows — what a product is FROM, where a brand is who MADE it.
200
+ // Same tier-0, outside-the-forest shape as brands. Seven of the eight are
201
+ // seeded INACTIVE on purpose; see the dead-facet rule in _helpers/franchise-rows.ts.
202
+ const franchiseRows = buildFranchiseRows({
203
+ createdBy: "user-admin-letitrip",
204
+ createdAt: daysAgo(300),
205
+ updatedAt: daysAgo(30),
206
+ });
198
207
  // P-17 — 5 bundle rows (categoryType:"bundle") grouping the Beyblade-minimal
199
208
  // standard products by generation, plus one cross-generation starter pack.
200
209
  // Every bundle references real seeded product ids (products-standard-seed-data.ts),
@@ -238,6 +247,34 @@ function bundleCategorySlugsFor(productIds) {
238
247
  }
239
248
  return out;
240
249
  }
250
+ /**
251
+ * Attach a bundle's member images so its card can render a collage.
252
+ *
253
+ * 🛑 A bundle IS its contents, so a single placeholder tile is the least
254
+ * informative thing a bundle card can show. Resolved here, at seed time, from
255
+ * the members' own `mainImage` — a card cannot do it at render time without one
256
+ * query per bundle, and a grid of five would be five reads for decoration.
257
+ *
258
+ * `<ImageMosaic>` renders nothing below `MOSAIC_MIN` (3), so a bundle with one
259
+ * or two resolvable members quietly falls back to its cover image rather than
260
+ * showing a broken collage. A member id that resolves to no product is dropped
261
+ * rather than defaulted — a wrong photo is worse than one fewer tile.
262
+ */
263
+ function withBundleMosaic(row) {
264
+ const ids = row.bundleProductIds ?? [];
265
+ // `row.display` must already exist — `CategoryDocumentDisplay` requires
266
+ // `showInFooter`, so spreading onto `{}` would drop a required field. Every
267
+ // bundle row declares one; a row without is left untouched rather than
268
+ // half-built.
269
+ if (ids.length === 0 || !row.display)
270
+ return row;
271
+ const images = ids
272
+ .map((id) => productsStandardSeedData.find((p) => p.id === id)?.mainImage)
273
+ .filter((src) => typeof src === "string" && src.length > 0);
274
+ if (images.length === 0)
275
+ return row;
276
+ return { ...row, display: { ...row.display, mosaicImages: images } };
277
+ }
241
278
  const bundleRows = [
242
279
  {
243
280
  id: "bundle-original-collectors-set",
@@ -504,7 +541,8 @@ const allCategoryRows = [
504
541
  ...rawCategories.map((c) => ({ ancestors: [], ...c, createdByType: "admin" })),
505
542
  ...sublistingRows.map((s) => ({ ancestors: [], ...s, createdByType: "admin" })),
506
543
  ...brandRows.map((b) => ({ ancestors: [], ...b, createdByType: "admin" })),
507
- ...bundleRows.map((b) => ({ ancestors: [], ...b })),
544
+ ...franchiseRows.map((f) => ({ ancestors: [], ...f, createdByType: "admin" })),
545
+ ...bundleRows.map((b) => ({ ancestors: [], ...withBundleMosaic(b) })),
508
546
  ]
509
547
  .map(withGeneratedContent)
510
548
  .map(withGeneratedCoverImage)
@@ -610,6 +648,9 @@ if (tier4Rows !== expectedTier4) {
610
648
  if (brandRows.length !== BRAND_ROW_COUNT) {
611
649
  throw new Error(`category seed: ${brandRows.length} brand rows built, expected ${BRAND_ROW_COUNT}`);
612
650
  }
651
+ if (franchiseRows.length !== FRANCHISE_ROW_COUNT) {
652
+ throw new Error(`category seed: ${franchiseRows.length} franchise rows built, expected ${FRANCHISE_ROW_COUNT}`);
653
+ }
613
654
  {
614
655
  /*
615
656
  * A price band keyed on a leaf that does not exist is dead weight that READS
@@ -79,6 +79,7 @@ export { groupedListingsSeedData } from "./grouped-listings-seed-data";
79
79
  export { mediaAssetsSeedData, MEDIA_ASSET_SEED_COUNT, } from "./media-assets-seed-data";
80
80
  export { productsCatalogueSeedData, CATALOGUE_LISTING_COUNT, } from "./products-catalogue-seed-data";
81
81
  export { GENERATED_PART_COLOURS, GENERATED_PART_COLOUR_COUNT, } from "./_helpers/part-colours-generated";
82
+ export { FRANCHISE_ROW_COUNT, FRANCHISE_SLUGS, FRANCHISE_BY_CATEGORY_ROOT, } from "./_helpers/franchise-rows";
82
83
  export { scammersSeedData } from "./scammers-seed-data";
83
84
  export { supportTicketsSeedData } from "./support-tickets-seed-data";
84
85
  export { testerChecklistSeedData, testerResponsesSeedData, } from "../features/tester/seed-data";
@@ -78,6 +78,7 @@ export { groupedListingsSeedData } from "./grouped-listings-seed-data.js";
78
78
  export { mediaAssetsSeedData, MEDIA_ASSET_SEED_COUNT, } from "./media-assets-seed-data.js";
79
79
  export { productsCatalogueSeedData, CATALOGUE_LISTING_COUNT, } from "./products-catalogue-seed-data.js";
80
80
  export { GENERATED_PART_COLOURS, GENERATED_PART_COLOUR_COUNT, } from "./_helpers/part-colours-generated.js";
81
+ export { FRANCHISE_ROW_COUNT, FRANCHISE_SLUGS, FRANCHISE_BY_CATEGORY_ROOT, } from "./_helpers/franchise-rows.js";
81
82
  export { scammersSeedData } from "./scammers-seed-data.js";
82
83
  export { supportTicketsSeedData } from "./support-tickets-seed-data.js";
83
84
  // Tester checklist/response seed data lives in ../features/tester/seed-data (isolated from
package/dist/server.d.ts CHANGED
@@ -509,6 +509,7 @@ export { getPreOrderForDetail, getProductFeaturesForPreOrder } from "./_internal
509
509
  export { getStoreForDetail, listStoreProductsInitial, listStoreAuctionsInitial, listStorePreOrdersInitial, listSitemapStores } from "./_internal/server/features/stores/index";
510
510
  export { toStoreDetail, toStoreListItem, PUBLIC_STORE_FIELDS, PRIVATE_STORE_FIELDS, type ToStoreDetailOptions, type ToStoreListItemOptions, type StoreProjectionSource, } from "./_internal/server/features/stores/adapters";
511
511
  export { getBrandForDetail } from "./_internal/server/features/brands/index";
512
+ export { getFranchiseForDetail, listActiveFranchises, renderFranchiseOg, } from "./_internal/server/features/franchises/index";
512
513
  export { getBlogPostForDetail } from "./_internal/server/features/blog/index";
513
514
  export { getEventForDetail } from "./_internal/server/features/events/index";
514
515
  export { getSublistingCategoryForDetail } from "./_internal/server/features/sublisting-categories/index";
package/dist/server.js CHANGED
@@ -1387,6 +1387,7 @@ export { getStoreForDetail, listStoreProductsInitial, listStoreAuctionsInitial,
1387
1387
  // may reach a client component or an HTTP response. See adapters.ts header.
1388
1388
  export { toStoreDetail, toStoreListItem, PUBLIC_STORE_FIELDS, PRIVATE_STORE_FIELDS, } from "./_internal/server/features/stores/adapters.js";
1389
1389
  export { getBrandForDetail } from "./_internal/server/features/brands/index.js";
1390
+ export { getFranchiseForDetail, listActiveFranchises, renderFranchiseOg, } from "./_internal/server/features/franchises/index.js";
1390
1391
  export { getBlogPostForDetail } from "./_internal/server/features/blog/index.js";
1391
1392
  export { getEventForDetail } from "./_internal/server/features/events/index.js";
1392
1393
  export { getSublistingCategoryForDetail } from "./_internal/server/features/sublisting-categories/index.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mohasinac/appkit",
3
- "version": "4.50.0",
3
+ "version": "4.52.0",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -195,6 +195,7 @@ const {
195
195
  productsPrizeDrawsSeedData, productsClassifiedsSeedData, productsDigitalCodesSeedData, productsLiveItemsSeedData,
196
196
  productsArtSeedData, productsStickersSeedData,
197
197
  productsCatalogueSeedData,
198
+ FRANCHISE_BY_CATEGORY_ROOT,
198
199
  ordersSeedData, reviewsSeedData, cartsSeedData, bidsSeedData,
199
200
  couponsSeedData, couponUsageSeedData,
200
201
  eventsSeedData, eventEntriesSeedData, payoutsSeedData, offersSeedData,
@@ -369,6 +370,35 @@ const COLLECTION_MAP = {
369
370
  * signal, so a silent 5% here would hide exactly what it is meant to surface —
370
371
  * and a wrong rate on a real invoice is worse than a failed load.
371
372
  */
373
+ /**
374
+ * Stamp `franchise` / `franchiseSlug` from the product's category ROOT.
375
+ *
376
+ * 🛑 Its OWN pass, deliberately NOT folded into `withDerivedCategoryFields`.
377
+ * That function early-returns on `gstRate && hsnCode && authenticity`, which
378
+ * every one of the 1,237 generated catalogue rows already satisfies — so a
379
+ * franchise derived in there would be silently skipped for 95% of the
380
+ * catalogue. That is the exact bug its own comment records happening when
381
+ * `authenticity` was added beside `gstRate`; adding a third field to the same
382
+ * condition is how it happens a third time.
383
+ *
384
+ * A root with no mapping yields NO franchise (living collectibles are not an
385
+ * IP) — see `FRANCHISE_BY_CATEGORY_ROOT`.
386
+ */
387
+ function withFranchise(products) {
388
+ let stamped = 0;
389
+ const out = products.map((p) => {
390
+ if (p.franchiseSlug) return p;
391
+ const chain = Array.isArray(p.categorySlugs) ? p.categorySlugs : [];
392
+ const root = chain.length > 0 ? String(chain[chain.length - 1]) : null;
393
+ const f = root ? FRANCHISE_BY_CATEGORY_ROOT[root] : null;
394
+ if (!f) return p;
395
+ stamped++;
396
+ return { ...p, franchise: f.name, franchiseSlug: f.slug };
397
+ });
398
+ console.log(` • franchise stamped on ${stamped}/${products.length} products`);
399
+ return out;
400
+ }
401
+
372
402
  function withDerivedCategoryFields(products) {
373
403
  const catById = new Map((categoriesSeedData || []).map((c) => [String(c.id), c]));
374
404
  const taxById = new Map((taxCodesSeedData || []).map((t) => [String(t.id), t]));
@@ -461,7 +491,7 @@ const SEED_DATA_MAP = {
461
491
  // standalone brands collection/seed exists anymore.
462
492
  categories: categoriesSeedData,
463
493
  stores: storesSeedData,
464
- products: withDerivedCategoryFields([
494
+ products: withFranchise(withDerivedCategoryFields([
465
495
  ...(productsStandardSeedData || []), ...(productsAuctionsSeedData || []), ...(productsPreordersSeedData || []),
466
496
  ...(productsPrizeDrawsSeedData || []), ...(productsClassifiedsSeedData || []), ...(productsDigitalCodesSeedData || []), ...(productsLiveItemsSeedData || []),
467
497
  // art + stickers were written, exported from the seed barrel, and then never
@@ -474,7 +504,7 @@ const SEED_DATA_MAP = {
474
504
  // reproducible after a full wipe; without it the collection drops from
475
505
  // 1,039 to 71 the moment someone purges it.
476
506
  ...(productsCatalogueSeedData || []),
477
- ]),
507
+ ])),
478
508
  orders: ordersSeedData,
479
509
  reviews: reviewsSeedData,
480
510
  bids: bidsSeedData,