@mohasinac/appkit 4.37.1 → 4.39.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 (172) hide show
  1. package/dist/_internal/client/features/digital-code/CodeRevealPanel.d.ts +20 -1
  2. package/dist/_internal/client/features/digital-code/CodeRevealPanel.js +21 -1
  3. package/dist/_internal/client/features/digital-code/DigitalContentPoolManager.d.ts +40 -0
  4. package/dist/_internal/client/features/digital-code/DigitalContentPoolManager.js +67 -0
  5. package/dist/_internal/server/features/bundles/og.d.ts +0 -1
  6. package/dist/_internal/server/features/bundles/og.js +2 -18
  7. package/dist/_internal/server/features/catalogue/og.d.ts +0 -1
  8. package/dist/_internal/server/features/catalogue/og.js +1 -9
  9. package/dist/_internal/server/features/checkout/actions.js +18 -0
  10. package/dist/_internal/server/features/classified/og.d.ts +0 -1
  11. package/dist/_internal/server/features/classified/og.js +1 -9
  12. package/dist/_internal/server/features/digital-code/download.d.ts +60 -0
  13. package/dist/_internal/server/features/digital-code/download.js +128 -0
  14. package/dist/_internal/server/features/digital-code/og.d.ts +0 -1
  15. package/dist/_internal/server/features/digital-code/og.js +1 -9
  16. package/dist/_internal/server/features/digital-code/pool.d.ts +101 -0
  17. package/dist/_internal/server/features/digital-code/pool.js +208 -0
  18. package/dist/_internal/server/features/digital-code/upload.d.ts +51 -0
  19. package/dist/_internal/server/features/digital-code/upload.js +144 -0
  20. package/dist/_internal/server/features/grouped/og.d.ts +7 -4
  21. package/dist/_internal/server/features/grouped/og.js +7 -6
  22. package/dist/_internal/server/features/item-requests/og.d.ts +0 -1
  23. package/dist/_internal/server/features/item-requests/og.js +2 -18
  24. package/dist/_internal/server/features/live/og.d.ts +0 -1
  25. package/dist/_internal/server/features/live/og.js +1 -9
  26. package/dist/_internal/server/features/prize-draws/og.js +5 -10
  27. package/dist/_internal/server/features/products/og.js +5 -2
  28. package/dist/_internal/server/features/seo/sitemap.js +10 -2
  29. package/dist/_internal/server/features/shared/listing-adapters.d.ts +1 -2
  30. package/dist/_internal/server/features/shared/listing-adapters.js +5 -3
  31. package/dist/_internal/server/jobs/core/cleanupRtdbEvents.js +40 -0
  32. package/dist/_internal/server/jobs/core/countersReconcile.js +153 -40
  33. package/dist/_internal/server/jobs/core/onCategoryWrite.js +33 -0
  34. package/dist/_internal/server/jobs/core/onProductWrite.js +93 -25
  35. package/dist/_internal/server/notifications/send-recorder.d.ts +79 -0
  36. package/dist/_internal/server/notifications/send-recorder.js +126 -0
  37. package/dist/_internal/shared/listing-types/_registry.d.ts +31 -4
  38. package/dist/_internal/shared/listing-types/art/config.d.ts +1 -0
  39. package/dist/_internal/shared/listing-types/art/config.js +2 -0
  40. package/dist/_internal/shared/listing-types/auction/config.d.ts +1 -0
  41. package/dist/_internal/shared/listing-types/auction/config.js +3 -0
  42. package/dist/_internal/shared/listing-types/classified/config.d.ts +1 -0
  43. package/dist/_internal/shared/listing-types/classified/config.js +3 -0
  44. package/dist/_internal/shared/listing-types/digital-code/config.d.ts +1 -0
  45. package/dist/_internal/shared/listing-types/digital-code/config.js +3 -0
  46. package/dist/_internal/shared/listing-types/live/config.d.ts +1 -0
  47. package/dist/_internal/shared/listing-types/live/config.js +3 -0
  48. package/dist/_internal/shared/listing-types/pre-order/config.d.ts +1 -0
  49. package/dist/_internal/shared/listing-types/pre-order/config.js +2 -0
  50. package/dist/_internal/shared/listing-types/prize-draw/config.d.ts +1 -0
  51. package/dist/_internal/shared/listing-types/prize-draw/config.js +3 -0
  52. package/dist/_internal/shared/listing-types/standard/config.d.ts +1 -0
  53. package/dist/_internal/shared/listing-types/standard/config.js +2 -0
  54. package/dist/_internal/shared/listing-types/stickers/config.d.ts +1 -0
  55. package/dist/_internal/shared/listing-types/stickers/config.js +2 -0
  56. package/dist/client/observability/reportClientError.js +11 -1
  57. package/dist/client.d.ts +6 -0
  58. package/dist/client.js +10 -0
  59. package/dist/core/hooks/useSyncManager.js +11 -1
  60. package/dist/features/account/hooks/useNotifications.js +25 -5
  61. package/dist/features/auctions/components/AuctionBottomActions.js +10 -2
  62. package/dist/features/auctions/components/AuctionDetailPageView.js +12 -3
  63. package/dist/features/auctions/components/LiveBidPrice.js +2 -2
  64. package/dist/features/auctions/components/LiveMinIncrement.d.ts +1 -1
  65. package/dist/features/auctions/components/LiveMinIncrement.js +8 -0
  66. package/dist/features/auctions/components/MarketplaceAuctionCard.js +3 -3
  67. package/dist/features/auctions/components/PlaceBidFormClient.js +16 -1
  68. package/dist/features/auctions/hooks/useAuctions.js +12 -1
  69. package/dist/features/auctions/hooks/useRealtimeBids.d.ts +3 -0
  70. package/dist/features/auctions/hooks/useRealtimeBids.js +138 -36
  71. package/dist/features/blog/components/BlogIndexPageView.js +10 -1
  72. package/dist/features/blog/repository/blog.repository.d.ts +14 -0
  73. package/dist/features/blog/repository/blog.repository.js +23 -1
  74. package/dist/features/catalogue/components/PublicCatalogueItemDetailView.js +2 -2
  75. package/dist/features/catalogue/components/PublicCatalogueView.js +2 -2
  76. package/dist/features/categories/components/BundleCollage.js +10 -5
  77. package/dist/features/categories/components/BundleDetailView.js +7 -3
  78. package/dist/features/categories/components/CategoryDetailPageView.js +1 -1
  79. package/dist/features/categories/components/CategoryProductsListing.js +4 -1
  80. package/dist/features/categories/components/CategoryTree.js +1 -1
  81. package/dist/features/categories/repository/categories.repository.d.ts +34 -2
  82. package/dist/features/categories/repository/categories.repository.js +31 -9
  83. package/dist/features/classified/components/ClassifiedDetailPageView.js +2 -2
  84. package/dist/features/contact/email.js +36 -1
  85. package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +2 -2
  86. package/dist/features/homepage/components/FeaturedBundlesSection.js +4 -4
  87. package/dist/features/live/components/LiveItemDetailPageView.js +2 -2
  88. package/dist/features/orders/repository/orders.repository.d.ts +21 -1
  89. package/dist/features/orders/repository/orders.repository.js +26 -9
  90. package/dist/features/pre-orders/components/MarketplacePreorderCard.js +3 -3
  91. package/dist/features/pre-orders/components/PreOrderActionsClient.js +2 -2
  92. package/dist/features/pre-orders/components/PreOrderDetailPageView.js +2 -2
  93. package/dist/features/pre-orders/components/PreOrdersIndexListing.js +4 -1
  94. package/dist/features/products/actions/product-actions.d.ts +9 -0
  95. package/dist/features/products/actions/product-actions.js +11 -0
  96. package/dist/features/products/columns/productTableColumns.js +2 -2
  97. package/dist/features/products/components/AuctionsIndexListing.js +4 -1
  98. package/dist/features/products/components/BidHistory.js +2 -2
  99. package/dist/features/products/components/CompareOverlay.js +2 -2
  100. package/dist/features/products/components/GroupMemberPicker.js +5 -5
  101. package/dist/features/products/components/ListingBottomActions.js +12 -3
  102. package/dist/features/products/components/MakeOfferButton.js +7 -1
  103. package/dist/features/products/components/MarketplaceBundleCard.js +4 -4
  104. package/dist/features/products/components/MarketplacePrizeDrawCard.js +2 -2
  105. package/dist/features/products/components/PrizeDrawCollage.js +16 -5
  106. package/dist/features/products/components/PrizeDrawEntryActions.js +2 -2
  107. package/dist/features/products/components/PrizeDrawsIndexListing.js +4 -1
  108. package/dist/features/products/components/PrizeRevealModal.js +2 -2
  109. package/dist/features/products/components/ProductDetailActions.js +10 -3
  110. package/dist/features/products/components/ProductFilters.js +2 -2
  111. package/dist/features/products/components/ProductGrid.js +5 -2
  112. package/dist/features/products/components/ProductsIndexListing.js +12 -1
  113. package/dist/features/products/components/ProductsIndexPageView.js +26 -0
  114. package/dist/features/products/components/ShowGroupSection.js +2 -2
  115. package/dist/features/products/components/SublistingCarouselSection.js +2 -2
  116. package/dist/features/products/constants/listing-tabs.d.ts +36 -12
  117. package/dist/features/products/constants/listing-tabs.js +38 -14
  118. package/dist/features/products/constants/sieve.d.ts +14 -0
  119. package/dist/features/products/constants/sieve.js +39 -0
  120. package/dist/features/products/repository/products.repository.d.ts +35 -0
  121. package/dist/features/products/repository/products.repository.js +116 -3
  122. package/dist/features/products/schemas/firestore.d.ts +37 -0
  123. package/dist/features/seller/actions/seller-actions.js +4 -13
  124. package/dist/features/seller/components/SellerProductShell.d.ts +7 -1
  125. package/dist/features/seller/components/SellerProductShell.js +11 -2
  126. package/dist/features/stores/api/[storeSlug]/reviews/route.d.ts +6 -1
  127. package/dist/features/stores/api/[storeSlug]/reviews/route.js +89 -30
  128. package/dist/features/stores/components/StoresIndexPageView.js +17 -1
  129. package/dist/features/tester/seed-data/authored/admin__users-trust.js +1 -1
  130. package/dist/features/tester/seed-data/authored/buying__bidding.js +5 -4
  131. package/dist/features/tester/seed-data/authored/buying__browsing-search.js +85 -85
  132. package/dist/features/tester/seed-data/authored/buying__image-tile-layout.js +2 -2
  133. package/dist/features/tester/seed-data/authored/buying__offers.js +1 -1
  134. package/dist/features/tester/seed-data/authored/buying__product-detail.js +53 -15
  135. package/dist/features/tester/seed-data/authored/buying__user-dashboard-extras.js +6 -6
  136. package/dist/features/tester/seed-data/authored/content-discovery__search.js +6 -4
  137. package/dist/features/tester/seed-data/authored/design-ux__carousel-arrow-bounds.js +4 -4
  138. package/dist/features/tester/seed-data/authored/design-ux__homepage-carousels.js +2 -2
  139. package/dist/features/tester/seed-data/authored/design-ux__status-badge-legibility.js +4 -4
  140. package/dist/features/tester/seed-data/authored/design-ux__sticky-cta-bar.js +8 -8
  141. package/dist/features/tester/seed-data/authored/public-pages__core-listing-pages.js +8 -8
  142. package/dist/features/tester/seed-data/authored/seo__canonical-and-host.js +16 -0
  143. package/dist/features/tester/seed-data/authored/seo__og-images.js +17 -0
  144. package/dist/features/tester/seed-data/stores-tester-seed-data.js +41 -2
  145. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +36 -6
  146. package/dist/features/tester/utils/phases.js +78 -12
  147. package/dist/features/wishlist/components/WishlistPage.js +2 -2
  148. package/dist/index.d.ts +2 -1
  149. package/dist/index.js +4 -1
  150. package/dist/providers/db-firebase/rtdb-paths.d.ts +7 -0
  151. package/dist/providers/db-firebase/rtdb-paths.js +7 -0
  152. package/dist/react/contexts/SessionContext.js +40 -1
  153. package/dist/react/hooks/useCanSeePrices.d.ts +37 -0
  154. package/dist/react/hooks/useCanSeePrices.js +40 -0
  155. package/dist/seed/addresses-seed-data.js +36 -2
  156. package/dist/seed/users-seed-data.js +10 -2
  157. package/dist/seo/json-ld.d.ts +21 -0
  158. package/dist/seo/json-ld.js +45 -0
  159. package/dist/server.d.ts +3 -0
  160. package/dist/server.js +12 -0
  161. package/dist/styles.css +41 -0
  162. package/dist/ui/components/FilePickerButton.d.ts +32 -0
  163. package/dist/ui/components/FilePickerButton.js +23 -0
  164. package/dist/ui/components/GatedPrice.d.ts +66 -0
  165. package/dist/ui/components/GatedPrice.js +80 -0
  166. package/dist/ui/components/GatedPrice.style.css +40 -0
  167. package/dist/ui/components/PriceDisplay.d.ts +12 -0
  168. package/dist/ui/components/PriceDisplay.js +17 -17
  169. package/dist/ui/components/index.style.css +1 -0
  170. package/dist/ui/index.d.ts +4 -0
  171. package/dist/ui/index.js +8 -0
  172. package/package.json +1 -1
