@mohasinac/appkit 4.53.1 → 4.53.2

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 (53) hide show
  1. package/dist/.build-stamp.json +3 -3
  2. package/dist/_internal/client/theme/ThemeProvider.js +15 -1
  3. package/dist/_internal/server/features/categories/price-guidance-resolve.d.ts +66 -0
  4. package/dist/_internal/server/features/categories/price-guidance-resolve.js +138 -0
  5. package/dist/_internal/server/features/price-index/market.d.ts +57 -0
  6. package/dist/_internal/server/features/price-index/market.js +120 -0
  7. package/dist/_internal/server/features/products/list-public.d.ts +12 -0
  8. package/dist/_internal/server/features/products/list-public.js +10 -1
  9. package/dist/_internal/server/features/products/listing-tab-counts.d.ts +25 -1
  10. package/dist/_internal/server/features/products/listing-tab-counts.js +128 -10
  11. package/dist/_internal/server/features/stores/tabs.d.ts +7 -2
  12. package/dist/_internal/server/features/stores/tabs.js +5 -3
  13. package/dist/_internal/shared/search/match.js +28 -2
  14. package/dist/constants/api-endpoints.d.ts +7 -0
  15. package/dist/constants/api-endpoints.js +7 -0
  16. package/dist/features/auctions/components/AuctionDetailPageView.js +8 -7
  17. package/dist/features/categories/api/[id]/price-guidance/route.js +1 -24
  18. package/dist/features/categories/components/BrandDetailPageView.js +14 -6
  19. package/dist/features/categories/components/BrandDetailTabs.d.ts +7 -1
  20. package/dist/features/categories/components/BrandDetailTabs.js +18 -3
  21. package/dist/features/categories/components/BundleCollage.js +3 -2
  22. package/dist/features/categories/components/BundleItemsPicker.js +3 -4
  23. package/dist/features/categories/components/CategoryDetailPageView.js +17 -7
  24. package/dist/features/categories/components/CategoryDetailTabs.d.ts +7 -1
  25. package/dist/features/categories/components/CategoryDetailTabs.js +29 -7
  26. package/dist/features/classified/components/ClassifiedDetailPageView.js +10 -1
  27. package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +10 -1
  28. package/dist/features/live/components/LiveItemDetailPageView.js +10 -1
  29. package/dist/features/pre-orders/components/PreOrderDetailPageView.js +8 -7
  30. package/dist/features/price-index/components/PriceIndexToolView.d.ts +21 -0
  31. package/dist/features/price-index/components/PriceIndexToolView.js +230 -27
  32. package/dist/features/price-index/lookup-target.d.ts +48 -0
  33. package/dist/features/price-index/lookup-target.js +107 -0
  34. package/dist/features/products/components/AvailabilityScopeHint.d.ts +22 -0
  35. package/dist/features/products/components/AvailabilityScopeHint.js +27 -0
  36. package/dist/features/products/components/CompareOverlay.d.ts +2 -1
  37. package/dist/features/products/components/CompareOverlay.js +2 -1
  38. package/dist/features/products/components/GroupSettingsPanel.js +2 -1
  39. package/dist/features/products/components/ProductDetailPageView.js +7 -2
  40. package/dist/features/products/components/SublistingCarouselSection.js +2 -1
  41. package/dist/features/products/repository/products.repository.d.ts +1 -0
  42. package/dist/features/products/repository/products.repository.js +5 -1
  43. package/dist/features/products/utils/group-member-image.d.ts +9 -2
  44. package/dist/features/products/utils/group-member-image.js +2 -1
  45. package/dist/features/seller/components/SellerProductsView.js +3 -2
  46. package/dist/features/stores/components/StoreAuctionsListing.js +2 -1
  47. package/dist/features/tester/seed-data/authored/content-discovery__authenticity-checker.js +114 -2
  48. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +12 -0
  49. package/dist/seed/products-beyblade-x-seed-data.js +36 -1
  50. package/dist/server.d.ts +2 -0
  51. package/dist/server.js +8 -0
  52. package/package.json +1 -1
  53. package/scripts/seed-cli.mjs +133 -27
@@ -11,9 +11,10 @@
11
11
  * the tab bar renders, so a tenth listing type is counted automatically.
12
12
  *
13
13
  * EXPORTS:
14
- * ListingTabScope — the surface's scope (category / brand / store)
15
- * ListingTabCounts — tabSlug -> count, `undefined` meaning "query failed"
16
- * listingTabCounts — resolve every tab's count in one parallel batch
14
+ * ListingTabScope — the surface's scope (category / brand / store)
15
+ * ListingTabCounts — tabSlug -> count, `undefined` meaning "unknown"
16
+ * ListingTabCountResult — the AVAILABLE and TOTAL maps, see below
17
+ * listingTabCounts — resolve every tab's counts in one parallel batch
17
18
  *
18
19
  * @tag domain:products,categories,stores
19
20
  * @tag layer:server
@@ -25,6 +26,11 @@
25
26
  import { categoriesRepository, productRepository } from "../../../../repositories/index.js";
26
27
  import { serverLogger } from "../../../../monitoring/index.js";
27
28
  import { normalizeError } from "../../../../errors/normalize.js";
29
+ import { sieveAnd, sieveFilter, SIEVE_OP, sortBy } from "../../../../utils/sieve-builder.js";
30
+ import { PRODUCT_FIELDS } from "../../../../constants/field-names.js";
31
+ import { isListingRowAvailable, unavailableClausesFor, } from "../../../shared/listing-types/_registry.js";
32
+ import { normalizeListingType } from "../../../../features/products/utils/listing-type.js";
33
+ import { clauseToSieve, PUBLIC_PRODUCT_MAX_PAGE_SIZE } from "./list-public.js";
28
34
  const DEFAULT_STATUS = "published";
29
35
  /** Bundles a category owns, resolved by the same rule the bundles tab renders. */
30
36
  async function countBundles(scope) {
@@ -65,6 +71,101 @@ function bundleMatchesScope(bundle, scope) {
65
71
  }
66
72
  return true;
67
73
  }
