@mohasinac/appkit 4.45.0 → 4.46.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 (152) hide show
  1. package/dist/.build-stamp.json +4 -4
  2. package/dist/_internal/server/features/categories/live-counts.d.ts +41 -0
  3. package/dist/_internal/server/features/categories/live-counts.js +72 -0
  4. package/dist/_internal/server/features/checkout/expand-order-items.js +17 -0
  5. package/dist/_internal/server/features/orders/adapters.js +29 -0
  6. package/dist/_internal/server/features/parts/assembly.d.ts +126 -0
  7. package/dist/_internal/server/features/parts/assembly.js +246 -0
  8. package/dist/_internal/server/features/parts/data.d.ts +65 -0
  9. package/dist/_internal/server/features/parts/data.js +170 -0
  10. package/dist/_internal/server/features/parts/og.d.ts +34 -0
  11. package/dist/_internal/server/features/parts/og.js +66 -0
  12. package/dist/_internal/server/features/search/search-everything.d.ts +75 -0
  13. package/dist/_internal/server/features/search/search-everything.js +120 -0
  14. package/dist/_internal/server/features/search/search-sources.d.ts +17 -0
  15. package/dist/_internal/server/features/search/search-sources.js +138 -0
  16. package/dist/_internal/server/jobs/core/categoryTaxonomyBackfill.d.ts +70 -0
  17. package/dist/_internal/server/jobs/core/categoryTaxonomyBackfill.js +302 -0
  18. package/dist/_internal/server/jobs/core/countersReconcile.js +128 -19
  19. package/dist/_internal/server/jobs/core/jobRunners.js +12 -0
  20. package/dist/_internal/server/jobs/core/onCategoryWrite.js +73 -3
  21. package/dist/_internal/server/jobs/core/onProductWrite.js +93 -43
  22. package/dist/_internal/server/jobs/core/productViewWindows.d.ts +2 -0
  23. package/dist/_internal/server/jobs/core/productViewWindows.js +173 -0
  24. package/dist/_internal/server/jobs/core/testDataCleanup.d.ts +1 -1
  25. package/dist/_internal/server/jobs/core/testDataCleanup.js +27 -1
  26. package/dist/_internal/server/jobs/handlers/pageViewRollup.d.ts +20 -0
  27. package/dist/_internal/server/jobs/handlers/pageViewRollup.js +25 -1
  28. package/dist/_internal/shared/actions/action-registry.js +36 -0
  29. package/dist/_internal/shared/features/promotions/schema.d.ts +2 -2
  30. package/dist/_internal/shared/features/shipping/protections.d.ts +93 -1
  31. package/dist/_internal/shared/features/shipping/protections.js +127 -0
  32. package/dist/_internal/shared/search/part-aliases.d.ts +44 -0
  33. package/dist/_internal/shared/search/part-aliases.js +104 -0
  34. package/dist/constants/api-endpoints.d.ts +18 -12
  35. package/dist/constants/api-endpoints.js +6 -8
  36. package/dist/constants/field-names.d.ts +0 -2
  37. package/dist/constants/field-names.js +0 -2
  38. package/dist/constants/index.d.ts +1 -1
  39. package/dist/constants/index.js +1 -1
  40. package/dist/core/index.d.ts +0 -3
  41. package/dist/core/index.js +0 -2
  42. package/dist/errors/messages.d.ts +0 -7
  43. package/dist/errors/messages.js +0 -8
  44. package/dist/features/admin/components/AdminSectionsView.js +83 -32
  45. package/dist/features/admin/components/AdminSiteSettingsView.js +6 -2
  46. package/dist/features/admin/components/sections/sectionTemplates.d.ts +117 -0
  47. package/dist/features/admin/components/sections/sectionTemplates.js +213 -0
  48. package/dist/features/admin/schemas/firestore.d.ts +15 -0
  49. package/dist/features/admin/types/index.d.ts +0 -1
  50. package/dist/features/auth/permissions/constants.d.ts +1 -1
  51. package/dist/features/auth/permissions/constants.js +0 -2
  52. package/dist/features/categories/api/[id]/description-template/route.js +40 -1
  53. package/dist/features/categories/components/BrandDetailPageView.js +14 -3
  54. package/dist/features/categories/components/CategoryDetailPageView.js +89 -14
  55. package/dist/features/categories/components/CategoryTemplatePicker.js +9 -1
  56. package/dist/features/categories/repository/categories.repository.d.ts +65 -0
  57. package/dist/features/categories/repository/categories.repository.js +168 -22
  58. package/dist/features/categories/repository/category-counts.d.ts +143 -0
  59. package/dist/features/categories/repository/category-counts.js +224 -0
  60. package/dist/features/categories/schemas/category-counts.d.ts +155 -0
  61. package/dist/features/categories/schemas/category-counts.js +116 -0
  62. package/dist/features/categories/schemas/firestore.d.ts +0 -2
  63. package/dist/features/categories/schemas/firestore.js +0 -1
  64. package/dist/features/categories/utils/breadcrumb-chain.d.ts +194 -0
  65. package/dist/features/categories/utils/breadcrumb-chain.js +202 -0
  66. package/dist/features/corporate/schemas/index.d.ts +4 -4
  67. package/dist/features/orders/schemas/firestore.d.ts +13 -0
  68. package/dist/features/orders/types/index.d.ts +30 -0
  69. package/dist/features/parts/components/AssemblyDrawer.d.ts +107 -0
  70. package/dist/features/parts/components/AssemblyDrawer.js +250 -0
  71. package/dist/features/parts/components/ModelCompositionPanel.d.ts +37 -0
  72. package/dist/features/parts/components/ModelCompositionPanel.js +34 -0
  73. package/dist/features/parts/components/PartDetailPageView.d.ts +48 -0
  74. package/dist/features/parts/components/PartDetailPageView.js +20 -0
  75. package/dist/features/parts/components/PartsIndexPageView.d.ts +33 -0
  76. package/dist/features/parts/components/PartsIndexPageView.js +17 -0
  77. package/dist/features/parts/schemas/firestore.d.ts +257 -0
  78. package/dist/features/parts/schemas/firestore.js +181 -0
  79. package/dist/features/parts/utils/colourway-match.d.ts +62 -0
  80. package/dist/features/parts/utils/colourway-match.js +98 -0
  81. package/dist/features/parts/utils/part-breakdown.d.ts +53 -0
  82. package/dist/features/parts/utils/part-breakdown.js +77 -0
  83. package/dist/features/products/components/ProductDetailPageView.d.ts +14 -1
  84. package/dist/features/products/components/ProductDetailPageView.js +22 -3
  85. package/dist/features/products/components/ProductTabsShell.d.ts +3 -1
  86. package/dist/features/products/components/ProductTabsShell.js +4 -1
  87. package/dist/features/products/repository/category-assignment.d.ts +132 -0
  88. package/dist/features/products/repository/category-assignment.js +304 -0
  89. package/dist/features/products/repository/products.repository.d.ts +58 -1
  90. package/dist/features/products/repository/products.repository.js +255 -28
  91. package/dist/features/products/schemas/firestore.d.ts +147 -6
  92. package/dist/features/products/schemas/firestore.js +49 -0
  93. package/dist/features/search/components/SearchResultsPageView.d.ts +33 -0
  94. package/dist/features/search/components/SearchResultsPageView.js +21 -0
  95. package/dist/features/seller/components/SellerProductsView.js +129 -3
  96. package/dist/features/tester/seed-data/authored/admin__site-system.js +0 -15
  97. package/dist/features/tester/seed-data/authored/content-discovery__parts-database.d.ts +2 -0
  98. package/dist/features/tester/seed-data/authored/content-discovery__parts-database.js +166 -0
  99. package/dist/features/tester/seed-data/authored/index.js +2 -0
  100. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +28 -1
  101. package/dist/index.d.ts +11 -16
  102. package/dist/index.js +26 -18
  103. package/dist/next/routing/route-map.d.ts +6 -2
  104. package/dist/next/routing/route-map.js +14 -1
  105. package/dist/providers/db-firebase/base.repository.d.ts +31 -0
  106. package/dist/providers/db-firebase/base.repository.js +31 -0
  107. package/dist/repositories/index.d.ts +1 -2
  108. package/dist/repositories/index.js +0 -1
  109. package/dist/seed/_helpers/category-content.d.ts +29 -0
  110. package/dist/seed/_helpers/category-content.js +98 -1
  111. package/dist/seed/_helpers/category-forest.js +104 -24
  112. package/dist/seed/_helpers/category-parts.d.ts +9 -0
  113. package/dist/seed/_helpers/category-parts.js +4594 -0
  114. package/dist/seed/carousel-slides-seed-data.js +81 -8
  115. package/dist/seed/categories-seed-data.js +44 -6
  116. package/dist/seed/homepage-sections-seed-data.js +170 -9
  117. package/dist/seed/index.d.ts +2 -0
  118. package/dist/seed/index.js +13 -0
  119. package/dist/seed/model-composition-seed-data.d.ts +15 -0
  120. package/dist/seed/model-composition-seed-data.js +8918 -0
  121. package/dist/seed/part-glossary-seed-data.d.ts +15 -0
  122. package/dist/seed/part-glossary-seed-data.js +10729 -0
  123. package/dist/seed/products-standard-seed-data.js +52 -0
  124. package/dist/seed/seed-purpose.d.ts +103 -4
  125. package/dist/seed/seed-purpose.js +162 -11
  126. package/dist/seo/json-ld.d.ts +10 -1
  127. package/dist/seo/json-ld.js +9 -1
  128. package/dist/server.d.ts +7 -0
  129. package/dist/server.js +11 -0
  130. package/dist/styles.css +1 -1
  131. package/dist/tailwind-utilities.css +1 -1
  132. package/dist/utils/id-generators.d.ts +30 -0
  133. package/dist/utils/id-generators.js +31 -1
  134. package/dist/utils/search-txt-builders.js +22 -0
  135. package/dist/utils/search-txt.d.ts +22 -0
  136. package/dist/utils/search-txt.js +17 -0
  137. package/dist/values/success-messages.d.ts +0 -4
  138. package/dist/values/success-messages.js +0 -5
  139. package/package.json +1 -1
  140. package/scripts/seed-cli.mjs +292 -16
  141. package/dist/core/copilot-log.repository.d.ts +0 -44
  142. package/dist/core/copilot-log.repository.js +0 -61
  143. package/dist/features/copilot/components/AdminCopilotView.d.ts +0 -21
  144. package/dist/features/copilot/components/AdminCopilotView.js +0 -51
  145. package/dist/features/copilot/components/index.d.ts +0 -2
  146. package/dist/features/copilot/components/index.js +0 -1
  147. package/dist/features/copilot/hooks/useCopilotChat.d.ts +0 -25
  148. package/dist/features/copilot/hooks/useCopilotChat.js +0 -65
  149. package/dist/features/copilot/hooks/useCopilotFeedback.d.ts +0 -15
  150. package/dist/features/copilot/hooks/useCopilotFeedback.js +0 -15
  151. package/dist/features/copilot/index.d.ts +0 -3
  152. package/dist/features/copilot/index.js +0 -3