@@ -390,9 +390,9 @@ const rawTesterChecklistItems = [
390
390
  href: "/products",
391
391
  },
392
392
  {
393
- key: "pre-orders-reachable-from-products",
394
- label: "Pre-orders are reachable from /products — Filters → Listing type → tick Pre-Orders → Apply, and confirm real pre-order items appear with a \"Pre-Order\" badge",
395
- description: "This was the original report: pre-orders (and auctions, prize draws, art, stickers) had no entry in the type filter at all, so they could only be found via their own dedicated pages.",
393
+ key: "every-listing-type-reachable-from-nav-or-products",
394
+ label: "Every listing type is reachable — the five with a main-nav entry from the nav, and the four without one from the /products Listing type filter",
395
+ description: "Reachability is the invariant, not the route to it. Auctions, pre-orders, prize draws and art/stickers used to have no entry in the /products type filter and were unreachable from the main catalogue; they were added there, and have now been moved back out because each owns a nav-linked page of its own. Classifieds, digital codes and live items have NO nav entry, so /products is still their only discovery path — dropping them from it is the same bug in a new place.",
396
396
  href: "/products",
397
397
  },
398
398
  {
@@ -456,7 +456,19 @@ const rawTesterChecklistItems = [
456
456
  pageLabel: "Product / Auction / Pre-order Detail",
457
457
  href: "/products",
458
458
  cases: [
459
- { key: "standard-detail", label: "Standard product detail page loads correctly", href: "/products/product-tester-standard-1" },
459
+ { key: "standard-detail", label: "Standard product detail page loads correctly, and a SIGNED-OUT visitor sees \"Sign in to see price\" where the price was", href: "/products/product-tester-standard-1" },
460
+ {
461
+ key: "standard-detail-price-signed-in",
462
+ label: "SIGNED IN, the same product detail page shows the real price (₹199.00) and no sign-in prompt",
463
+ description: "The twin of the guest case above, and the half that is easy to forget: gating a price must not cost the coverage of the price itself. A gate that hides the amount from everyone passes the guest case perfectly and is a total regression.",
464
+ href: "/products/product-tester-standard-1",
465
+ },
466
+ {
467
+ key: "price-does-not-flash-sign-in-prompt-on-hard-reload",
468
+ label: "SIGNED IN, hard-reloading a product page never flashes \"Sign in to see price\" before the real price appears",
469
+ description: "The session provider is mounted with no user and resolves asynchronously, so `loading` starts true on every hard load — for signed-in visitors too. A gate written as \"no user means show the prompt\" therefore accuses an already-signed-in buyer of being signed out for several hundred milliseconds on every page load. The gate has THREE states for this reason: while the session is resolving it renders a neutral placeholder, never the prompt. This is the single most likely regression in the price-gating work and it is invisible to every static check.",
470
+ href: "/products/product-tester-standard-1",
471
+ },
460
472
  {
461
473
  key: "tester-fixtures-hidden-from-the-public",
462
474
  label: "SIGNED OUT, no product on /products has an id containing \"tester\" — the sandbox is invisible to the public",
@@ -477,8 +489,14 @@ const rawTesterChecklistItems = [
477
489
  },
478
490
  {
479
491
  key: "auction-detail",
480
- label: "Auction detail page shows current bid + a live, ticking countdown correctly",
481
- description: "The countdown (\"Ends in 2d 5h 30m\", ticking every second — not a static date) must appear directly above every \"Place a bid\" button: the info panel, the desktop and mobile compact bid-summary cards, inside the \"Place your bid\" modal above the submit button, and in the mobile sticky bottom bar (as a row above the current-bid/bid-count line). It should switch to \"Ended\" once the end time passes.",
492
+ label: "SIGNED OUT, the auction detail page hides every bid figure and still shows a live, ticking countdown",
493
+ description: "The countdown (\"Ends in 2d 5h 30m\", ticking every second — not a static date) must appear directly above every \"Place a bid\" button: the info panel, the desktop and mobile compact bid-summary cards, and the mobile sticky bottom bar. It should switch to \"Ended\" once the end time passes. A signed-out visitor additionally sees NO bid amounts: the current bid reads \"Sign in to see the current bid\" and the bid form is replaced by \"Bidding is for members\" — the form was never usable signed out, so showing it with live figures was both a disclosure and a dead end.",
494
+ href: "/auctions/auction-tester-sandbox-cycle-1",
495
+ },
496
+ {
497
+ key: "auction-detail-bid-signed-in",
498
+ label: "SIGNED IN, the same auction shows the real current bid (₹15,000.00), the minimum increment and a working bid form",
499
+ description: "The twin of the guest case above. A gate that hides the bid from signed-in buyers too would pass the guest case and break bidding entirely.",
482
500
  href: "/auctions/auction-tester-sandbox-cycle-1",
483
501
  },
484
502
  { key: "preorder-detail", label: "Pre-order detail page shows expected ship date correctly", href: "/pre-orders/preorder-tester-sandbox-1" },
@@ -4232,6 +4250,12 @@ const rawTesterChecklistItems = [
4232
4250
  label: "The apex host redirects to the www host, and does so permanently rather than temporarily",
4233
4251
  description: "BEFORE: the apex 307'd to www. A 307 is TEMPORARY and explicitly tells a search engine NOT to move the index entry, so every page kept two competing addresses. AFTER: the redirect is permanent and the destination is the host the sitemap advertises.",
4234
4252
  },
4253
+ {
4254
+ key: "gated-price-is-declared-in-structured-data",
4255
+ label: "A product and an auction page still publish offers.price to search engines, AND declare the sign-in gate with isAccessibleForFree:false plus a cssSelector that matches a real element",
4256
+ description: "Prices are hidden from signed-out visitors, and Googlebot crawls signed out — so the crawler renders 'Sign in to see price' while the JSON-LD beside it declares a real amount. Google requires marked-up content to be visible to the user, so that gap has to be DECLARED (paywalled-content markup) rather than left implicit, which risks the rich result. Serving prices to crawler user-agents but not to people would be cloaking and is deliberately not done. The selector half matters as much as the flag: a cssSelector matching nothing is indistinguishable from having made no declaration at all.",
4257
+ href: "/products/product-beyblade-burst-valkyrie",
4258
+ },
4235
4259
  {
4236
4260
  key: "locale-prefix-redirects-to-bare",
4237
4261
  label: "A locale-prefixed path redirects to the unprefixed one",
@@ -4362,6 +4386,12 @@ const rawTesterChecklistItems = [
4362
4386
  label: "A product's social preview image shows that product's own title and image",
4363
4387
  href: "/products/product-beyblade-burst-valkyrie",
4364
4388
  },
4389
+ {
4390
+ key: "og-image-carries-no-price",
4391
+ label: "No social preview image renders a ₹ amount — product, bundle, classified, digital code, live item, catalogue item, prize draw or item request",
4392
+ description: "An OG image is a public image URL with the amount burned into the pixels. No sign-in gate reaches it and no structured-data declaration describes it, so it is the one surface where hiding a price on the page buys nothing. Eight og.tsx renderers carried a ₹ figure before 2026-09-14; the auction and pre-order cards already used a non-money accent (end date / release date) and are the pattern the rest now follow.",
4393
+ href: "/products/product-beyblade-burst-valkyrie",
4394
+ },
4365
4395
  {
4366
4396
  key: "og-image-brand-logo-present",
4367
4397
  label: "A brand's social preview card shows the brand's cover image rather than an empty slot",
@@ -25,22 +25,88 @@ export const DEFAULT_TESTER_PHASE_SIZE = 25;
25
25
  * already be in catalog order (e.g. sorted by the `order` field).
26
26
  */
27
27
  export function assignDefaultPhases(itemsInCatalogOrder, targetPhaseSize = DEFAULT_TESTER_PHASE_SIZE) {
28
- const phaseNumbers = new Array(itemsInCatalogOrder.length);
28
+ /*
29
+ * 🛑 PAGES ARE INTERLEAVED ACROSS GROUPS, NOT PACKED IN CATALOGUE ORDER.
30
+ *
31
+ * This used to walk the catalogue straight through, so a group's phase number
32
+ * was an accident of where its `...group(...)` spread sat in a 6,200-line
33
+ * literal. `admin` is the 9th spread, so its 231 cases landed at phases
34
+ * **25–32** — and any run that stops early is therefore GUARANTEED to lose
35
+ * the largest, most privileged group in its entirety. The same held for every
36
+ * infrastructure group in the tail: page-wiring (32–33), cta-layout (33),
37
+ * addresses and search-and-nav (34).
38
+ *
39
+ * That is not hypothetical. Run run-1789300124915 stopped at phase 17 of 34
40
+ * and ten of fourteen groups had ZERO coverage — 527 cases, including all 233
41
+ * admin ones — while the run looked about half done.
42
+ *
43
+ * Round-robin over groups fixes the shape rather than the symptom: an early
44
+ * stop now costs a proportional slice of every group instead of the whole
45
+ * tail. Groups are visited largest-first within each round so the big ones do
46
+ * not bunch up at the end as the small ones run out.
47
+ *
48
+ * A phase may now span several groups. That is fine for a tester session —
49
+ * it is ~25 cases either way — and every read path already groups by the
50
+ * STORED `item.phase`, never by position.
51
+ *
52
+ * 🛑 The RETURN IS STILL POSITIONAL. Items keep their catalogue order and
53
+ * their `order` field; only the phase LABEL changes. Nothing downstream
54
+ * reorders, and `fetch-cases` still sorts by (phase, order) to build batches.
55
+ */
56
+ const pageOrder = [];
57
+ const pageSize = new Map();
58
+ const pageGroup = new Map();
59
+ for (const item of itemsInCatalogOrder) {
60
+ const pageId = `${item.groupKey}␟${item.pageKey}`;
61
+ if (!pageSize.has(pageId)) {
62
+ pageOrder.push(pageId);
63
+ pageSize.set(pageId, 0);
64
+ pageGroup.set(pageId, item.groupKey);
65
+ }
66
+ pageSize.set(pageId, pageSize.get(pageId) + 1);
67
+ }
68
+ // Queues of pages per group, each still in catalogue order within its group.
69
+ const byGroup = new Map();
70
+ for (const pageId of pageOrder) {
71
+ const g = pageGroup.get(pageId);
72
+ if (!byGroup.has(g))
73
+ byGroup.set(g, []);
74
+ byGroup.get(g).push(pageId);
75
+ }
76
+ const groupsLargestFirst = [...byGroup.entries()]
77
+ .map(([g, pages]) => ({
78
+ g,
79
+ pages,
80
+ cases: pages.reduce((n, p) => n + pageSize.get(p), 0),
81
+ }))
82
+ .sort((a, b) => b.cases - a.cases || a.g.localeCompare(b.g))
83
+ .map((e) => e.g);
84
+ const cursor = new Map(groupsLargestFirst.map((g) => [g, 0]));
85
+ const interleaved = [];
86
+ let remaining = pageOrder.length;
87
+ while (remaining > 0) {
88
+ for (const g of groupsLargestFirst) {
89
+ const i = cursor.get(g);
90
+ const pages = byGroup.get(g);
91
+ if (i >= pages.length)
92
+ continue;
93
+ interleaved.push(pages[i]);
94
+ cursor.set(g, i + 1);
95
+ remaining--;
96
+ }
97
+ }
98
+ // Pack the interleaved page sequence into phases, never splitting a page.
99
+ const phaseOfPage = new Map();
29
100
  let currentPhase = 1;
30
101
  let currentPhaseCount = 0;
31
- let lastPageKey = null;
32
- for (let i = 0; i < itemsInCatalogOrder.length; i++) {
33
- const item = itemsInCatalogOrder[i];
34
- const pageId = `${item.groupKey}␟${item.pageKey}`;
35
- const isNewPage = pageId !== lastPageKey;
36
- // Only allow a phase boundary between pages, never mid-page.
37
- if (isNewPage && currentPhaseCount >= targetPhaseSize) {
102
+ for (const pageId of interleaved) {
103
+ const size = pageSize.get(pageId);
104
+ if (currentPhaseCount > 0 && currentPhaseCount >= targetPhaseSize) {
38
105
  currentPhase += 1;
39
106
  currentPhaseCount = 0;
40
107
  }
41
- phaseNumbers[i] = currentPhase;
42
- currentPhaseCount += 1;
43
- lastPageKey = pageId;
108
+ phaseOfPage.set(pageId, currentPhase);
109
+ currentPhaseCount += size;
44
110
  }
45
- return phaseNumbers;
111
+ return itemsInCatalogOrder.map((item) => phaseOfPage.get(`${item.groupKey}␟${item.pageKey}`));
46
112
  }
@@ -1,12 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Button, Div, Stack, Text } from "../../../ui";
2
+ import { Button, Div, GatedPrice, Stack, Text } from "../../../ui";
3
3
  import { MediaImage } from "../../media/MediaImage";
4
4
  import { formatCurrency } from "../../../utils/number.formatter";
5
5
  const CLS_REMOVE_BTN = "self-start text-[var(--appkit-color-text-faint)] transition hover:text-error";
6
6
  export function WishlistCard({ item, onRemove, onProductClick, }) {
7
7
  return (_jsxs(Div, { layout: "flex", gap: "4", surface: "card", padding: "sm", children: [_jsx(Div, { role: onProductClick ? "button" : undefined, tabIndex: onProductClick ? 0 : undefined, onClick: onProductClick ? () => onProductClick(item) : undefined, onKeyDown: onProductClick
8
8
  ? (e) => e.key === "Enter" && onProductClick(item)
9
- : undefined, className: `relative h-20 w-20 flex-shrink-0 overflow-hidden bg-[var(--appkit-color-surface)] bg-[var(--appkit-color-surface-elevated)] ${onProductClick ? "cursor-pointer" : ""}`, rounded: "lg", children: item.productImage && (_jsx(MediaImage, { src: item.productImage, alt: item.productTitle ?? "", size: "thumbnail" })) }), _jsxs(Stack, { justify: "between", className: "flex-1", children: [_jsx(Text, { truncate: 2, weight: "medium", color: "primary", children: item.productTitle }), item.productPrice !== undefined && (_jsx(Text, { className: "text-[var(--appkit-color-text)]", size: "sm", weight: "semibold", children: formatCurrency(item.productPrice, item.productCurrency) }))] }), onRemove && (_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => onRemove(item.id), "aria-label": "Remove from wishlist", className: CLS_REMOVE_BTN, children: "\u2715" }))] }));
9
+ : undefined, className: `relative h-20 w-20 flex-shrink-0 overflow-hidden bg-[var(--appkit-color-surface)] bg-[var(--appkit-color-surface-elevated)] ${onProductClick ? "cursor-pointer" : ""}`, rounded: "lg", children: item.productImage && (_jsx(MediaImage, { src: item.productImage, alt: item.productTitle ?? "", size: "thumbnail" })) }), _jsxs(Stack, { justify: "between", className: "flex-1", children: [_jsx(Text, { truncate: 2, weight: "medium", color: "primary", children: item.productTitle }), item.productPrice !== undefined && (_jsx(Text, { className: "text-[var(--appkit-color-text)]", size: "sm", weight: "semibold", children: _jsx(GatedPrice, { children: formatCurrency(item.productPrice, item.productCurrency) }) }))] }), onRemove && (_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => onRemove(item.id), "aria-label": "Remove from wishlist", className: CLS_REMOVE_BTN, children: "\u2715" }))] }));
10
10
  }