74
+ /**
75
+ * How many rows in scope are NOT available, per listing type.
76
+ *
77
+ * ── 🛑 WHY THIS IS NOT AN AGGREGATE COUNT ─────────────────────────────────
78
+ *
79
+ * Two independent reasons, either of which alone is disqualifying.
80
+ *
81
+ * 1. Unavailability is an OR across DIFFERENT fields (`availableQuantity == 0`
82
+ * OR `isSold == true` OR `prizeRevealStatus == closed` …). Firestore cannot
83
+ * express that in one query, and counting each clause separately and adding
84
+ * them double-counts every row matching two of them.
85
+ *
86
+ * 2. A clause selects a SUPERSET, deliberately. `madeToOrder` is pointedly NOT
87
+ * a clause (see the note in `availability.ts`), so a print-on-demand sticker
88
+ * sitting at `availableQuantity: 0` matches `availableQuantity == 0` and is
89
+ * nevertheless AVAILABLE. Only `isListingRowAvailable` knows that, and it is
90
+ * a per-row predicate. Measured on the category that prompted this work,
91
+ * a clause-count would have badged "Art & Stickers 2" over three available
92
+ * rows — trading one wrong number for another.
93
+ *
94
+ * So this is Path C of `listPublicProducts` reused in miniature: one bounded
95
+ * query per DISTINCT clause, in parallel, merged and deduped by id, then every
96
+ * surviving row confirmed with the real predicate. Nine listing types collapse
97
+ * to five clauses, so the whole tab bar costs ~5 × 50 documents in one parallel
98
+ * round — the same fan-out and the same budget that scope already spends.
99
+ *
100
+ * 🛑 The queries are NOT narrowed to the tab's listing types. Firestore permits
101
+ * one array operator per query and the category scope already spends it on
102
+ * `categorySlugs array-contains-any`, so adding `listingType in (…)` risks an
103
+ * INVALID_ARGUMENT for no gain: unavailable rows are sparse, and tallying each
104
+ * row by its own `listingType` afterwards answers every tab from one pass.
105
+ */
106
+ async function unavailableByType(scope, types, now) {
107
+ const byType = new Map();
108
+ const clauses = unavailableClausesFor(types);
109
+ if (clauses.length === 0)
110
+ return { byType, truncated: false };
111
+ const status = scope.status ?? DEFAULT_STATUS;
112
+ const results = await Promise.all(clauses.map(async (clause) => {
113
+ // An inequality must be ordered by its own field — Firestore appends it
114
+ // to the `orderBy` implicitly, so any other sort demands a composite
115
+ // index nobody declared (Root Cause #59).
116
+ const sorts = clause.op === "lt" && clause.sortField
117
+ ? sortBy(clause.sortField, "DESC")
118
+ : sortBy(PRODUCT_FIELDS.CREATED_AT, "DESC");
119
+ try {
120
+ return await productRepository.list({
121
+ filters: sieveAnd(sieveFilter(PRODUCT_FIELDS.STATUS, SIEVE_OP.EQ, status), clauseToSieve(clause, now)),
122
+ sorts,
123
+ page: 1,
124
+ pageSize: PUBLIC_PRODUCT_MAX_PAGE_SIZE,
125
+ }, {
126
+ storeId: scope.storeId,
127
+ categoriesIn: scope.categoriesIn,
128
+ brandName: scope.brandName,
129
+ });
130
+ }
131
+ catch (error) {
132
+ const e = normalizeError(error);
133
+ // ERROR, not warn: a clause that cannot run is almost always a missing
134
+ // composite index — a permanent, silent skew of every badge on the
135
+ // page rather than a transient blip.
136
+ serverLogger.error(`listingTabCounts: unavailable clause failed (${clause.field} ${clause.op}); badges fall back to totals`, { field: clause.field, op: clause.op, error: e.message });
137
+ return null;
138
+ }
139
+ }));
140
+ const merged = new Map();
141
+ let truncated = false;
142
+ for (const result of results) {
143
+ if (!result) {
144
+ truncated = true;
145
+ continue;
146
+ }
147
+ const rows = result.items;
148
+ if (rows.length >= PUBLIC_PRODUCT_MAX_PAGE_SIZE)
149
+ truncated = true;
150
+ for (const row of rows) {
151
+ const id = typeof row.id === "string" ? row.id : JSON.stringify(row.id);
152
+ if (!merged.has(id))
153
+ merged.set(id, row);
154
+ }
155
+ }
156
+ for (const row of merged.values()) {
157
+ // The clause matched a superset; this is the real answer.
158
+ if (isListingRowAvailable(row, now))
159
+ continue;
160
+ const type = normalizeListingType(row);
161
+ byType.set(type, (byType.get(type) ?? 0) + 1);
162
+ }
163
+ return { byType, truncated };
164
+ }
165
+ /** The listing types one tab spans — `"art|stickers"` is a pipe OR-group. */
166
+ function typesOf(tab) {
167
+ return String(tab.listingType).split("|").filter(Boolean);
168
+ }
68
169
  /**
69
170
  * Count every tab in `tabs` within `scope`, in one parallel batch.
70
171
  *
@@ -75,10 +176,12 @@ function bundleMatchesScope(bundle, scope) {
75
176
  * `stores` is not counted here: only the category page has that tab and it
76
177
  * already resolves its store list eagerly to render the tab body.
77
178
  */