@@ -1,5 +1,9 @@
1
1
  import { normalizeError } from "../../../errors/normalize.js";
2
2
  import { increment, serverTimestamp } from "../../../contracts/field-ops.js";
3
+ // 🛑 `../../../errors` re-exports the REAL ValidationError (errors/validation-error),
4
+ // which is the one `mapToHttpError` instanceof-checks to produce a 400. The
5
+ // unrelated same-named class under `_internal/shared/errors` falls through every
6
+ // classified branch to a generic 500 — Root Cause #53.
3
7
  import { DatabaseError } from "../../../errors/index.js";
4
8
  import { BaseRepository, prepareForFirestore, parseSieveDateValue, } from "../../../providers/db-firebase/index.js";
5
9
  import { cacheManager } from "../../../core/index.js";
@@ -19,6 +23,11 @@ import { PRODUCT_COLLECTION, ProductStatusValues } from "../schemas/index.js";
19
23
  // reads: no repository in this codebase imports another feature's, because of
20
24
  // the Turbopack import-chain trap (Root Cause #6/#18).
21
25
  import { CATEGORIES_COLLECTION } from "../../categories/schemas/firestore.js";
26
+ import { CATEGORY_COUNTS_COLLECTION, sumCounts } from "../../categories/schemas/category-counts.js";
27
+ import { ENFORCE_LEAF_FILING, assertAssignmentShape, buildTaxonomyClosure, pickDefaultAssignment, readAssignments, } from "./category-assignment.js";
28
+ import { describeCountable, countsChanged, buildCountPatch, applyCountPatch, } from "../../categories/repository/category-counts.js";
29
+ // The ONE availability predicate — never a second copy (Root Cause #73).
30
+ import { isListingRowAvailable } from "../../../_internal/shared/listing-types/_registry.js";
22
31
  import { TAX_CODES_COLLECTION } from "../../tax-codes/schemas/firestore.js";