11
11
  export function WishlistPage({ items, isLoading, onRemove, onProductClick, emptyLabel = "Your wishlist is empty", }) {
12
12
  if (isLoading) {
package/dist/index.d.ts CHANGED
@@ -341,6 +341,7 @@ export { blogPostJsonLd } from "./seo/index";
341
341
  export { breadcrumbJsonLd } from "./seo/index";
342
342
  export { createSeoConfig } from "./seo/index";
343
343
  export { faqJsonLd } from "./seo/index";
344
+ export { gatedPriceWebPageJsonLd } from "./seo/index";
344
345
  export { generateAuctionMetadata } from "./seo/index";
345
346
  export { generateBlogMetadata } from "./seo/index";
346
347
  export { generateCategoryMetadata } from "./seo/index";
@@ -3001,7 +3002,7 @@ export { Heading, Spinner, IconButton, Progress, IndeterminateProgress, Paginati
3001
3002
  export { ModalFooter, UnsavedChangesModal, ResponsiveView, Textarea, Checkbox } from "./ui/index";
3002
3003
  export { ActionRow } from "./ui/index";
3003
3004
  export type { ActionRowProps, ActionRowAlign } from "./ui/index";
3004
- export { ToastProvider, StarRating, StepperNav, ViewToggle, PriceDisplay, SummaryCard } from "./ui/index";
3005
+ export { ToastProvider, StarRating, StepperNav, ViewToggle, PriceDisplay, GatedPrice, PricesOnly, SummaryCard } from "./ui/index";
3005
3006
  export { CountdownDisplay, ItemRow, HorizontalScroller } from "./ui/index";
3006
3007
  export { CardBody, MenuTrigger, MenuContent, MenuSeparator, RoleBadge, SkipToMain } from "./ui/index";
3007
3008
  export { Tabs, TabsList, TabsTrigger, TabsContent, PasswordStrengthIndicator, TablePagination } from "./ui/index";
package/dist/index.js CHANGED
@@ -598,6 +598,9 @@ export { createSeoConfig } from "./seo/index";
598
598
  // faqJsonLd - Shared export for faq json ld.
599
599
  export { faqJsonLd } from "./seo/index";
600
600
  // [CLIENT-SSR]-Runs in both SSR and browser â€" React component or hook that does not depend on browser-only APIs.
601
+ // gatedPriceWebPageJsonLd - Shared export for gated price web page json ld.
602
+ export { gatedPriceWebPageJsonLd } from "./seo/index";
603
+ // [CLIENT-SSR]-Runs in both SSR and browser â€" React component or hook that does not depend on browser-only APIs.
601
604
  // generateAuctionMetadata - Helper for generate auction metadata.
602
605
  export { generateAuctionMetadata } from "./seo/index";
603
606
  // [CLIENT-SSR]-Runs in both SSR and browser â€" React component or hook that does not depend on browser-only APIs.
@@ -5364,7 +5367,7 @@ export { Button } from "./ui/index";
5364
5367
  export { Heading, Spinner, IconButton, Progress, IndeterminateProgress, Pagination, PageLoader } from "./ui/index";
5365
5368
  export { ModalFooter, UnsavedChangesModal, ResponsiveView, Textarea, Checkbox } from "./ui/index";
5366
5369
  export { ActionRow } from "./ui/index";
5367
- export { ToastProvider, StarRating, StepperNav, ViewToggle, PriceDisplay, SummaryCard } from "./ui/index";
5370
+ export { ToastProvider, StarRating, StepperNav, ViewToggle, PriceDisplay, GatedPrice, PricesOnly, SummaryCard } from "./ui/index";
5368
5371
  export { CountdownDisplay, ItemRow, HorizontalScroller } from "./ui/index";
5369
5372
  export { CardBody, MenuTrigger, MenuContent, MenuSeparator, RoleBadge, SkipToMain } from "./ui/index";
5370
5373
  export { Tabs, TabsList, TabsTrigger, TabsContent, PasswordStrengthIndicator, TablePagination } from "./ui/index";
@@ -19,4 +19,11 @@ export declare const RTDB_PATHS: {
19
19
  readonly AUTH_EVENTS: "auth_events";
20
20
  readonly PAYMENT_EVENTS: "payment_events";
21
21
  readonly BULK_EVENTS: "bulk_events";
22
+ /**
23
+ * Tester-only. A one-line ping per email decision so the harness can poll
24
+ * instead of reading a real mailbox — see `send-recorder.ts` for why, and for
25
+ * the gate that keeps real users' mail out of it. Pruned by
26
+ * `cleanupRtdbEvents` alongside the other three.
27
+ */
28
+ readonly EMAIL_EVENTS: "email_events";
22
29
  };
@@ -19,4 +19,11 @@ export const RTDB_PATHS = {
19
19
  AUTH_EVENTS: "auth_events",
20
20
  PAYMENT_EVENTS: "payment_events",
21
21
  BULK_EVENTS: "bulk_events",
22
+ /**
23
+ * Tester-only. A one-line ping per email decision so the harness can poll
24
+ * instead of reading a real mailbox — see `send-recorder.ts` for why, and for
25
+ * the gate that keeps real users' mail out of it. Pruned by
26
+ * `cleanupRtdbEvents` alongside the other three.
27
+ */
28
+ EMAIL_EVENTS: "email_events",
22
29
  };
@@ -19,7 +19,22 @@ const DEFAULT_ENDPOINTS = {
19
19
  // ---------------------------------------------------------------------------
20
20
  const SESSION_COOKIE = "__session";
21
21
  const SESSION_ID_COOKIE = "__session_id";
22
- const ACTIVITY_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
22
+ /**
23
+ * How often a VISIBLE tab pings session activity and re-reads RBAC flags.
24
+ *
25
+ * Each tick costs two function invocations (`sessionActivity`, then the
26
+ * piggybacked profile re-fetch below), per open tab, wall-clock rather than
27
+ * activity-gated. At the original 5 minutes that was 24 invocations/hour for
28
+ * every tab any signed-in user had left open, and it contributed to the Vercel
29
+ * overage that paused the account (2026-09-14).
30
+ *
31
+ * 15 minutes is safe because it is a BACKSTOP, not the primary freshness
32
+ * mechanism: `RoleGuard` calls `refreshUser()` on every mount of a role-gated
33
+ * layout, so navigating into /admin, /store or /user re-reads the flags
34
+ * immediately, and the visibility handler below re-reads them the moment a
35
+ * backgrounded tab is looked at again.
36
+ */
37
+ const ACTIVITY_INTERVAL_MS = 15 * 60 * 1000; // 15 minutes
23
38
  // ---------------------------------------------------------------------------
24
39
  // Context
25
40
  // ---------------------------------------------------------------------------
@@ -166,6 +181,14 @@ export function SessionProvider({ children, initialUser, endpoints: endpointOver
166
181
  const currentSessionId = getSessionIdFromCookie();
167
182
  if (!currentSessionId || !user)
168
183
  return;
184
+ // A hidden tab is not "activity", so reporting it as such is both wasteful
185
+ // and wrong — an abandoned background tab used to keep both requests below
186
+ // firing indefinitely. The visibility handler re-runs this immediately when
187
+ // the tab is looked at again, so nothing is merely deferred into staleness.
188
+ if (typeof document !== "undefined" &&
189
+ document.visibilityState === "hidden") {
190
+ return;
191
+ }
169
192
  try {
170
193
  await apiClient.post(ep.sessionActivity, {
171
194
  sessionId: currentSessionId,
@@ -203,6 +226,22 @@ export function SessionProvider({ children, initialUser, endpoints: endpointOver
203
226
  useEffect(() => {
204
227
  updateSessionActivityRef.current = updateSessionActivity;
205
228
  }, [updateSessionActivity]);
229
+ // Re-read session activity and RBAC flags the moment a backgrounded tab is
230
+ // looked at again. This is what lets the interval above be 15 minutes and the
231
+ // hidden-tab skip be safe: the user-visible freshness guarantee is now "fresh
232
+ // whenever you are actually looking at it", which is strictly better than the
233
+ // old fixed 5-minute tick that fired whether anyone was there or not.
234
+ useEffect(() => {
235
+ if (typeof document === "undefined")
236
+ return;
237
+ const onVisible = () => {
238
+ if (document.visibilityState === "visible") {
239
+ void updateSessionActivityRef.current?.();
240
+ }
241
+ };
242
+ document.addEventListener("visibilitychange", onVisible);
243
+ return () => document.removeEventListener("visibilitychange", onVisible);
244
+ }, []);
206
245
  // -----------------------------------------------------------------------
207
246
  // Refresh helpers
208
247
  // -----------------------------------------------------------------------
@@ -0,0 +1,37 @@
1
+ export interface CanSeePricesState {
2
+ /** The viewer is signed in and may be shown money amounts. */
3
+ canSeePrices: boolean;
4
+ /**
5
+ * The session has not resolved yet. Render a neutral placeholder while this
6
+ * is true — NEVER the sign-in prompt. See the note below.
7
+ */
8
+ isAuthResolving: boolean;
9
+ }
10
+ /**
11
+ * May the current viewer be shown money amounts on a public listing surface?
12
+ *
13
+ * Prices, current bids, deposits and per-entry amounts are hidden from
14
+ * signed-out visitors so the catalogue cannot be read at scale without an
15
+ * account. Signed-in users see everything exactly as before.
16
+ *
17
+ * ## Why there are THREE states, not two
18
+ *
19
+ * `SessionProvider` is mounted `initialUser={null}` because the root locale
20
+ * layout must stay static (Root Cause #82), so `loading` starts `true` on every
21
+ * hard load — for signed-in users too, until Firebase persistence resolves and
22
+ * `/api/user/profile` returns. A naive `!user` test therefore renders
23
+ * "Sign in to see price" at an already-signed-in user for several hundred
24
+ * milliseconds on every page load.
25
+ *
26
+ * So: `isAuthResolving` means "don't know yet" and must render a neutral
27
+ * placeholder. Only `canSeePrices === false && isAuthResolving === false` is a
28
+ * confirmed guest.
29
+ *
30
+ * ## Why `useOptionalSession`
31
+ *
32
+ * This is library code that ADJUSTS for the viewer rather than requiring one.
33
+ * `useSession()` throws without a provider, which would turn an optional
34
+ * refinement into a breaking contract for every consumer (Root Cause #20). With
35
+ * no provider we fail CLOSED — no session means no prices.
36
+ */
37
+ export declare function useCanSeePrices(): CanSeePricesState;
@@ -0,0 +1,40 @@
1
+ "use client";
2
+ import { useOptionalSession } from "../contexts/SessionContext";
3
+ /**
4
+ * May the current viewer be shown money amounts on a public listing surface?
5
+ *
6
+ * Prices, current bids, deposits and per-entry amounts are hidden from
7
+ * signed-out visitors so the catalogue cannot be read at scale without an
8
+ * account. Signed-in users see everything exactly as before.
9
+ *
10
+ * ## Why there are THREE states, not two
11
+ *
12
+ * `SessionProvider` is mounted `initialUser={null}` because the root locale
13
+ * layout must stay static (Root Cause #82), so `loading` starts `true` on every
14
+ * hard load — for signed-in users too, until Firebase persistence resolves and
15
+ * `/api/user/profile` returns. A naive `!user` test therefore renders
16
+ * "Sign in to see price" at an already-signed-in user for several hundred
17
+ * milliseconds on every page load.
18
+ *
19
+ * So: `isAuthResolving` means "don't know yet" and must render a neutral
20
+ * placeholder. Only `canSeePrices === false && isAuthResolving === false` is a
21
+ * confirmed guest.
22
+ *
23
+ * ## Why `useOptionalSession`
24
+ *
25
+ * This is library code that ADJUSTS for the viewer rather than requiring one.
26
+ * `useSession()` throws without a provider, which would turn an optional
27
+ * refinement into a breaking contract for every consumer (Root Cause #20). With
28
+ * no provider we fail CLOSED — no session means no prices.
29
+ */
30
+ export function useCanSeePrices() {
31
+ const session = useOptionalSession();
32
+ // No provider: fail closed, and don't claim to be mid-resolution — there is
33
+ // nothing in flight that will ever change the answer.
34
+ if (!session)
35
+ return { canSeePrices: false, isAuthResolving: false };
36
+ return {
37
+ canSeePrices: Boolean(session.user?.uid),
38
+ isAuthResolving: session.loading,
39
+ };
40
+ }
@@ -3,12 +3,12 @@
3
3
  * addresses live entirely in store-addresses-seed-data.ts (merged in at the API route
4
4
  * level) — this file only carries ownerType:"user" entries now, to avoid seeding two
5
5
  * overlapping sources of store address data.
6
- * WHAT: Exports 3 user addresses. Top-level collection (SB-UNI-A) with ownerType discriminator.
6
+ * WHAT: Exports 4 user addresses. Top-level collection (SB-UNI-A) with ownerType discriminator.
7
7
  * PII encrypted via HMAC blind indices (emailIndex, phoneIndex). Composite indexes
8
8
  * (ownerType, ownerId, createdAt desc) + (ownerType, ownerId, isDefault).
9
9
  *
10
10
  * EXPORTS:
11
- * addressesSeedData — Array of 3 user address documents
11
+ * addressesSeedData — Array of 4 user address documents
12
12
  *
13
13
  * @tag domain:addresses,shipping
14
14
  * @tag layer:seed
@@ -74,5 +74,39 @@ const _rawAddressesSeedData = [
74
74
  createdAt: daysAgo(365),
75
75
  updatedAt: daysAgo(5),
76
76
  },
77
+ /*
78
+ * Automated checklist runner — delivery address.
79
+ *
80
+ * 🛑 THIS ROW IS WHY CHECKOUT CAN BE TESTED AT ALL.
81
+ *
82
+ * `user-claude-tester` had no address, so /checkout stopped dead at "Step 1 of 3:
83
+ * Shipping Address — No saved addresses yet." Every order, payment, coupon-at-placement,
84
+ * offer-checkout and forfeited-bid case died there: 117 blocked cases, 27% of everything
85
+ * the run could not test, and the single largest blocker by a wide margin (measured,
86
+ * run 1789300124915).
87
+ *
88
+ * It has to be SEEDED rather than created by the runner mid-run. `addresses` is PRESERVE
89
+ * tier in tester/scripts/lib/collections.mjs — teardown deliberately never touches it,
90
+ * because real people's saved addresses live there — so a row the runner created at
91
+ * runtime would survive every future run with nothing owning its cleanup. Seeded, it is
92
+ * idempotent: `appkit-seed load` upserts this exact id, and re-running changes nothing.
93
+ */
94
+ {
95
+ id: "addr-claude-tester-home",
96
+ ownerType: "user",
97
+ ownerId: "user-claude-tester",
98
+ label: "Home",
99
+ fullName: "Claude (automated)",
100
+ phone: "+91-99999-00019",
101
+ addressLine1: "1 Automation Street",
102
+ addressLine2: "Koramangala",
103
+ city: "Bengaluru",
104
+ state: "Karnataka",
105
+ postalCode: "560034",
106
+ country: "India",
107
+ isDefault: true,
108
+ createdAt: daysAgo(1),
109
+ updatedAt: daysAgo(1),
110
+ },
77
111
  ];
78
112
  export const addressesSeedData = _rawAddressesSeedData;
@@ -546,8 +546,16 @@ export const usersSeedData = [
546
546
  isBot: true,
547
547
  emailVerified: true,
548
548
  disabled: false,
549
- storeId: "store-tester-qa-seller",
550
- storeSlug: "store-tester-qa-seller",
549
+ // 🛑 This MUST name a store whose own `ownerId` is this uid. It used to say
550
+ // "store-tester-qa-seller", which is owned by `user-tester-qa` — so the app, which
551
+ // resolves sellers by `ownerId`, answered every /api/store/* call with 403
552
+ // "No store found for this account" while the client, reading `role` and `storeId`
553
+ // off this document, rendered the entire Store Panel over it. 36 checklist cases were
554
+ // blocked by that one-way pointer (measured, run 1789300124915).
555
+ // See stores-tester-seed-data.ts for why the shared sandbox could not simply be
556
+ // repointed at this account instead.
557
+ storeId: "store-claude-tester",
558
+ storeSlug: "store-claude-tester",
551
559
  storeStatus: "approved",
552
560
  publicProfile: {
553
561
  isPublic: false,
@@ -84,6 +84,27 @@ export interface BlogPostJsonLdInput {
84
84
  metaTitle?: string;
85
85
  metaDescription?: string;
86
86
  }
87
+ /**
88
+ * Declares that this page's price is behind a sign-in gate.
89
+ *
90
+ * Prices are hidden from signed-out visitors, and Googlebot crawls signed out —
91
+ * so the crawler sees "Sign in to see price" while the Product JSON-LD beside it
92
+ * declares a real `offers.price`. Left undeclared, that is a structured-data
93
+ * mismatch: Google requires marked-up content to be visible to the user, and an
94
+ * unexplained gap can cost the rich result.
95
+ *
96
+ * `isAccessibleForFree: false` + `hasPart`/`cssSelector` is the supported way to
97
+ * say "the markup is richer than what an anonymous visitor sees". It lives on a
98
+ * `WebPage` node rather than on the Product, because `isAccessibleForFree` is a
99
+ * `CreativeWork` property and `Product` is not a `CreativeWork`.
100
+ *
101
+ * Serving the price to crawler user-agents but not to people would be cloaking,
102
+ * which is a harder violation; this declares the gate instead of hiding it.
103
+ *
104
+ * 🛑 Emit this on EVERY page that emits `offers.price`. The audit enforces the
105
+ * pairing precisely so nobody removes one half later.
106
+ */
107
+ export declare function gatedPriceWebPageJsonLd(path: string): Record<string, JsonLdValue>;
87
108
  export declare function productJsonLd(product: ProductJsonLdInput): Record<string, JsonLdValue>;
88
109
  export declare function reviewJsonLd(review: ReviewJsonLdInput): Record<string, JsonLdValue>;
89
110
  export declare function aggregateRatingJsonLd(product: Pick<ProductJsonLdInput, "title" | "slug">, stats: {
@@ -39,6 +39,51 @@ function absoluteUrl(value, base = SITE_URL) {
39
39
  import { ProductStatusValues } from "../features/products/schemas";
40
40
  import { getDefaultCurrency } from "../core/baseline-resolver";
41
41
  // --- Helpers -----------------------------------------------------------------
42
+ /**
43
+ * The CSS selector the price gate renders in every state.
44
+ *
45
+ * 🛑 Must equal `GATED_PRICE_CLASS` in `ui/components/GatedPrice.tsx`. It is
46
+ * duplicated as a literal here rather than imported because `seo/` is reached
47
+ * from server-only metadata paths and must not pull in a `"use client"` module
48
+ * (Root Cause #76). `audit-guest-price-leak` cross-checks the two.
49
+ */
50
+ const GATED_PRICE_SELECTOR = ".appkit-gated-price";
51
+ /**
52
+ * Declares that this page's price is behind a sign-in gate.
53
+ *
54
+ * Prices are hidden from signed-out visitors, and Googlebot crawls signed out —
55
+ * so the crawler sees "Sign in to see price" while the Product JSON-LD beside it
56
+ * declares a real `offers.price`. Left undeclared, that is a structured-data
57
+ * mismatch: Google requires marked-up content to be visible to the user, and an
58
+ * unexplained gap can cost the rich result.
59
+ *
60
+ * `isAccessibleForFree: false` + `hasPart`/`cssSelector` is the supported way to
61
+ * say "the markup is richer than what an anonymous visitor sees". It lives on a
62
+ * `WebPage` node rather than on the Product, because `isAccessibleForFree` is a
63
+ * `CreativeWork` property and `Product` is not a `CreativeWork`.
64
+ *
65
+ * Serving the price to crawler user-agents but not to people would be cloaking,
66
+ * which is a harder violation; this declares the gate instead of hiding it.
67
+ *
68
+ * 🛑 Emit this on EVERY page that emits `offers.price`. The audit enforces the
69
+ * pairing precisely so nobody removes one half later.
70
+ */
71
+ export function gatedPriceWebPageJsonLd(path) {
72
+ return {
73
+ "@context": "https://schema.org",
74
+ "@type": "WebPage",
75
+ // Absolutised against the ONE canonical host, never a second definition of
76
+ // it (Root Cause #81) — a WebPage node naming a host the sitemap does not
77
+ // advertise is the same split-identity bug in miniature.
78
+ url: absoluteUrl(path),
79
+ isAccessibleForFree: false,
80
+ hasPart: {
81
+ "@type": "WebPageElement",
82
+ isAccessibleForFree: false,
83
+ cssSelector: GATED_PRICE_SELECTOR,
84
+ },
85
+ };
86
+ }
42
87
  export function productJsonLd(product) {
43
88
  const url = `${SITE_URL}/products/${product.slug}`;
44
89
  // Absolutise, then de-duplicate: `mainImage` is very often also `images[0]`,
package/dist/server.d.ts CHANGED
@@ -556,6 +556,9 @@ export { getDigitalCodeForDetail } from "./_internal/server/features/digital-cod
556
556
  export { toClientDigitalCode, type DigitalCodeClientShape } from "./_internal/server/features/digital-code/adapters";
557
557
  export { buildDigitalCodeMetadata, type DigitalCodeMetadataOptions } from "./_internal/server/features/digital-code/metadata";
558
558
  export { renderDigitalCodeOg, renderDigitalCodeOgImage, renderDigitalCodeOgFromDoc, type DigitalCodeOgData } from "./_internal/server/features/digital-code/og";
559
+ export { addPoolEntries, listPoolEntries, deletePoolEntry, recountPool, assertCanUploadContentKind, DIGITAL_CONTENT_PREFIX, type AddPoolEntryInput, } from "./_internal/server/features/digital-code/pool";
560
+ export { signDigitalContentUpload, finalizeDigitalContentUpload, contentKindForMime, type SignedDigitalContent, type FinalizedDigitalContent, } from "./_internal/server/features/digital-code/upload";
561
+ export { resolveCodeAssetForOrder, codeAssetHeaders, CODE_DOWNLOAD_STATUSES, type CodeAssetResolution, } from "./_internal/server/features/digital-code/download";
559
562
  export { getLiveItemForDetail } from "./_internal/server/features/live/data";
560
563
  export { toClientLiveItem, type LiveItemClientShape } from "./_internal/server/features/live/adapters";
561
564
  export { buildLiveItemMetadata, type LiveItemMetadataOptions } from "./_internal/server/features/live/metadata";
package/dist/server.js CHANGED
@@ -1444,6 +1444,18 @@ export { getDigitalCodeForDetail } from "./_internal/server/features/digital-cod
1444
1444
  export { toClientDigitalCode } from "./_internal/server/features/digital-code/adapters";
1445
1445
  export { buildDigitalCodeMetadata } from "./_internal/server/features/digital-code/metadata";
1446
1446
  export { renderDigitalCodeOg, renderDigitalCodeOgImage, renderDigitalCodeOgFromDoc } from "./_internal/server/features/digital-code/og";
1447
+ /*
1448
+ * The digital-CONTENT pool: the half of this feature that had no writer at all
1449
+ * until 2026-09-14, so every digital-code purchase delivered nothing.
1450
+ *
1451
+ * Server-only, and that is structural rather than tidy — `pool` and `download`
1452
+ * both reach `getAdminDb`/`getAdminStorage`, and `upload` mints signed URLs.
1453
+ * None of it may appear in `index.ts` or `client.ts` (the Turbopack
1454
+ * client-bundle trap, Root Cause #6/#24).
1455
+ */
1456
+ export { addPoolEntries, listPoolEntries, deletePoolEntry, recountPool, assertCanUploadContentKind, DIGITAL_CONTENT_PREFIX, } from "./_internal/server/features/digital-code/pool";
1457
+ export { signDigitalContentUpload, finalizeDigitalContentUpload, contentKindForMime, } from "./_internal/server/features/digital-code/upload";
1458
+ export { resolveCodeAssetForOrder, codeAssetHeaders, CODE_DOWNLOAD_STATUSES, } from "./_internal/server/features/digital-code/download";
1447
1459
  // ── Live-item listing — data + adapters + metadata + OG ──────────────────────
1448
1460
  export { getLiveItemForDetail } from "./_internal/server/features/live/data";
1449
1461
  export { toClientLiveItem } from "./_internal/server/features/live/adapters";