78
- export async function listingTabCounts(tabs, scope) {
179
+ export async function listingTabCounts(tabs, scope, opts) {
180
+ const now = opts?.now ?? new Date();
79
181
  const listingTabs = tabs.filter((tab) => Boolean(tab.listingType));
80
182
  const wantsBundles = tabs.some((tab) => tab.collection === "bundles");
81
- const [typeCounts, bundleCount] = await Promise.all([
183
+ const allTypes = [...new Set(listingTabs.flatMap(typesOf))];
184
+ const [typeCounts, bundleCount, unavailable] = await Promise.all([
82
185
  productRepository.countByListingTypes(listingTabs.map((tab) => String(tab.listingType)), {
83
186
  status: scope.status ?? DEFAULT_STATUS,
84
187
  storeId: scope.storeId,
@@ -86,12 +189,27 @@ export async function listingTabCounts(tabs, scope) {
86
189
  brandName: scope.brandName,
87
190
  }),
88
191
  wantsBundles ? countBundles(scope) : Promise.resolve(undefined),
192
+ unavailableByType(scope, allTypes, now),
89
193
  ]);
90
- const counts = {};
194
+ const total = {};
195
+ const available = {};
91
196
  for (const tab of listingTabs) {
92
- counts[tab.id] = typeCounts[String(tab.listingType)];
197
+ const totalForTab = typeCounts[String(tab.listingType)];
198
+ total[tab.id] = totalForTab;
199
+ if (totalForTab === undefined || unavailable.truncated) {
200
+ // Unknown beats a number that is quietly too high. Falling back to the
201
+ // total reproduces the pre-2026-10-20 badge rather than inventing one.
202
+ available[tab.id] = totalForTab;
203
+ continue;
204
+ }
205
+ const gone = typesOf(tab).reduce((sum, type) => sum + (unavailable.byType.get(type) ?? 0), 0);
206
+ available[tab.id] = Math.max(0, totalForTab - gone);
207
+ }
208
+ if (wantsBundles) {
209
+ // A bundle has no availability scope of its own — the bundles tab renders
210
+ // active bundle ROWS, not products, so the two numbers are one number.
211
+ total.bundles = bundleCount;
212
+ available.bundles = bundleCount;
93
213
  }
94
- if (wantsBundles)
95
- counts.bundles = bundleCount;
96
- return counts;
214
+ return { available, total };
97
215
  }
@@ -1,5 +1,5 @@
1
1
  import { isListingTypeEnabled } from "../../../shared/listing-types/feature-flags";
2
- import { type ListingTabCounts } from "../products/listing-tab-counts";
2
+ import { type ListingTabCountResult } from "../products/listing-tab-counts";
3
3
  type SiteSettingsSnapshot = Parameters<typeof isListingTypeEnabled>[1];
4
4
  export interface StoreTabLink {
5
5
  value: string;
@@ -11,7 +11,12 @@ export interface ResolvedStoreTabs {
11
11
  dropdownTabs: StoreTabLink[];
12
12
  /** Coupons (if any), Reviews (if any), About — always non-empty. */
13
13
  tabs: StoreTabLink[];
14
- listingCounts: ListingTabCounts;
14
+ /**
15
+ * Both numbers, because they answer different questions: `available` is what
16
+ * each tab badges, `total` is what decides whether the tab exists at all.
17
+ * See `ListingTabCountResult`.
18
+ */
19
+ listingCounts: ListingTabCountResult;
15
20
  couponsCount: number;
16
21
  reviewsCount: number;
17
22
  }
@@ -73,7 +73,7 @@ export async function resolveStoreTabs(storeSlug, storeId, settings) {
73
73
  .then((r) => r.total)
74
74
  .catch(() => 0),
75
75
  ])
76
- : [{}, 0, 0];
76
+ : [{ available: {}, total: {} }, 0, 0];
77
77
  const visibleStoreTabs = STORE_PAGE_TABS.filter((tab) => {
78
78
  if (tab.id === "bundles") {
79
79
  if (!isCategoryTypeEnabled("bundle", settings))
@@ -92,12 +92,14 @@ export async function resolveStoreTabs(storeSlug, storeId, settings) {
92
92
  // that leads to an empty page. `undefined` means the count query FAILED,
93
93
  // not zero, so the tab stays visible rather than hiding real stock
94
94
  // behind a swallowed error (Root Cause #59).
95
- const count = listingCounts[tab.id];
95
+ // TOTAL, not the badge: a tab whose rows have all ended still has an
96
+ // archive behind the Ended/All scopes, and hiding it would strand that.
97
+ const count = listingCounts.total[tab.id];
96
98
  return count === undefined || count > 0;
97
99
  });
98
100
  const dropdownTabs = visibleStoreTabs.map((tab) => ({
99
101
  value: tab.id,
100
- label: tabLabel(tab.label, listingCounts[tab.id]),
102
+ label: tabLabel(tab.label, listingCounts.available[tab.id]),
101
103
  href: STORE_LISTING_HREF[tab.id](storeSlug),
102
104
  }));
103
105
  const tabs = [
@@ -119,6 +119,30 @@ export function prepareIndex(artifact) {
119
119
  };
120
120
  });
121
121
  }
122
+ /**
123
+ * The shortest term allowed to match MID-WORD.
124
+ *
125
+ * 🛑 This exists because of a reported bug, and the mechanism is worth stating:
126
+ * the substring tier is `flat.includes(term)`, and for a one-character term
127
+ * that is satisfied by almost every record in the artifact. A category's
128
+ * `detail` is its " › "-joined path, so `"g"` matches
129
+ * `"Spinning Tops › Beyblade Original › Dragoon Line"` on the `g` in
130
+ * "Spinning". AND semantics then cannot narrow anything: `"dragoon g"` scored
131
+ * identically to `"dragoon"` and returned every Dragoon in the catalogue —
132
+ * which is the "even if I type dragoon g the results show everything"
133
+ * complaint, and it was never the AND logic's fault.
134
+ *
135
+ * 🛑 It does NOT reintroduce a minimum query length. This module's header is
136
+ * explicit that typing `"d"` and seeing something is what a local index is FOR,
137
+ * and it still does: the EXACT and PREFIX tiers are untouched, so a
138
+ * one-character term still matches any word starting with it. Only the mid-word
139
+ * tier is gated, because one character appearing somewhere inside a string is
140
+ * not evidence of intent.
141
+ *
142
+ * Two rather than three: `"cx"`, `"bu"`, `"gt"` and `"1b"` are real product
143
+ * vocabulary a collector types, and they discriminate.
144
+ */
145
+ const MIN_SUBSTRING_TERM = 2;
122
146
  /** Best match quality for one term against one field. 0 means no match. */
123
147
  function fieldScore(term, words, flat) {
124
148
  if (!flat)
@@ -129,8 +153,10 @@ function fieldScore(term, words, flat) {
129
153
  for (const w of words)
130
154
  if (w.startsWith(term))
131
155
  return S_PREFIX;
132
- // Mid-word. The capability the token index cannot have.
133
- if (flat.includes(term))
156
+ // Mid-word. The capability the token index cannot have — but see
157
+ // MIN_SUBSTRING_TERM: for a single character it is not a capability, it is a
158
+ // match against everything.
159
+ if (term.length >= MIN_SUBSTRING_TERM && flat.includes(term))
134
160
  return S_SUBSTRING;
135
161
  return 0;
136
162
  }
@@ -301,6 +301,13 @@ export declare const TOOL_ENDPOINTS: {
301
301
  * is the multi-get `searchPriceIndex` otherwise pays for every lookup.
302
302
  */
303
303
  readonly PRICE_INDEX_BANDS: (ids: readonly string[]) => string;
304
+ /**
305
+ * The competitor-crawl market band for the same leaves — SIGNED-IN ONLY, and
306
+ * therefore a separate request from `PRICE_INDEX_BANDS` rather than more
307
+ * fields on it. That route is public and edge-cached; this one must not be,
308
+ * or one signed-in lookup populates an entry every guest reads.
309
+ */
310
+ readonly PRICE_INDEX_MARKET: (ids: readonly string[]) => string;
304
311
  };
305
312
  /**
306
313
  * The sticker-sheet kiss-cut tracer. Admin-only, every route behind
@@ -348,6 +348,13 @@ export const TOOL_ENDPOINTS = {
348
348
  * is the multi-get `searchPriceIndex` otherwise pays for every lookup.
349
349
  */
350
350
  PRICE_INDEX_BANDS: (ids) => `/api/tools/price-index/bands?ids=${ids.map(encodeURIComponent).join(",")}`,
351
+ /**
352
+ * The competitor-crawl market band for the same leaves — SIGNED-IN ONLY, and
353
+ * therefore a separate request from `PRICE_INDEX_BANDS` rather than more
354
+ * fields on it. That route is public and edge-cached; this one must not be,
355
+ * or one signed-in lookup populates an entry every guest reads.
356
+ */
357
+ PRICE_INDEX_MARKET: (ids) => `/api/tools/price-index/market?ids=${ids.map(encodeURIComponent).join(",")}`,
351
358
  };
352
359
  /**
353
360
  * The sticker-sheet kiss-cut tracer. Admin-only, every route behind
@@ -27,6 +27,7 @@ import { RelatedProducts } from "../../products/components/RelatedProducts.js";
27
27
  import { ProductGalleryClient } from "../../products/components/ProductGalleryClient.js";
28
28
  import { ProductFeatureBadges } from "../../products/components/ProductFeatureBadges.js";
29
29
  import { FeatureBadgeList } from "../../products/components/FeatureBadge.js";
30
+ import { productImageUrls } from "../../products/schemas/firestore.js";
30
31
  import { HistoryTracker } from "../../history/components/HistoryTracker.js";
31
32
  import { ShareButton } from "../../products/components/ShareButton.js";
32
33
  import { MarketplaceAuctionGrid } from "./MarketplaceAuctionGrid.js";
@@ -149,8 +150,12 @@ export async function AuctionDetailPageView({ id, initialAuction, onPlaceBid, on
149
150
  ? p.price
150
151
  : 0;
151
152
  const minBidIncrementOverride = typeof p.minBidIncrement === "number" ? p.minBidIncrement : undefined;
152
- const images = Array.isArray(p.images)
153
- ? p.images
153
+ // `images` is `ProductImage[]` since A6 — `productImageUrls` is the one
154
+ // normaliser (it also accepts the legacy `string[]`). Casting it to
155
+ // `string[]` handed the gallery objects and rendered a placeholder.
156
+ const galleryUrls = productImageUrls(p.images);
157
+ const images = galleryUrls.length > 0
158
+ ? galleryUrls
154
159
  : typeof p.mainImage === "string"
155
160
  ? [p.mainImage]
156
161
  : [];
@@ -206,11 +211,7 @@ export async function AuctionDetailPageView({ id, initialAuction, onPlaceBid, on
206
211
  });
207
212
  return (_jsxs(Main, { children: [_jsx(HistoryTracker, { productId: String(p.id ?? p.slug ?? ""), productType: "auction", snapshot: {
208
213
  title: typeof p.title === "string" ? p.title : undefined,
209
- thumb: typeof p.mainImage === "string"
210
- ? p.mainImage
211
- : Array.isArray(p.images) && typeof p.images[0] === "string"
212
- ? p.images[0]
213
- : undefined,
214
+ thumb: typeof p.mainImage === "string" ? p.mainImage : images[0],
214
215
  price: typeof p.price === "number" ? p.price : undefined,
215
216
  storeId: typeof p.storeId === "string" ? p.storeId : undefined,
216
217
  storeName: typeof p.storeName === "string" ? p.storeName : undefined,
@@ -43,30 +43,7 @@ import { getProviders } from "../../../../../contracts/index.js";
43
43
  import { categoriesRepository } from "../../../repository/categories.repository.js";
44
44
  import { isAdminUser } from "../../../../auth/role-predicates.js";
45
45
  import { priceGuidanceOnSellerBasis, describePriceGuidanceBasis, } from "../../../../../_internal/shared/features/categories/price-guidance.js";
46
- const MAX_ANCESTORS = 8;
47
- /**
48
- * Nearest-first ancestor walk for `priceGuidance`, same shape as
49
- * `description-template/route.ts`'s `resolveInherited` — the leaf's own band
50
- * wins, and only a leaf with none falls back to the nearest ancestor that has
51
- * one. ONE bounded `findByIds`, not a sequential walk: a seller form hitting
52
- * this on every category change should not pay up to eight round trips for it.
53
- */
54
- async function resolveInheritedGuidance(leaf) {
55
- if (leaf.productDefaults?.priceGuidance)
56
- return leaf.productDefaults.priceGuidance;
57
- const parentIds = (leaf.parentIds ?? []).slice(0, MAX_ANCESTORS);
58
- if (parentIds.length === 0)
59
- return undefined;
60
- const nearestFirst = [...parentIds].reverse();
61
- const ancestors = await categoriesRepository.findByIds(nearestFirst);
62
- const byId = new Map(ancestors.map((a) => [a.id, a]));
63
- for (const pid of nearestFirst) {
64
- const guidance = byId.get(pid)?.productDefaults?.priceGuidance;
65
- if (guidance)
66
- return guidance;
67
- }
68
- return undefined;
69
- }
46
+ import { resolveInheritedGuidance, } from "../../../../../_internal/server/features/categories/price-guidance-resolve.js";
70
47
  export const GET = createRouteHandler({
71
48
  roles: ["seller", "admin", "moderator"],
72
49
  handler: async ({ user, params }) => {
@@ -60,7 +60,7 @@ export async function BrandDetailPageView({ slug, initialBrand }) {
60
60
  // therefore never hide, however empty the brand was.
61
61
  brandName
62
62
  ? listingTabCounts(CATEGORY_PAGE_TABS, { brandName })
63
- : Promise.resolve({}),
63
+ : Promise.resolve({ available: {}, total: {} }),
64
64
  // SB-UNI-D — bundles are categoryType:"bundle" rows on the categories
65
65
  // collection, tagged to a brand via the real brandSlug field (added
66
66
  // 2026-08-21 — previously a fragile seo.keywords string-match heuristic).
@@ -108,15 +108,23 @@ export async function BrandDetailPageView({ slug, initialBrand }) {
108
108
  // Keyed by tabSlug. `bundles` is overridden with the brand-scoped figure —
109
109
  // `listingTabCounts` cannot narrow bundles by brand (a bundle's brand lives on
110
110
  // `brandSlug`, which this page already filters on locally).
111
+ // Badge = available scope; hiding + the header pill = total. See the same
112
+ // split in `CategoryDetailPageView` and `ListingTabCountResult`.
111
113
  const counts = {
112
- ...tabCounts,
113
- products: tabCounts.products ?? brand?.metrics?.productCount ?? 0,
114
- auctions: tabCounts.auctions ?? brand?.metrics?.auctionCount ?? 0,
114
+ ...tabCounts.available,
115
+ products: tabCounts.available.products ?? brand?.metrics?.productCount ?? 0,
116
+ auctions: tabCounts.available.auctions ?? brand?.metrics?.auctionCount ?? 0,
117
+ bundles: brandBundles.length,
118
+ };
119
+ const totals = {
120
+ ...tabCounts.total,
121
+ products: tabCounts.total.products ?? brand?.metrics?.productCount ?? 0,
122
+ auctions: tabCounts.total.auctions ?? brand?.metrics?.auctionCount ?? 0,
115
123
  bundles: brandBundles.length,
116
124
  };
117
125
  // Header total — `?? 0` is right here (a pill just doesn't render), unlike the
118
126
  // tab bar where an unknown count must keep its tab visible.
119
- const totalItems = Object.values(counts).reduce((sum, n) => sum + (n ?? 0), 0);
127
+ const totalItems = Object.values(totals).reduce((sum, n) => sum + (n ?? 0), 0);
120
128
  /*
121
129
  * ── D4: structured data on the brand page ───────────────────────────────
122
130
  *
@@ -168,5 +176,5 @@ export async function BrandDetailPageView({ slug, initialBrand }) {
168
176
  const ldFaq = brand?.faqs && brand.faqs.length > 0
169
177
  ? faqJsonLd(brand.faqs.map((f) => ({ question: f.question, answer: f.answer })))
170
178
  : null;
171
- return (_jsxs(Main, { children: [_jsx("script", { type: "application/ld+json", dangerouslySetInnerHTML: { __html: JSON.stringify(ldBreadcrumb) } }), _jsx("script", { type: "application/ld+json", dangerouslySetInnerHTML: { __html: JSON.stringify(ldCollection) } }), ldFaq && (_jsx("script", { type: "application/ld+json", dangerouslySetInnerHTML: { __html: JSON.stringify(ldFaq) } })), _jsxs(Section, { className: `relative ${__O.hidden} ${hasCover ? "min-h-[220px] md:min-h-[280px]" : "bg-[var(--appkit-color-bg)]"}`, children: [hasCover && (_jsxs(_Fragment, { children: [_jsx(Div, { className: "absolute inset-0", children: _jsx(MediaImage, { src: coverImage, alt: "", size: "banner" }) }), _jsx(Div, { surface: "overlay-md", className: "absolute inset-0" })] })), !hasCover && (_jsx(DynamicBgDiv, { color: brandColor, className: "absolute inset-0 opacity-10" })), _jsxs(Div, { className: "relative z-10 max-w-7xl mx-auto", paddingY: hasCover ? "y-3xl" : "y-xl", padding: "x-md", children: [_jsxs(Nav, { layout: "flex", gap: "2xs", textSize: "sm", className: "mb-4", "aria-label": "Breadcrumb", children: [_jsx(Link, { href: String(ROUTES.HOME), className: hasCover ? "text-white/70 hover:text-white transition-colors" : "text-[var(--appkit-color-text-muted)] hover:text-primary-600 dark:hover:text-primary-400 transition-colors", children: "Home" }), _jsx(Span, { className: hasCover ? "text-white/40" : "text-zinc-400", children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.BRANDS), className: hasCover ? "text-white/70 hover:text-white transition-colors" : "text-[var(--appkit-color-text-muted)] hover:text-primary-600 dark:hover:text-primary-400 transition-colors", children: "Brands" }), _jsx(Span, { className: hasCover ? "text-white/40" : "text-zinc-400", children: "/" }), _jsx(Span, { weight: "medium", className: hasCover ? "text-white" : "text-[var(--appkit-color-text)]", children: brand?.name ?? slug })] }), _jsxs(Row, { className: "mb-3", align: "center", gap: "md", children: [brand?.display?.icon && (_jsx(Span, { className: "leading-none", size: "5xl", children: brand.display.icon })), _jsxs(_Fragment, { children: [_jsx(Heading, { color: "inverse", level: 1, className: `${hasCover ? "" : "text-[var(--appkit-color-text)]"}`, size: "3xl", mdSize: "4xl", weight: "bold", children: brand?.name ?? slug }), brand?.description && typeof brand.description === "string" && !brand.description.startsWith("{") && (_jsx(Text, { color: hasCover ? "inverse" : "muted", className: `max-w-2xl mt-1 ${hasCover ? "/80" : ""}`, size: "base", children: brand.description }))] })] }), _jsxs(Row, { gap: "sm", wrap: true, className: "mt-3", children: [(counts.products ?? 0) > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: "inverse", size: "xs", weight: "medium", className: `${hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "bg-primary/10 text-primary-700 dark:text-primary-400"}`, rounded: "full", padding: "pill-sm-tall", children: [counts.products.toLocaleString(), " ", counts.products === 1 ? "product" : "products"] })), (counts.auctions ?? 0) > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "warning", surface: hasCover ? undefined : "warning-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [counts.auctions.toLocaleString(), " ", counts.auctions === 1 ? "auction" : "auctions"] })), (counts["pre-orders"] ?? 0) > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "info", surface: hasCover ? undefined : "info-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [counts["pre-orders"].toLocaleString(), " ", counts["pre-orders"] === 1 ? "pre-order" : "pre-orders"] })), totalItems === 0 && (_jsx(Span, { size: "sm", className: hasCover ? "text-white/60" : "text-zinc-400", children: "No items listed yet" }))] })] })] }), (brand?.brandWebsite || brand?.brandCountry || brand?.brandFounded) && (_jsx(Section, { border: "subtle", surface: "default", className: "border-b", padding: "y-md", children: _jsx(Container, { size: "xl", children: _jsxs(Stack, { gap: "sm", children: [_jsx(Heading, { level: 2, size: "sm", weight: "semibold", color: "muted", transform: "uppercase", children: "About this brand" }), _jsxs(Dl, { divide: "subtle", rounded: "xl", border: "subtle", className: "overflow-hidden max-w-lg", children: [brand?.brandWebsite && (_jsxs(Row, { gap: "md", oddEven: "zebra", surface: "default", padding: "inline", children: [_jsx(Dt, { className: "w-32 flex-shrink-0", color: "primary", weight: "medium", children: "Website" }), _jsx(Dd, { className: "flex-1", children: _jsx(Anchor, { href: brand.brandWebsite, tone: "brand", underline: "hover", children: brand.brandWebsite }) })] })), brand?.brandCountry && (_jsxs(Row, { gap: "md", oddEven: "zebra", surface: "default", padding: "inline", children: [_jsx(Dt, { className: "w-32 flex-shrink-0", color: "primary", weight: "medium", children: "Country" }), _jsx(Dd, { className: "flex-1", color: "muted", children: brand.brandCountry })] })), brand?.brandFounded && (_jsxs(Row, { gap: "md", oddEven: "zebra", surface: "default", padding: "inline", children: [_jsx(Dt, { className: "w-32 flex-shrink-0", color: "primary", weight: "medium", children: "Founded" }), _jsx(Dd, { className: "flex-1", color: "muted", children: brand.brandFounded })] }))] })] }) }) })), brandContentBody && brandContentBody.trim().length > 0 && (_jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "xl", children: _jsx(RichTextRenderer, { html: brandContentBody }) }) })), _jsx(CategoryHighlightsAndFaqSection, { highlights: brand?.highlights, faqs: brand?.faqs }), _jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "xl", children: brandName ? (_jsx(BrandDetailTabs, { brandName: brandName, initialProductsData: productsResult ?? undefined, initialBundles: brandBundles, counts: counts, enabledListingTypes: enabledListingTypes(settings), enabledCategoryTypes: enabledCategoryTypes(settings) })) : (_jsx(Text, { paddingY: "3xl", color: "muted", size: "sm", align: "start", children: "Brand not found." })) }) }), groupedListings.length > 0 && (_jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "xl", children: _jsx(GroupedListingsCarousel, { groups: groupedListings }) }) })), relatedBrands.length > 0 && (_jsx(Section, { border: "subtle", surface: "default", className: "border-t", padding: "y-lg", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 2, className: "mb-4", size: "xl", weight: "semibold", children: "Related Brands" }), _jsx(CategoryGrid, { categories: relatedBrands, getHref: (b) => String(ROUTES.PUBLIC.BRAND_DETAIL(b.slug)) })] }) }))] }));
179
+ return (_jsxs(Main, { children: [_jsx("script", { type: "application/ld+json", dangerouslySetInnerHTML: { __html: JSON.stringify(ldBreadcrumb) } }), _jsx("script", { type: "application/ld+json", dangerouslySetInnerHTML: { __html: JSON.stringify(ldCollection) } }), ldFaq && (_jsx("script", { type: "application/ld+json", dangerouslySetInnerHTML: { __html: JSON.stringify(ldFaq) } })), _jsxs(Section, { className: `relative ${__O.hidden} ${hasCover ? "min-h-[220px] md:min-h-[280px]" : "bg-[var(--appkit-color-bg)]"}`, children: [hasCover && (_jsxs(_Fragment, { children: [_jsx(Div, { className: "absolute inset-0", children: _jsx(MediaImage, { src: coverImage, alt: "", size: "banner" }) }), _jsx(Div, { surface: "overlay-md", className: "absolute inset-0" })] })), !hasCover && (_jsx(DynamicBgDiv, { color: brandColor, className: "absolute inset-0 opacity-10" })), _jsxs(Div, { className: "relative z-10 max-w-7xl mx-auto", paddingY: hasCover ? "y-3xl" : "y-xl", padding: "x-md", children: [_jsxs(Nav, { layout: "flex", gap: "2xs", textSize: "sm", className: "mb-4", "aria-label": "Breadcrumb", children: [_jsx(Link, { href: String(ROUTES.HOME), className: hasCover ? "text-white/70 hover:text-white transition-colors" : "text-[var(--appkit-color-text-muted)] hover:text-primary-600 dark:hover:text-primary-400 transition-colors", children: "Home" }), _jsx(Span, { className: hasCover ? "text-white/40" : "text-zinc-400", children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.BRANDS), className: hasCover ? "text-white/70 hover:text-white transition-colors" : "text-[var(--appkit-color-text-muted)] hover:text-primary-600 dark:hover:text-primary-400 transition-colors", children: "Brands" }), _jsx(Span, { className: hasCover ? "text-white/40" : "text-zinc-400", children: "/" }), _jsx(Span, { weight: "medium", className: hasCover ? "text-white" : "text-[var(--appkit-color-text)]", children: brand?.name ?? slug })] }), _jsxs(Row, { className: "mb-3", align: "center", gap: "md", children: [brand?.display?.icon && (_jsx(Span, { className: "leading-none", size: "5xl", children: brand.display.icon })), _jsxs(_Fragment, { children: [_jsx(Heading, { color: "inverse", level: 1, className: `${hasCover ? "" : "text-[var(--appkit-color-text)]"}`, size: "3xl", mdSize: "4xl", weight: "bold", children: brand?.name ?? slug }), brand?.description && typeof brand.description === "string" && !brand.description.startsWith("{") && (_jsx(Text, { color: hasCover ? "inverse" : "muted", className: `max-w-2xl mt-1 ${hasCover ? "/80" : ""}`, size: "base", children: brand.description }))] })] }), _jsxs(Row, { gap: "sm", wrap: true, className: "mt-3", children: [(counts.products ?? 0) > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: "inverse", size: "xs", weight: "medium", className: `${hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "bg-primary/10 text-primary-700 dark:text-primary-400"}`, rounded: "full", padding: "pill-sm-tall", children: [counts.products.toLocaleString(), " ", counts.products === 1 ? "product" : "products"] })), (counts.auctions ?? 0) > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "warning", surface: hasCover ? undefined : "warning-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [counts.auctions.toLocaleString(), " ", counts.auctions === 1 ? "auction" : "auctions"] })), (counts["pre-orders"] ?? 0) > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "info", surface: hasCover ? undefined : "info-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [counts["pre-orders"].toLocaleString(), " ", counts["pre-orders"] === 1 ? "pre-order" : "pre-orders"] })), totalItems === 0 && (_jsx(Span, { size: "sm", className: hasCover ? "text-white/60" : "text-zinc-400", children: "No items listed yet" }))] })] })] }), (brand?.brandWebsite || brand?.brandCountry || brand?.brandFounded) && (_jsx(Section, { border: "subtle", surface: "default", className: "border-b", padding: "y-md", children: _jsx(Container, { size: "xl", children: _jsxs(Stack, { gap: "sm", children: [_jsx(Heading, { level: 2, size: "sm", weight: "semibold", color: "muted", transform: "uppercase", children: "About this brand" }), _jsxs(Dl, { divide: "subtle", rounded: "xl", border: "subtle", className: "overflow-hidden max-w-lg", children: [brand?.brandWebsite && (_jsxs(Row, { gap: "md", oddEven: "zebra", surface: "default", padding: "inline", children: [_jsx(Dt, { className: "w-32 flex-shrink-0", color: "primary", weight: "medium", children: "Website" }), _jsx(Dd, { className: "flex-1", children: _jsx(Anchor, { href: brand.brandWebsite, tone: "brand", underline: "hover", children: brand.brandWebsite }) })] })), brand?.brandCountry && (_jsxs(Row, { gap: "md", oddEven: "zebra", surface: "default", padding: "inline", children: [_jsx(Dt, { className: "w-32 flex-shrink-0", color: "primary", weight: "medium", children: "Country" }), _jsx(Dd, { className: "flex-1", color: "muted", children: brand.brandCountry })] })), brand?.brandFounded && (_jsxs(Row, { gap: "md", oddEven: "zebra", surface: "default", padding: "inline", children: [_jsx(Dt, { className: "w-32 flex-shrink-0", color: "primary", weight: "medium", children: "Founded" }), _jsx(Dd, { className: "flex-1", color: "muted", children: brand.brandFounded })] }))] })] }) }) })), brandContentBody && brandContentBody.trim().length > 0 && (_jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "xl", children: _jsx(RichTextRenderer, { html: brandContentBody }) }) })), _jsx(CategoryHighlightsAndFaqSection, { highlights: brand?.highlights, faqs: brand?.faqs }), _jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "xl", children: brandName ? (_jsx(BrandDetailTabs, { brandName: brandName, initialProductsData: productsResult ?? undefined, initialBundles: brandBundles, counts: counts, totals: totals, enabledListingTypes: enabledListingTypes(settings), enabledCategoryTypes: enabledCategoryTypes(settings) })) : (_jsx(Text, { paddingY: "3xl", color: "muted", size: "sm", align: "start", children: "Brand not found." })) }) }), groupedListings.length > 0 && (_jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "xl", children: _jsx(GroupedListingsCarousel, { groups: groupedListings }) }) })), relatedBrands.length > 0 && (_jsx(Section, { border: "subtle", surface: "default", className: "border-t", padding: "y-lg", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 2, className: "mb-4", size: "xl", weight: "semibold", children: "Related Brands" }), _jsx(CategoryGrid, { categories: relatedBrands, getHref: (b) => String(ROUTES.PUBLIC.BRAND_DETAIL(b.slug)) })] }) }))] }));
172
180
  }
@@ -10,10 +10,16 @@ export interface BrandDetailTabsProps {
10
10
  * inventory (Root Cause #59). Keyed by tab id; the previous hand-named shape
11
11
  * covered only 5 of 9 tabs (Root Cause #61).
12
12
  */
13
+ /** Badge counts — what the default (available) scope will render. */
13
14
  counts?: Record<string, number | undefined>;
15
+ /**
16
+ * Every published row per tab, all availability scopes. Drives hide-at-zero
17
+ * and the "N ended — show all" hint; see `ListingTabCountResult`.
18
+ */
19
+ totals?: Record<string, number | undefined>;
14
20
  /** Enabled listing types (e.g. ["standard","auction"]). When omitted, all tabs shown. */
15
21
  enabledListingTypes?: string[];
16
22
  /** Enabled category types (e.g. ["category","brand","bundle"]). When omitted, all tabs shown. */
17
23
  enabledCategoryTypes?: string[];
18
24
  }
19
- export declare function BrandDetailTabs({ brandName, initialProductsData, initialBundles, counts, enabledListingTypes, enabledCategoryTypes, }: BrandDetailTabsProps): React.JSX.Element;
25
+ export declare function BrandDetailTabs({ brandName, initialProductsData, initialBundles, counts, totals, enabledListingTypes, enabledCategoryTypes, }: BrandDetailTabsProps): React.JSX.Element;
@@ -2,6 +2,7 @@
2
2
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useState } from "react";
4
4
  import { EmptyState, Tabs, TabsList, TabsTrigger } from "../../../ui/index.js";
5
+ import { AvailabilityScopeHint } from "../../products/components/AvailabilityScopeHint.js";
5
6
  import { CategoryProductsListing } from "./CategoryProductsListing.js";
6
7
  import { AuctionsIndexListing } from "../../products/components/AuctionsIndexListing.js";
7
8
  import { PreOrdersIndexListing } from "../../pre-orders/components/PreOrdersIndexListing.js";
@@ -28,6 +29,18 @@ const TAB_TYPE_MAP = {
28
29
  ])),
29
30
  bundles: { kind: "category", type: "bundle" },
30
31
  };
32
+ /**
33
+ * The listing types a tab spans, for the availability-scope hint. Derived
34
+ * from the same maps the tabs render from, never hand-written (Root Cause
35
+ * #61).
36
+ */
37
+ function typesForTab(id) {
38
+ const generic = GENERIC_TAB_LISTING_TYPES[id];
39
+ if (generic)
40
+ return generic;
41
+ const mapping = TAB_TYPE_MAP[id];
42
+ return mapping?.kind === "listing" ? [mapping.type] : [];
43
+ }
31
44
  /** Listing types rendered by the shared products listing rather than a dedicated one. */
32
45
  const GENERIC_TAB_LISTING_TYPES = {
33
46
  [pluginFor("standard").tabSlug]: ["standard"],
@@ -46,8 +59,9 @@ const GENERIC_TAB_LISTING_TYPES = {
46
59
  */
47
60
  [pluginFor("spares").tabSlug]: ["spares"],
48
61
  };
49
- export function BrandDetailTabs({ brandName, initialProductsData, initialBundles = [], counts, enabledListingTypes, enabledCategoryTypes, }) {
62
+ export function BrandDetailTabs({ brandName, initialProductsData, initialBundles = [], counts, totals, enabledListingTypes, enabledCategoryTypes, }) {
50
63
  const countFor = (id) => counts?.[id];
64
+ const totalFor = (id) => totals?.[id] ?? counts?.[id];
51
65
  const visibleTabs = CATEGORY_PAGE_TABS.filter((t) => {
52
66
  const mapping = TAB_TYPE_MAP[t.id];
53
67
  // A tab with no mapping has no render branch below and would show a blank
@@ -67,7 +81,8 @@ export function BrandDetailTabs({ brandName, initialProductsData, initialBundles
67
81
  // Hide a tab only when its count is known and explicitly zero. `undefined`
68
82
  // now means the count query FAILED (every tab is counted), so the tab stays
69
83
  // visible rather than hiding real listings behind a swallowed error.
70
- const count = countFor(t.id);
84
+ // TOTAL, not the badge — see the same note in CategoryDetailTabs.
85
+ const count = totalFor(t.id);
71
86
  return count === undefined || count > 0;
72
87
  });
73
88
  const firstTabId = (visibleTabs[0]?.id ?? "products");
@@ -75,5 +90,5 @@ export function BrandDetailTabs({ brandName, initialProductsData, initialBundles
75
90
  if (visibleTabs.length === 0) {
76
91
  return (_jsx(EmptyState, { title: "Nothing listed here yet", description: "This brand has no active listings right now. Check back soon." }));
77
92
  }
78
- return (_jsxs(_Fragment, { children: [_jsx(Tabs, { value: activeTab, onChange: (v) => setActiveTab(v), className: "mb-6", children: _jsx(TabsList, { children: visibleTabs.map((t) => (_jsx(TabsTrigger, { value: t.id, badge: countFor(t.id), children: t.label }, t.id))) }) }), GENERIC_TAB_LISTING_TYPES[activeTab] && (_jsx(CategoryProductsListing, { categorySlug: "", brandName: brandName, listingTypes: GENERIC_TAB_LISTING_TYPES[activeTab], initialData: activeTab === pluginFor("standard").tabSlug ? initialProductsData : undefined })), activeTab === pluginFor("auction").tabSlug && (_jsx(AuctionsIndexListing, { brandName: brandName })), activeTab === pluginFor("pre-order").tabSlug && (_jsx(PreOrdersIndexListing, { brandName: brandName })), activeTab === pluginFor("prize-draw").tabSlug && (_jsx(PrizeDrawsIndexListing, { brandName: brandName })), activeTab === "bundles" && (_jsx(CategoryBundlesListing, { initialBundles: initialBundles, brandName: brandName }))] }));
93
+ return (_jsxs(_Fragment, { children: [_jsx(Tabs, { value: activeTab, onChange: (v) => setActiveTab(v), className: "mb-6", children: _jsx(TabsList, { children: visibleTabs.map((t) => (_jsx(TabsTrigger, { value: t.id, badge: countFor(t.id), children: t.label }, t.id))) }) }), _jsx(AvailabilityScopeHint, { total: totalFor(activeTab), available: countFor(activeTab), types: typesForTab(activeTab) }), GENERIC_TAB_LISTING_TYPES[activeTab] && (_jsx(CategoryProductsListing, { categorySlug: "", brandName: brandName, listingTypes: GENERIC_TAB_LISTING_TYPES[activeTab], initialData: activeTab === pluginFor("standard").tabSlug ? initialProductsData : undefined })), activeTab === pluginFor("auction").tabSlug && (_jsx(AuctionsIndexListing, { brandName: brandName })), activeTab === pluginFor("pre-order").tabSlug && (_jsx(PreOrdersIndexListing, { brandName: brandName })), activeTab === pluginFor("prize-draw").tabSlug && (_jsx(PrizeDrawsIndexListing, { brandName: brandName })), activeTab === "bundles" && (_jsx(CategoryBundlesListing, { initialBundles: initialBundles, brandName: brandName }))] }));
79
94
  }
@@ -16,10 +16,11 @@ import { MediaImage } from "../../media/MediaImage.js";
16
16
  import { ImageLightbox } from "../../../ui/components/ImageLightbox.js";
17
17
  import { ROUTES } from "../../../next/routing/route-map.js";
18
18
  import { formatCurrency } from "../../../utils/number.formatter.js";
19
+ import { productImageUrls } from "../../products/schemas/firestore.js";
19
20
  const PLACEHOLDER_EMOJI = "📦";
20
21
  function toGalleryImages(members, canSeePrices) {
21
22
  return members.map((p, i) => ({
22
- src: p.mainImage ?? p.images?.[0] ?? "",
23
+ src: p.mainImage || productImageUrls(p.images)[0] || "",
23
24
  alt: p.title,
24
25
  badge: `#${i + 1}`,
25
26
  caption: p.title,
@@ -46,7 +47,7 @@ export function BundleCollage({ members, onItemClick }) {
46
47
  return null;
47
48
  const galleryImages = toGalleryImages(members, canSeePrices);
48
49
  return (_jsxs(_Fragment, { children: [_jsx(Grid, { cols: "categoryCards", gap: "3", children: members.map((p, idx) => {
49
- const cover = p.mainImage ?? p.images?.[0];
50
+ const cover = p.mainImage || productImageUrls(p.images)[0];
50
51
  const href = String(ROUTES.PUBLIC.PRODUCT_DETAIL?.(p.slug ?? p.id) ?? "#");
51
52
  return (_jsxs(Div, { layout: "flex-col", className: "group relative overflow-hidden border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] transition-transform hover:scale-[1.01]", rounded: "lg", children: [_jsx(Button, { variant: "ghost", type: "button", onClick: makeBundleItemClickHandler(p, idx, onItemClick, setLightboxIndex), paddingX: "none", paddingY: "none", rounded: "none", className: "relative aspect-square w-full overflow-hidden focus-visible:ring-2 focus-visible:ring-[var(--appkit-color-primary)]", "aria-label": `View ${p.title} in lightbox`, children: _jsxs(Div, { className: "relative aspect-square w-full", children: [cover ? (_jsx(MediaImage, { src: cover, alt: p.title, size: "card", className: "transition-transform group-hover:scale-105" })) : (_jsx(Row, { textSize: "3xl", surface: "muted", className: "absolute inset-0", align: "center", justify: "center", children: PLACEHOLDER_EMOJI })), _jsxs(Div, { textWeight: "semibold", textSize: "xs", surface: "overlay-xl", padding: "chip-2xs", className: "absolute left-2 top-2 text-white", rounded: "default", children: ["#", idx + 1] })] }) }), _jsxs(Div, { padding: "xs", children: [_jsx(Link, { href: href, className: "line-clamp-2 text-[length:var(--appkit-text-sm)] font-medium hover:underline", children: p.title }), _jsx(Text, { className: "mt-0.5 text-[var(--appkit-color-text-muted)]", size: "xs", children: _jsx(GatedPrice, { children: formatCurrency(p.price ?? 0, p.currency ?? "INR") }) })] })] }, p.id));
52
53
  }) }), _jsx(ImageLightbox, { images: galleryImages, activeIndex: lightboxIndex, onClose: () => setLightboxIndex(null), onNavigate: (i) => setLightboxIndex(i), showThumbnails: true })] }));
@@ -18,6 +18,7 @@ import { Badge, Button, Checkbox, Div, Input, Row, Stack, Text } from "../../../
18
18
  import { BUNDLE_MAX_ITEMS, BUNDLE_MIN_ITEMS, } from "../../../_internal/shared/features/categories/bundle-config.js";
19
19
  import { BUNDLE_COPY } from "../../../_internal/shared/features/categories/bundle-copy.js";
20
20
  import { PRODUCT_ENDPOINTS } from "../../../constants/api-endpoints.js";
21
+ import { productImageUrls } from "../../products/schemas/firestore.js";
21
22
  const __P = {
22
23
  p3: "p-[var(--appkit-space-3)]",
23
24
  };
@@ -137,11 +138,9 @@ export async function defaultBundleItemsFetch(query) {
137
138
  return {
138
139
  id,
139
140
  title,
140
- image: typeof rec.mainImage === "string"
141
+ image: typeof rec.mainImage === "string" && rec.mainImage
141
142
  ? rec.mainImage
142
- : Array.isArray(rec.images) && typeof rec.images[0] === "string"
143
- ? rec.images[0]
144
- : undefined,
143
+ : productImageUrls(rec.images)[0],
145
144
  price: typeof rec.price === "number" ? rec.price : undefined,
146
145
  storeId: typeof rec.storeId === "string" ? rec.storeId : undefined,
147
146
  };