23
32
  // Collection id only, never the stores repository — no repository in this
24
33
  // codebase imports another feature's (the Turbopack import-chain trap).
@@ -294,9 +303,8 @@ export class ProductRepository extends BaseRepository {
294
303
  * either way. The two can only disagree when one of them is stale, and the
295
304
  * derived one is the one that goes stale.
296
305
  */
297
- const leaf = input.category ||
298
- (Array.isArray(input.categorySlugs) && input.categorySlugs[0]) ||
299
- null;
306
+ const assignments = readAssignments(input);
307
+ const leaf = assignments[0] ?? null;
300
308
  /*
301
309
  * The brand selector's option value is the brand ROW's id (`brand-beyblade`),
302
310
  * but `ProductDocument.brand` is matched by DISPLAY NAME —
@@ -309,32 +317,91 @@ export class ProductRepository extends BaseRepository {
309
317
  * left exactly as it was.
310
318
  */
311
319
  const brandRef = input.brandSlug || input.brand || null;
312
- const [catSnap, brandSnap] = await Promise.all([
313
- leaf ? cats.doc(leaf).get() : Promise.resolve(null),
314
- brandRef ? cats.doc(brandRef).get() : Promise.resolve(null),
315
- ]);
316
- if (catSnap?.exists) {
317
- const cat = catSnap.data();
318
- // `parentIds` is ordered root-first / nearest-last (category-tree.ts:107),
319
- // and the stored chain is self-first / root-last. Reversing is what makes
320
- // the derived value byte-identical to what the seed hand-writes — verified
321
- // against products-standard-seed-data.ts:197.
322
- out.categorySlugs = [leaf, ...(cat.parentIds ?? []).slice().reverse()];
323
- out.category = leaf;
324
- const names = [
325
- cat.name,
326
- ...(cat.ancestors ?? []).map((a) => a?.name).reverse(),
327
- ].filter((n) => !!n);
328
- if (names.length)
329
- out.categoryNames = names;
320
+ /*
321
+ * ONE `getAll` for every assignment plus the brand, rather than a read per
322
+ * assignment. Rule #6 budgets ~3 sequential Firestore round-trips for a
323
+ * write path and this method is already one of them.
324
+ */
325
+ const refs = [...assignments.map((id) => cats.doc(id)), ...(brandRef ? [cats.doc(brandRef)] : [])];
326
+ const snaps = refs.length > 0 ? await this.db.getAll(...refs) : [];
327
+ const assignmentSnaps = snaps.slice(0, assignments.length);
328
+ const brandSnap = brandRef ? (snaps[assignments.length] ?? null) : null;
329
+ const catSnap = assignmentSnaps[0] ?? null;
330
+ /*
331
+ * I1/I2 — resolve every assignment, refuse the disallowed shapes, pick the
332
+ * default, then derive the ordered closure from all of them.
333
+ *
334
+ * 🛑 A row that does not resolve is DROPPED from the assignment list and
335
+ * warned about, never silently kept: an unresolvable id cannot be checked
336
+ * against the one-per-root rule and cannot contribute a chain, so keeping
337
+ * it would put an id in the closure that resolves to no page.
338
+ */
339
+ const resolved = [];
340
+ for (let i = 0; i < assignmentSnaps.length; i += 1) {
341
+ const snap = assignmentSnaps[i];
342
+ const id = assignments[i];
343
+ if (!snap?.exists) {
344
+ // Never destroy a chain we cannot improve on — see the `else if` below.
345
+ serverLogger.warn("Product category id does not resolve to a category", { leaf: id });
346
+ continue;
347
+ }
348
+ const d = snap.data();
349
+ resolved.push({
350
+ id,
351
+ name: d.name,
352
+ rootId: d.rootId,
353
+ tier: d.tier,
354
+ isLeaf: d.isLeaf,
355
+ parentIds: d.parentIds ?? [],
356
+ ancestorNames: (d.ancestors ?? []).map((a) => a?.name ?? ""),
357
+ });
330
358
  }
331
- else if (leaf) {
332
- // Never destroy a chain we cannot improve on. A leaf that is not a real
333
- // category row is a data problem to report, not a reason to blank the field.
334
- serverLogger.warn("Product category id does not resolve to a category", {
335
- leaf,
359
+ if (resolved.length > 0) {
360
+ assertAssignmentShape(resolved, input.isLot === true, {
361
+ enforceLeafFiling: ENFORCE_LEAF_FILING,
362
+ warn: (msg, meta) => serverLogger.warn(msg, { productTitle: input.title, ...meta }),
336
363
  });
364
+ /*
365
+ * The default's tie-break reads listing counts — and ONLY on a genuine
366
+ * tie, so a single-assignment product (the overwhelming majority) pays
367
+ * nothing for it. See `pickDefaultAssignment` for why depth, not count,
368
+ * is the first criterion.
369
+ */
370
+ let counts;
371
+ const deepest = Math.max(...resolved.map((c) => c.tier ?? -1));
372
+ if (resolved.filter((c) => (c.tier ?? -1) === deepest).length > 1) {
373
+ counts = await this.readAssignmentCounts(resolved.map((c) => c.id));
374
+ }
375
+ const defaultIdx = pickDefaultAssignment(resolved, counts);
376
+ const closure = buildTaxonomyClosure(resolved, defaultIdx);
377
+ /*
378
+ * 🛑 `categoryIds` is written in the DEFAULT-FIRST order, not the order
379
+ * the seller picked, so it is parallel to `categoryIdNames` and index 0
380
+ * agrees with `categorySlugs[0]`. Three fields that must line up, lined
381
+ * up at the one place that produces all three.
382
+ */
383
+ out.categoryIds = closure.categoryIdNames.length
384
+ ? [resolved[defaultIdx].id, ...resolved.filter((_, i) => i !== defaultIdx).map((c) => c.id)]
385
+ : resolved.map((c) => c.id);
386
+ out.categorySlugs = closure.categorySlugs;
387
+ out.category = closure.categorySlugs[0];
388
+ if (closure.categoryNames.length)
389
+ out.categoryNames = closure.categoryNames;
390
+ if (closure.categoryIdNames.length)
391
+ out.categoryIdNames = closure.categoryIdNames;
337
392
  }
393
+ /*
394
+ * 🛑 The old single-leaf block that stood here is GONE, and its two jobs
395
+ * moved for different reasons:
396
+ *
397
+ * - the chain + names build -> `buildTaxonomyClosure`, because it now has
398
+ * to span every assignment while keeping index 0 the default leaf;
399
+ * - the lot filing rule -> `assertAssignmentShape`, because it has to run
400
+ * per assignment and is now one half of a two-directional rule.
401
+ *
402
+ * Its `else if (leaf)` warn also moved, into the resolve loop above, where
403
+ * it fires per unresolvable assignment instead of only for the first.
404
+ */
338
405
  if (brandSnap?.exists) {
339
406
  const brand = brandSnap.data();
340
407
  if (brand.categoryType === "brand") {
@@ -349,6 +416,49 @@ export class ProductRepository extends BaseRepository {
349
416
  await this.applyInclusivePricing(input, out);
350
417
  return out;
351
418
  }
419
+ /**
420
+ * Listing counts for a handful of category ids, for the default tie-break.
421
+ *
422
+ * 🛑 Called ONLY when two assignments tie at the deepest tier, so the common
423
+ * single-assignment write pays nothing. One `getAll` of at most
424
+ * `MAX_CATEGORY_ASSIGNMENTS` documents by id — no index, no query.
425
+ *
426
+ * 🛑 A missing row is OMITTED from the map rather than defaulted to 0.
427
+ * `categoryCounts` documents the same rule: `undefined` means "not counted
428
+ * yet", and defaulting it to zero would let an uncounted node lose a tie it
429
+ * might have won.
430
+ *
431
+ * Reads the collection through `this.db` rather than importing
432
+ * `categoryCountsRepository` — no repository here imports another feature's
433
+ * (Root Cause #6/#18), and a raw read by id has neither problem.
434
+ */
435
+ async readAssignmentCounts(ids) {
436
+ const out = new Map();
437
+ if (ids.length === 0)
438
+ return out;
439
+ try {
440
+ const refs = ids.map((id) => this.db.collection(CATEGORY_COUNTS_COLLECTION).doc(id));
441
+ const snaps = await this.db.getAll(...refs);
442
+ for (const snap of snaps) {
443
+ if (!snap.exists)
444
+ continue;
445
+ const d = snap.data();
446
+ if (!d.total)
447
+ continue;
448
+ out.set(snap.id, sumCounts(d.total));
449
+ }
450
+ }
451
+ catch (err) {
452
+ /*
453
+ * A tie-break is not worth failing a product write over. Falling through
454
+ * with an empty map drops to level 3 — the order the seller picked — which
455
+ * is a deterministic answer rather than a wrong one.
456
+ */
457
+ void normalizeError(err);
458
+ serverLogger.warn("Could not read category counts for the default tie-break", { ids });
459
+ }
460
+ return out;
461
+ }
352
462
  /**
353
463
  * Resolve the GST rate floor, and gross up an ex-tax price if the seller
354
464
  * authors that way.
@@ -713,8 +823,58 @@ export class ProductRepository extends BaseRepository {
713
823
  searchTxt: buildProductSearchTxt(merged),
714
824
  });
715
825
  this.cacheSet(updated);
826
+ await this.syncCategoryCounts(current ?? null, updated);
716
827
  return updated;
717
828
  }
829
+ /**
830
+ * G3b — move the leaf→root listing counters for one product transition.
831
+ *
832
+ * 🛑 Called from HERE rather than from a Firestore trigger because
833
+ * `FieldValue.increment` is atomic but **not idempotent** and a trigger is
834
+ * at-least-once: a redelivered trigger double-counts permanently and
835
+ * invisibly. See `repository/category-counts.ts` for the full argument.
836
+ *
837
+ * 🛑 And the guard is on (chain, leaf, type, isLot, AVAILABILITY) — not on
838
+ * the taxonomy alone. `draft → published` changes no `categorySlugs`, so a
839
+ * taxonomy-only guard would never move the count on the one transition that
840
+ * matters most, with nothing to notice.
841
+ *
842
+ * Availability comes from `isListingRowAvailable`, the SAME predicate the
843
+ * listing query and `countersReconcile` use, so the incremental and nightly
844
+ * values can agree. Four partial copies of that predicate is what Root Cause
845
+ * #73 cost; this module takes the verdict as an argument rather than
846
+ * recomputing it.
847
+ */
848
+ async syncCategoryCounts(before, after) {
849
+ const now = new Date();
850
+ const side = (row) => ({
851
+ delta: row ? describeCountable(row) : null,
852
+ // A draft or archived row is not "available" and must not be counted —
853
+ // the count is of what a visitor can actually see, which is why the
854
+ // badge and the tab agree.
855
+ countsNow: !!row
856
+ && row.status === ProductStatusValues.PUBLISHED
857
+ && isListingRowAvailable(row, now),
858
+ });
859
+ const b = side(before);
860
+ const a = side(after);
861
+ if (!countsChanged(b, a))
862
+ return;
863
+ const patch = buildCountPatch([
864
+ { delta: b.countsNow ? b.delta : null, sign: -1 },
865
+ { delta: a.countsNow ? a.delta : null, sign: 1 },
866
+ ]);
867
+ await applyCountPatch(this.db, patch, (err) => {
868
+ // Never fails the product write: a count is a display number, and losing
869
+ // a seller's publish over an ancestor counter would trade a cosmetic
870
+ // staleness for real data. The nightly reconciler's `drifted` is how a
871
+ // missed increment becomes visible.
872
+ serverLogger.warn("categoryCounts increment failed", {
873
+ productId: after?.id ?? before?.id,
874
+ error: err.message,
875
+ });
876
+ });
877
+ }
718
878
  async create(input) {
719
879
  // Slug = document ID: derive slug from title first, then ensure uniqueness.
720
880
  const baseSlug = input.slug || slugify(input.title);
@@ -750,6 +910,7 @@ export class ProductRepository extends BaseRepository {
750
910
  .set(prepareForFirestore(productData));
751
911
  const created = { id, ...productData };
752
912
  this.cacheSet(created);
913
+ await this.syncCategoryCounts(null, created);
753
914
  return created;
754
915
  }
755
916
  async findByStore(storeId) {
@@ -767,6 +928,32 @@ export class ProductRepository extends BaseRepository {
767
928
  .get();
768
929
  return snap.docs.map((d) => this.mapDoc(d));
769
930
  }
931
+ /**
932
+ * I4 — products ASSIGNED to this category, not merely descended from it.
933
+ *
934
+ * 🛑 The distinction is the whole point, and the category-delete cascade is
935
+ * what needs it. `findByCategory` matches the derived CLOSURE, so it also
936
+ * returns every product in the descendant subtree — those belong to the
937
+ * children and travel with them when the children are reparented.
938
+ * `findBy("category", id)` matches only the scalar, i.e. the DEFAULT leaf, so
939
+ * a product that named this category as its SECOND assignment was invisible
940
+ * and the delete left a dangling id in its `categoryIds`.
941
+ *
942
+ * This matches the assigned list, and falls back to the scalar for every row
943
+ * written before `categoryIds` existed — two queries, because Firestore
944
+ * permits one array operator per query and there is no OR across fields.
945
+ */
946
+ async findByAssignment(categoryId) {
947
+ const [assigned, legacy] = await Promise.all([
948
+ this.getCollection().where("categoryIds", "array-contains", categoryId).get(),
949
+ this.getCollection().where(PRODUCT_FIELDS.CATEGORY, "==", categoryId).get(),
950
+ ]);
951
+ const byId = new Map();
952
+ for (const d of [...assigned.docs, ...legacy.docs]) {
953
+ byId.set(d.id, this.mapDoc(d));
954
+ }
955
+ return [...byId.values()];
956
+ }
770
957
  /**
771
958
  * Published products sharing at least one tag with the given list — "you
772
959
  * might also like" style discovery. `array-contains-any` caps at 10 values,
@@ -957,7 +1144,33 @@ export class ProductRepository extends BaseRepository {
957
1144
  }
958
1145
  async delete(id) {
959
1146
  this.cacheInvalidateForId(id);
960
- return super.delete(id);
1147
+ /*
1148
+ * 🛑 Read BEFORE deleting, or there is no chain left to decrement and the
1149
+ * counters keep the row forever — the one case where the delta cannot be
1150
+ * recovered from the write itself. One extra read on a delete, which is
1151
+ * the rarest product mutation there is.
1152
+ *
1153
+ * `safeRead`-shaped on purpose: a product that cannot be read must still
1154
+ * be deletable, and the nightly reconciler corrects the count.
1155
+ */
1156
+ let prior = null;
1157
+ try {
1158
+ prior = await super.findById(id);
1159
+ }
1160
+ catch (err) {
1161
+ const e = normalizeError(err);
1162
+ // 🛑 Logged, not swallowed. A failed pre-read means this product's
1163
+ // contribution stays on its ancestors' counters until the nightly
1164
+ // reconciler corrects them — bounded and self-healing, but invisible
1165
+ // unless it is said. `void normalizeError(err)` alone would look handled
1166
+ // while reporting nothing (audit-silent-degrade's normalize theatre).
1167
+ serverLogger.warn("product delete: pre-read failed, counts will drift until reconcile", {
1168
+ productId: id,
1169
+ error: e.message,
1170
+ });
1171
+ }
1172
+ await super.delete(id);
1173
+ await this.syncCategoryCounts(prior, null);
961
1174
  }
962
1175
  /**
963
1176
  * Shared scope construction for `list()` and `countBy*()` — both must narrow
@@ -1229,7 +1442,21 @@ ProductRepository.SIEVE_FIELDS = {
1229
1442
  price: { canFilter: true, canSort: true },
1230
1443
  stockQuantity: { canFilter: true, canSort: true },
1231
1444
  availableQuantity: { canFilter: true, canSort: true },
1232
- viewCount: { canFilter: true, canSort: true },
1445
+ /*
1446
+ * 🛑 D8 closes F1 #1. `viewCount` was `canFilter`/`canSort` here with NO
1447
+ * writer on any document — measured: absent from all 72 products and all 58
1448
+ * categories — and a Firestore `orderBy` EXCLUDES every document lacking
1449
+ * the ordering field, so `?sort=viewCount` returned zero rows with a valid
1450
+ * index present (Root Cause #100). A5 removed the UI sort and the reachable
1451
+ * field survived.
1452
+ *
1453
+ * Replaced by two fields that have a real nightly writer
1454
+ * (`runProductViewWindows`) and whose names state the window, which
1455
+ * `viewCount` never did — it read as a lifetime figure while nothing
1456
+ * maintained it.
1457
+ */
1458
+ views7d: { canFilter: true, canSort: true },
1459
+ views30d: { canFilter: true, canSort: true },
1233
1460
  currentBid: { canFilter: true, canSort: true },
1234
1461
  bidCount: { canFilter: true, canSort: true },
1235
1462
  bidsHaveStarted: { canFilter: true, canSort: false },
@@ -159,11 +159,57 @@ export interface ProductDocument extends BaseDocument {
159
159
  seoTitle?: string;
160
160
  seoDescription?: string;
161
161
  seoKeywords?: string[];
162
- /** Primary category FK slugs (1-to-many). Use categorySlugs[0] for display. */
162
+ /**
163
+ * I1 — the ASSIGNED categories. What a human picked, and the only one of the
164
+ * three category fields that is authored rather than derived.
165
+ *
166
+ * At most `MAX_CATEGORY_ASSIGNMENTS` (4), and 🛑 **at most ONE per root** —
167
+ * "a category can be multiple, they should not be under same tree". A single
168
+ * files at LEAVES, a lot at NON-LEAVES. All of it is enforced in
169
+ * `assertAssignmentShape`, which `deriveTaxonomy` calls on every write path.
170
+ *
171
+ * `categoryIds[0]` is NOT the default — the default is the DEEPEST assignment
172
+ * (count breaks a tie), and `categorySlugs[0]` is what carries it.
173
+ */
174
+ categoryIds?: string[];
175
+ /**
176
+ * I5b — DERIVED. One display name per assignment, parallel to `categoryIds`.
177
+ *
178
+ * Lets the breadcrumb row offer the OTHER categories as real links without a
179
+ * second read on a cached page. Deliberately not their full chains: the
180
+ * alternate crumb shows the other categories, never N stacked trails.
181
+ */
182
+ categoryIdNames?: string[];
183
+ /**
184
+ * DERIVED closure over every assignment — the field every category QUERY
185
+ * matches on, via `array-contains` / `array-contains-any`.
186
+ *
187
+ * 🛑 ORDERED, and that is load-bearing: the DEFAULT chain first (so
188
+ * `categorySlugs[0]` is still the default leaf, which ~30 call sites read it
189
+ * as), then each remaining chain leaf-first, deduped. A set-shaped union
190
+ * would break all of them silently — see `category-assignment.ts`'s header.
191
+ *
192
+ * Never authored. An inbound value is treated as a legacy single leaf: only
193
+ * its HEAD is read, as one assignment.
194
+ */
163
195
  categorySlugs: string[];
164
- /** Display-name cache parallel to categorySlugs[]. */
196
+ /**
197
+ * Display-name cache for the DEFAULT chain only, leaf-first.
198
+ *
199
+ * 🛑 Deliberately NOT parallel to `categorySlugs` once a product has two
200
+ * assignments: a union of two chains has no readable path, and the breadcrumb
201
+ * builder reverses this array to render root-first. Keeping it single-chain
202
+ * is what leaves every existing breadcrumb byte-identical.
203
+ */
165
204
  categoryNames?: string[];
166
- /** @deprecated Read-only alias — normalised from legacy docs on load. */
205
+ /**
206
+ * The default leaf, mirrored. 🛑 NOT dead and NOT read-only, whatever the
207
+ * `@deprecated` tag here said for a long time: it is in
208
+ * `PRODUCT_UPDATABLE_FIELDS`, it is what `CategoryInlineSelect` writes, it is
209
+ * the one category key `productCreateSchema` declares, and `deriveTaxonomy`
210
+ * prefers it over the derived chain. Sending it alone still means exactly
211
+ * "one assignment".
212
+ */
167
213
  category?: string;
168
214
  /** @deprecated Read-only alias. */
169
215
  categoryName?: string;
@@ -224,6 +270,101 @@ export interface ProductDocument extends BaseDocument {
224
270
  * always accepted. See `_internal/shared/features/orders/return-reasons`.
225
271
  */
226
272
  finalSale?: boolean;
273
+ /**
274
+ * This listing is a multi-item LOT rather than a single item.
275
+ *
276
+ * 🛑 A lot files under a NON-LEAF category and a single may file anywhere —
277
+ * enforced one-directionally in `ProductRepository.deriveTaxonomy`, which
278
+ * already holds the leaf document so the check costs no extra read. The
279
+ * reason is a claim about the ITEMS, not about counting: filing "12 assorted
280
+ * Burst beys" under `category-dragoon-g` asserts all twelve are Dragoon G.
281
+ *
282
+ * Counting is disambiguated separately, by `categoryCounts` carrying its own
283
+ * `lots` pair — so singles and lots are never summed into one number.
284
+ *
285
+ * A lot is the INVERSE of per-unit selling: many objects, one listing, one
286
+ * stock count. Two physically distinct units of one model are two listings
287
+ * each at `stockQuantity: 1` (see `unitLabel`), never one listing of two.
288
+ */
289
+ isLot?: boolean;
290
+ /**
291
+ * How many items the lot contains. Drives the "lot of 12" label and the
292
+ * per-item price hint.
293
+ *
294
+ * Deliberately NOT required when `isLot` is true: a seller who knows it is a
295
+ * lot but has not counted the pile yet must still be able to save a draft.
296
+ */
297
+ lotSize?: number;
298
+ /**
299
+ * Distinguishes two physically distinct units of the SAME model and the same
300
+ * catalogue variant — both white Dragoon G, one with a scuffed attack ring.
301
+ *
302
+ * 🛑 Referenced by `isLot`'s comment above since G3a and declared NOWHERE
303
+ * until now, so the rule that comment states had no field to state it about.
304
+ *
305
+ * 🛑 NOT `itemCode`, which is the grading axis and documents `"PSA 10"` as
306
+ * its example — a grade is a third party's claim about condition. A unit
307
+ * label is a disambiguator the seller chose.
308
+ *
309
+ * It exists because the alternative is what the crawl measured at
310
+ * thelimitlesscorner: one listing per physical unit with the condition
311
+ * defects welded into the TITLE.
312
+ */
313
+ unitLabel?: string;
314
+ /**
315
+ * This listing is a COMPONENT, and this is which one.
316
+ *
317
+ * The `partGlossary` document id (`${slot}--${tail}`), stored whole.
318
+ *
319
+ * 🛑 A single id rather than the `{classification, code}` pair the plan
320
+ * proposed, and the departure is deliberate: a pair is a second place the
321
+ * key gets composed, and `partGlossaryId()` is already the one place that
322
+ * composes it. Two composers of one key is Root Cause #75's shape, and the
323
+ * id is also directly the URL (`/parts/{slot}/{tail}`) with no re-derivation.
324
+ *
325
+ * Optional even on a part listing: the glossary holds 753 rows and does not
326
+ * cover every component ever moulded, so a spare we cannot classify is still
327
+ * sellable and still countable.
328
+ */
329
+ partGlossaryId?: string;
330
+ /**
331
+ * What distinguishes two physical units of the same part code — "pink",
332
+ * "translucent blue", "gold".
333
+ *
334
+ * 🛑 NOT a `productFeature`. That vocabulary is ~169 curated terms in 13
335
+ * groups and `colour` is not one of them, deliberately: colourways are
336
+ * open-ended and per-part, and pouring them into a shared vocabulary gives a
337
+ * facet with hundreds of single-use values — the long-tail-tag anti-pattern
338
+ * this project REJECTED when redeyemerch was crawled (50–160 tags per
339
+ * product, 162 products on one tag).
340
+ *
341
+ * 🛑 Free text, and the VOCABULARY is deliberately not seeded. The corpus
342
+ * carries part names, codes and weights and no colours at all, so a seeded
343
+ * list would be invented. It accumulates from real spares (GAP-8: data
344
+ * entry, not code).
345
+ */
346
+ colourway?: string;
347
+ /** Mould/version variant — the "storm pegasus version" of a shared part. */
348
+ mouldVariant?: string;
349
+ /**
350
+ * Built HERE from components. Each entry decremented a part listing's stock.
351
+ *
352
+ * 🛑 The decrement must happen in a real transaction that RE-READS, and it
353
+ * must read `availableQuantity` rather than `stockQuantity`: a component with
354
+ * 10 in stock and 3 reserved has SEVEN assemblable, and assembling from the
355
+ * wrong field builds a bey out of parts somebody has already bought. The
356
+ * PhonePe path's `runBatch` over a snapshot read outside any transaction is
357
+ * the oversell this avoids, five components at a time.
358
+ */
359
+ assembledFrom?: Array<{
360
+ slotKey: string;
361
+ productId: string;
362
+ quantity: number;
363
+ }>;
364
+ /** Views in the trailing 7 days. */
365
+ views7d?: number;
366
+ /** Views in the trailing 30 days. */
367
+ views30d?: number;
227
368
  condition?: "new" | "like_new" | "good" | "fair" | "poor" | "used" | "refurbished" | "broken" | "graded";
228
369
  insurance?: boolean;
229
370
  insuranceCost?: number;
@@ -505,10 +646,10 @@ export declare const ProductStatusValues: {
505
646
  readonly ARCHIVED: "archived";
506
647
  };
507
648
  export declare const PRODUCT_COLLECTION: "products";
508
- export declare const PRODUCT_INDEXED_FIELDS: readonly ["storeId", "status", "category", "featured", "listingType", "isPromoted", "isOnSale", "isSold", "searchTxt", "createdAt", "barcodeId", "physicalLocation.zone"];
649
+ export declare const PRODUCT_INDEXED_FIELDS: readonly ["storeId", "status", "category", "categoryIds", "featured", "listingType", "isPromoted", "isOnSale", "isSold", "searchTxt", "createdAt", "barcodeId", "physicalLocation.zone"];
509
650
  export declare const DEFAULT_PRODUCT_DATA: Partial<ProductDocument>;
510
- export declare const PRODUCT_PUBLIC_FIELDS: readonly ["allowOffers", "minOfferPercent", "id", "title", "description", "category", "subcategory", "brand", "price", "currency", "stockQuantity", "availableQuantity", "images", "status", "storeName", "featured", "tags", "specifications", "features", "shippingInfo", "returnPolicy", "finalSale", "listingType", "auctionEndDate", "startingBid", "currentBid", "bidCount", "reservePrice", "buyNowPrice", "minBidIncrement", "autoExtendable", "auctionExtensionMinutes", "auctionShippingPaidBy", "bidsHaveStarted", "grading", "card", "classified", "digitalCode", "liveItem", "catalogProductId", "preOrderDeliveryDate", "preOrderDepositPercent", "preOrderDepositAmount", "preOrderMaxQuantity", "preOrderCurrentCount", "preOrderProductionStatus", "preOrderCancellable", "condition", "insurance", "insuranceCost", "shippingPaidBy", "isPromoted", "isOnSale", "isSold", "slug", "seoTitle", "seoDescription", "seoKeywords", "viewCount", "customFields", "customSections", "sublistingCategoryId", "groupId", "isGroupParent", "groupParentSlug", "groupChildSlugs", "groupTitle", "createdAt"];
511
- export declare const PRODUCT_UPDATABLE_FIELDS: readonly ["title", "description", "category", "subcategory", "brand", "price", "stockQuantity", "images", "status", "tags", "specifications", "features", "shippingInfo", "returnPolicy", "finalSale", "pickupAddressId", "condition", "insurance", "shippingPaidBy", "autoExtendable", "auctionExtensionMinutes", "auctionShippingPaidBy", "reservePrice", "buyNowPrice", "minBidIncrement", "bidsHaveStarted", "grading", "card", "classified", "digitalCode", "liveItem", "catalogProductId", "listingType", "preOrderDeliveryDate", "preOrderDepositPercent", "preOrderDepositAmount", "preOrderMaxQuantity", "preOrderProductionStatus", "preOrderCancellable", "isOnSale", "isSold", "allowShipBeforeEmiComplete", "gstRate", "hsnCode", "authenticity", "printMeta", "seoTitle", "seoDescription", "seoKeywords", "customFields", "customSections", "sublistingCategoryId", "groupId", "isGroupParent", "groupParentSlug", "groupChildSlugs", "groupTitle", "barcodeId"];
651
+ export declare const PRODUCT_PUBLIC_FIELDS: readonly ["allowOffers", "minOfferPercent", "id", "title", "description", "category", "categoryIds", "categoryIdNames", "subcategory", "brand", "price", "currency", "stockQuantity", "availableQuantity", "images", "status", "storeName", "featured", "tags", "specifications", "features", "shippingInfo", "returnPolicy", "finalSale", "isLot", "lotSize", "unitLabel", "partGlossaryId", "colourway", "mouldVariant", "assembledFrom", "views7d", "views30d", "unitLabel", "partGlossaryId", "colourway", "mouldVariant", "assembledFrom", "listingType", "auctionEndDate", "startingBid", "currentBid", "bidCount", "reservePrice", "buyNowPrice", "minBidIncrement", "autoExtendable", "auctionExtensionMinutes", "auctionShippingPaidBy", "bidsHaveStarted", "grading", "card", "classified", "digitalCode", "liveItem", "catalogProductId", "preOrderDeliveryDate", "preOrderDepositPercent", "preOrderDepositAmount", "preOrderMaxQuantity", "preOrderCurrentCount", "preOrderProductionStatus", "preOrderCancellable", "condition", "insurance", "insuranceCost", "shippingPaidBy", "isPromoted", "isOnSale", "isSold", "slug", "seoTitle", "seoDescription", "seoKeywords", "viewCount", "customFields", "customSections", "sublistingCategoryId", "groupId", "isGroupParent", "groupParentSlug", "groupChildSlugs", "groupTitle", "createdAt"];
652
+ export declare const PRODUCT_UPDATABLE_FIELDS: readonly ["title", "description", "category", "categoryIds", "subcategory", "brand", "price", "stockQuantity", "images", "status", "tags", "specifications", "features", "shippingInfo", "returnPolicy", "finalSale", "isLot", "lotSize", "pickupAddressId", "condition", "insurance", "shippingPaidBy", "autoExtendable", "auctionExtensionMinutes", "auctionShippingPaidBy", "reservePrice", "buyNowPrice", "minBidIncrement", "bidsHaveStarted", "grading", "card", "classified", "digitalCode", "liveItem", "catalogProductId", "listingType", "preOrderDeliveryDate", "preOrderDepositPercent", "preOrderDepositAmount", "preOrderMaxQuantity", "preOrderProductionStatus", "preOrderCancellable", "isOnSale", "isSold", "allowShipBeforeEmiComplete", "gstRate", "hsnCode", "authenticity", "printMeta", "seoTitle", "seoDescription", "seoKeywords", "customFields", "customSections", "sublistingCategoryId", "groupId", "isGroupParent", "groupParentSlug", "groupChildSlugs", "groupTitle", "barcodeId"];
512
653
  export type ProductCreateInput = Omit<ProductDocument, "id" | "createdAt" | "updatedAt" | "availableQuantity" | "bidCount" | "currentBid" | "auctionOriginalEndDate">;
513
654
  export type ProductUpdateInput = Partial<Pick<ProductDocument, (typeof PRODUCT_UPDATABLE_FIELDS)[number]>>;
514
655
  export type ProductAdminUpdateInput = Partial<Omit<ProductDocument, "id" | "createdAt">>;
@@ -16,6 +16,10 @@ export const PRODUCT_INDEXED_FIELDS = [
16
16
  "storeId",
17
17
  "status",
18
18
  "category",
19
+ /* Indexed: `array-contains` on the ASSIGNED list is what the category
20
+ * delete cascade queries to find a product filed here as its SECOND
21
+ * category — `findBy("category", id)` sees only the default leaf. */
22
+ "categoryIds",
19
23
  "featured",
20
24
  "listingType",
21
25
  "isPromoted",
@@ -74,6 +78,9 @@ export const PRODUCT_PUBLIC_FIELDS = [
74
78
  "title",
75
79
  "description",
76
80
  "category",
81
+ /* Public: the chips on a detail page render the assignments. */
82
+ "categoryIds",
83
+ "categoryIdNames",
77
84
  "subcategory",
78
85
  "brand",
79
86
  "price",
@@ -92,6 +99,35 @@ export const PRODUCT_PUBLIC_FIELDS = [
92
99
  // Buyer-facing: drives the "Final Sale" card pill and the detail-page
93
100
  // Delivery & Returns line. Absent means final sale — read via isFinalSale().
94
101
  "finalSale",
102
+ // Buyer-facing by necessity: "lot of 12" is the single most important thing
103
+ // about a lot listing, and a buyer who cannot see it thinks they are bidding
104
+ // on one item.
105
+ "isLot",
106
+ "lotSize",
107
+ /* D13/D14 — buyer-facing by definition: a colourway is WHY someone buys that
108
+ * particular spare, and a unit label is how they tell two identical listings
109
+ * apart. `assembledFrom` is listed too — it names product ids that are
110
+ * already public listings, and "built from these parts" is provenance a
111
+ * buyer should see. */
112
+ "unitLabel",
113
+ "partGlossaryId",
114
+ "colourway",
115
+ "mouldVariant",
116
+ "assembledFrom",
117
+ /* D8 — public because "N people viewed this this week" is the honest social
118
+ * proof H2 is built on, and it is a figure we measured. 🛑 Deliberately NOT
119
+ * in PRODUCT_UPDATABLE_FIELDS: a seller who could write their own view count
120
+ * turns a measurement into a claim. */
121
+ "views7d",
122
+ "views30d",
123
+ /* 🛑 D13/D14. Listed here or an update STRIPS them: the write schema has no
124
+ * `.passthrough()`, so a field absent from this list is dropped before it
125
+ * reaches Firestore while the write still returns 200 (Root Cause #101). */
126
+ "unitLabel",
127
+ "partGlossaryId",
128
+ "colourway",
129
+ "mouldVariant",
130
+ "assembledFrom",
95
131
  "listingType",
96
132
  "auctionEndDate",
97
133
  "startingBid",
@@ -147,6 +183,12 @@ export const PRODUCT_UPDATABLE_FIELDS = [
147
183
  "title",
148
184
  "description",
149
185
  "category",
186
+ /* I1 — the ASSIGNED list. 🛑 Named here or an update STRIPS it while still
187
+ * returning 200 (Root Cause #101), which is exactly how a UI-created
188
+ * product lost its chain. `categorySlugs`/`categoryNames` stay OUT: they
189
+ * are derived, and a caller that could set them could desync the closure
190
+ * from the assignments it is supposed to be the closure OF. */
191
+ "categoryIds",
150
192
  "subcategory",
151
193
  "brand",
152
194
  "price",
@@ -159,6 +201,13 @@ export const PRODUCT_UPDATABLE_FIELDS = [
159
201
  "shippingInfo",
160
202
  "returnPolicy",
161
203
  "finalSale",
204
+ /* 🛑 Listed here or an update STRIPS them. The write schema has no
205
+ * `.passthrough()`, so a field absent from this list is dropped before it
206
+ * reaches Firestore while the write still returns 200 — exactly how a
207
+ * UI-created product lost its `categorySlugs` chain (Root Cause #101) and how
208
+ * `compareAtPrice` is silently discarded today (F1 #25). */
209
+ "isLot",
210
+ "lotSize",
162
211
  "pickupAddressId",
163
212
  "condition",
164
213
  "insurance",
@@ -0,0 +1,33 @@
1
+ /**
2
+ * D2 Fix 4 — the cross-resource search RESULTS page.
3
+ *
4
+ * Replaces a pure redirect. Before this, `/search` mapped each of 15
5
+ * `SearchResourceType` values to a browse route and `redirect()`ed, so a buyer
6
+ * searching "dranzer" could not see that we have a product, a category, a blog
7
+ * post and an FAQ about it — the dropdown only chose which single listing page
8
+ * to send them to.
9
+ *
10
+ * Measured on production: "burst" returns 21 hits across SIX groups
11
+ * (listings, categories, brands, stores, articles, FAQs); "zzzznope" and "!!!"
12
+ * return none.
13
+ *
14
+ * 🛑 STILL `noindex`, and that is deliberate rather than inherited. A search
15
+ * results page is the classic crawl trap: one URL per query string, thin
16
+ * content, and no canonical answer to index. The redirect it replaces was
17
+ * `noindex` too, so this changes the page's usefulness to a HUMAN without
18
+ * changing its relationship to a crawler. The `moreHref` links go to the real
19
+ * indexable listing pages.
20
+ *
21
+ * 🛑 NO PRICES ANYWHERE. Prices are hidden from signed-out visitors and this
22
+ * is the one surface where gating every row would be most of the markup.
23
+ * A title plus a link is honest, needs no `<GatedPrice>`, and cannot leak —
24
+ * there is nothing on the row to leak.
25
+ */
26
+ import React from "react";
27
+ import type { SearchEverythingResult } from "../../../_internal/server/features/search/search-everything";
28
+ export interface SearchResultsPageViewProps {
29
+ result: SearchEverythingResult;
30
+ /** Rendered above the groups — the consumer owns the input control. */
31
+ renderSearchInput?: () => React.ReactNode;
32
+ }
33
+ export declare function SearchResultsPageView({ result, renderSearchInput, }: SearchResultsPageViewProps): React.JSX.Element;