@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
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Record what the mailer decided, so the tester can assert on it without an inbox.
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * 21 checklist cases are about email. Reading a real mailbox over IMAP can answer
7
+ * some of them, but it is the wrong instrument for most:
8
+ *
9
+ * - **Six of them assert an email should NOT arrive** (kill switch spares staff,
10
+ * opt-out honoured, no employee blast, losing bidders get no mail). Over IMAP,
11
+ * "nothing arrived" is unprovable — you wait N seconds and hope, and a slow
12
+ * inbox is indistinguishable from a working suppression. Here it is one read.
13
+ * - Delivery latency makes every other case slow and flaky for no added truth:
14
+ * whether Resend accepted the handoff says nothing about whether the app made
15
+ * the right decision, which is what the cases are actually about.
16
+ *
17
+ * So this records the DECISION and its inputs. A real inbox is still the only
18
+ * proof of real delivery, and the two or three cases that assert that keep using
19
+ * one (`tester/scripts/lib/inbox.mjs`).
20
+ *
21
+ * ## Why here and not in `guardSend`
22
+ *
23
+ * `SendGuardContext` carries channel/feature/audience and deliberately no
24
+ * recipient — the guard's own docstring explains it must not become the transport.
25
+ * `sendEmail()` is the one place that holds the recipient, the rendered body AND
26
+ * the guard's verdict, which is exactly the tuple a case needs.
27
+ *
28
+ * ## 🛑 Gated, because recording real people's mail is a privacy problem
29
+ *
30
+ * Recording recipients and bodies for actual users would be a new PII store with
31
+ * no retention story. So this writes ONLY when the recipient is the harness
32
+ * mailbox (`TESTER_EMAIL_ID`), or when `EMAIL_RECORDER_ALL` is explicitly set for
33
+ * local debugging. Absent that env var, this function is a no-op and costs one
34
+ * string comparison.
35
+ *
36
+ * Best-effort throughout: a recorder that can fail a send is worse than no
37
+ * recorder, so every path swallows. Same discipline as `recordAdminAction`.
38
+ *
39
+ * @tag domain:notifications,tester
40
+ * @tag layer:server
41
+ * @tag access:server-only
42
+ */
43
+ import { normalizeError } from "../../../errors/normalize";
44
+ import { serverLogger } from "../../../monitoring";
45
+ import { RTDB_PATHS } from "../../../providers/db-firebase/rtdb-paths";
46
+ /** Firestore collection holding the canonical record. */
47
+ export const EMAIL_EVENTS_COLLECTION = "emailEvents";
48
+ /** Normalise `to` for comparison and storage. */
49
+ function recipients(to) {
50
+ return (Array.isArray(to) ? to : [to]).map((t) => String(t).trim().toLowerCase()).filter(Boolean);
51
+ }
52
+ /**
53
+ * Should this send be recorded at all?
54
+ *
55
+ * Exported so the audit and tests can exercise the gate directly rather than
56
+ * inferring it from behaviour.
57
+ */
58
+ export function shouldRecordSend(to) {
59
+ if (process.env.EMAIL_RECORDER_ALL === "true")
60
+ return true;
61
+ const mailbox = process.env.TESTER_EMAIL_ID?.trim().toLowerCase();
62
+ if (!mailbox)
63
+ return false;
64
+ return recipients(to).includes(mailbox);
65
+ }
66
+ /**
67
+ * Write the record. Never throws, never blocks the send.
68
+ *
69
+ * Firestore is canonical and RTDB is only a ping — the same split the messaging
70
+ * feature already uses. The body does NOT go into RTDB: `bulk_events` is
71
+ * uncapped and a newsletter CSV already goes there, which is the mistake not to
72
+ * repeat on a channel that fires on every email.
73
+ */
74
+ export async function recordSendAttempt(record) {
75
+ if (!shouldRecordSend(record.to))
76
+ return;
77
+ try {
78
+ const { getAdminDb } = await import("../../../providers/db-firebase/admin");
79
+ const db = getAdminDb();
80
+ const now = new Date();
81
+ const doc = {
82
+ to: recipients(record.to),
83
+ subject: record.subject,
84
+ feature: record.feature,
85
+ audience: record.audience,
86
+ channel: record.channel,
87
+ outcome: record.outcome,
88
+ reason: record.reason ?? null,
89
+ html: record.html ?? null,
90
+ createdAt: now,
91
+ };
92
+ const ref = await db.collection(EMAIL_EVENTS_COLLECTION).add(doc);
93
+ /*
94
+ * The ping. Tiny by design: an id, the outcome and the feature are enough for
95
+ * a poller to know something happened and go read the Firestore row.
96
+ * Best-effort separately from the write above — losing the ping costs a
97
+ * poller one extra second, losing the record costs the case its evidence.
98
+ */
99
+ try {
100
+ const { getAdminRealtimeDb } = await import("../../../providers/db-firebase/admin");
101
+ await getAdminRealtimeDb()
102
+ .ref(`${RTDB_PATHS.EMAIL_EVENTS}/${ref.id}`)
103
+ .set({
104
+ id: ref.id,
105
+ feature: record.feature,
106
+ outcome: record.outcome,
107
+ at: now.toISOString(),
108
+ });
109
+ }
110
+ catch (err) {
111
+ // Deliberately unreported: the Firestore row above is the record, and it
112
+ // already succeeded by the time this runs. Losing the ping costs a poller
113
+ // one extra second of latency and loses no evidence, so logging it would
114
+ // add noise to every transient RTDB blip for no recoverable information.
115
+ void normalizeError(err);
116
+ }
117
+ }
118
+ catch (err) {
119
+ const n = normalizeError(err);
120
+ serverLogger.warn("recordSendAttempt: could not record email event", {
121
+ feature: record.feature,
122
+ outcome: record.outcome,
123
+ message: n.message,
124
+ });
125
+ }
126
+ }
@@ -74,12 +74,39 @@ export interface ListingTypePlugin {
74
74
  /** Short label for the dense type-chip row on /products and /admin/products. */
75
75
  chipLabel: string;
76
76
  /**
77
- * Dedicated public browse page for this type, or `null` when it browses on
78
- * `/products` (art and stickers — see CLAUDE.md's Listing Types Reference).
79
- * A non-null value also drives the "full <type> filters →" link shown on
80
- * /products when exactly this type is selected.
77
+ * Dedicated public browse page for this type.
78
+ *
79
+ * EVERY type has one today, including `standard` (→ `/products`) and
80
+ * `art`/`stickers` (→ the combined `/art`). This field is therefore NOT a
81
+ * "does it have its own page" discriminator — use `inGeneralCatalogue` for
82
+ * that. (This comment used to claim `null` meant "browses on /products",
83
+ * which no config has ever matched.)
84
+ *
85
+ * Also drives the "full <type> filters →" link shown on /products when
86
+ * exactly this type is selected — suppressed when it points at /products
87
+ * itself, which would be a self-link.
81
88
  */
82
89
  browseRoute: string | null;
90
+ /**
91
+ * Does this type belong in the GENERAL catalogue at `/products`?
92
+ *
93
+ * `/products` is the general catalogue, not an index of everything. A type
94
+ * whose dedicated page is reachable from `MAIN_NAV_ITEMS` lives only on that
95
+ * page, so an item is never reachable from two places with two different
96
+ * chromes. A type with a dedicated page but NO nav entry
97
+ * (classified / digital-code / live) stays here, because `/products` is its
98
+ * only discovery path — dropping those is how Root Cause #61 happened.
99
+ *
100
+ * Required, not optional, on purpose: `LISTING_TYPE_REGISTRY` is a
101
+ * `Record<ListingType, ListingTypePlugin>`, so a tenth listing type cannot
102
+ * compile without answering this. A hand-written list of "types allowed on
103
+ * /products" is exactly the drift Root Cause #61 documents.
104
+ *
105
+ * 🛑 If you set this `false` for a type, confirm its dedicated page is
106
+ * actually linked from the main nav first — otherwise the type becomes
107
+ * reachable only by typing the URL.
108
+ */
109
+ inGeneralCatalogue: boolean;
83
110
  /**
84
111
  * Which "Show X" default this type's browse surfaces apply. Since 2026-08-24
85
112
  * this drives the LABEL of the availability tab bar's middle tab
@@ -17,6 +17,7 @@ export declare const config: {
17
17
  pluralLabel: string;
18
18
  chipLabel: string;
19
19
  browseRoute: string;
20
+ inGeneralCatalogue: boolean;
20
21
  hideDefault: "sold";
21
22
  isAvailable: typeof alwaysAvailable;
22
23
  unavailableClauses: readonly import("../availability").UnavailableClause[];
@@ -20,6 +20,8 @@ export const config = {
20
20
  // detail pages use the standard product route — so this is a real
21
21
  // dedicated browse surface even though detailRoute points at /products.
22
22
  browseRoute: String(ROUTES.PUBLIC.ART),
23
+ // /art ("Art & Stickers") is in MAIN_NAV_ITEMS and spans art + stickers.
24
+ inGeneralCatalogue: false,
23
25
  hideDefault: "sold",
24
26
  // Runs out rather than ending — no time dimension, so the shared
25
27
  // stock-only rules are the whole story for this type.
@@ -17,6 +17,7 @@ export declare const config: {
17
17
  pluralLabel: string;
18
18
  chipLabel: string;
19
19
  browseRoute: string;
20
+ inGeneralCatalogue: boolean;
20
21
  hideDefault: "ended";
21
22
  isAvailable: (row: AvailabilityRow, now: Date) => boolean;
22
23
  unavailableClauses: ({
@@ -19,6 +19,9 @@ export const config = {
19
19
  pluralLabel: "Auctions",
20
20
  chipLabel: "Auctions",
21
21
  browseRoute: String(ROUTES.PUBLIC.AUCTIONS),
22
+ // /auctions is in MAIN_NAV_ITEMS. An auction card in the general grid renders
23
+ // Buy-Now chrome rather than bid affordances, so it belongs on its own page.
24
+ inGeneralCatalogue: false,
22
25
  // An auction is never "sold out" — it ends. Hiding by stock would hide
23
26
  // every live auction, since auctions don't carry meaningful stock.
24
27
  hideDefault: "ended",
@@ -17,6 +17,7 @@ export declare const config: {
17
17
  pluralLabel: string;
18
18
  chipLabel: string;
19
19
  browseRoute: string;
20
+ inGeneralCatalogue: boolean;
20
21
  hideDefault: "sold";
21
22
  isAvailable: typeof alwaysAvailable;
22
23
  unavailableClauses: readonly import("../availability").UnavailableClause[];
@@ -18,6 +18,9 @@ export const config = {
18
18
  pluralLabel: "Classifieds",
19
19
  chipLabel: "Classifieds",
20
20
  browseRoute: String(ROUTES.PUBLIC.CLASSIFIED),
21
+ // Has a dedicated page but NO MAIN_NAV_ITEMS entry, so /products is its only
22
+ // discovery path. Dropping it here would orphan the type (Root Cause #61).
23
+ inGeneralCatalogue: true,
21
24
  hideDefault: "sold",
22
25
  // Runs out rather than ending — no time dimension, so the shared
23
26
  // stock-only rules are the whole story for this type.
@@ -17,6 +17,7 @@ export declare const config: {
17
17
  pluralLabel: string;
18
18
  chipLabel: string;
19
19
  browseRoute: string;
20
+ inGeneralCatalogue: boolean;
20
21
  hideDefault: "sold";
21
22
  isAvailable: (row: AvailabilityRow) => boolean;
22
23
  unavailableClauses: ({
@@ -31,6 +31,9 @@ export const config = {
31
31
  pluralLabel: "Digital Codes",
32
32
  chipLabel: "Digital Codes",
33
33
  browseRoute: String(ROUTES.PUBLIC.DIGITAL_CODES),
34
+ // Has a dedicated page but NO MAIN_NAV_ITEMS entry, so /products is its only
35
+ // discovery path. Dropping it here would orphan the type (Root Cause #61).
36
+ inGeneralCatalogue: true,
34
37
  // "Sold out" for a code pool means codesAvailable hit 0. That was ASSUMED to
35
38
  // be mirrored by stockQuantity, and it isn't — seed and production rows exist
36
39
  // with a drained pool and non-zero stock. The pool count is authoritative.
@@ -17,6 +17,7 @@ export declare const config: {
17
17
  pluralLabel: string;
18
18
  chipLabel: string;
19
19
  browseRoute: string;
20
+ inGeneralCatalogue: boolean;
20
21
  hideDefault: "sold";
21
22
  isAvailable: typeof alwaysAvailable;
22
23
  unavailableClauses: readonly import("../availability").UnavailableClause[];
@@ -18,6 +18,9 @@ export const config = {
18
18
  pluralLabel: "Live Items",
19
19
  chipLabel: "Live Items",
20
20
  browseRoute: String(ROUTES.PUBLIC.LIVE),
21
+ // Has a dedicated page but NO MAIN_NAV_ITEMS entry, so /products is its only
22
+ // discovery path. Dropping it here would orphan the type (Root Cause #61).
23
+ inGeneralCatalogue: true,
21
24
  hideDefault: "sold",
22
25
  // Runs out rather than ending — no time dimension, so the shared
23
26
  // stock-only rules are the whole story for this type.
@@ -17,6 +17,7 @@ export declare const config: {
17
17
  pluralLabel: string;
18
18
  chipLabel: string;
19
19
  browseRoute: string;
20
+ inGeneralCatalogue: boolean;
20
21
  hideDefault: "closed";
21
22
  isAvailable: (row: AvailabilityRow) => boolean;
22
23
  unavailableClauses: readonly import("../availability").UnavailableClause[];
@@ -19,6 +19,8 @@ export const config = {
19
19
  pluralLabel: "Pre-Orders",
20
20
  chipLabel: "Pre-Orders",
21
21
  browseRoute: String(ROUTES.PUBLIC.PRE_ORDERS),
22
+ // /pre-orders is in MAIN_NAV_ITEMS.
23
+ inGeneralCatalogue: false,
22
24
  hideDefault: "closed",
23
25
  // A pre-order closes when its allocation fills, NOT when its delivery date
24
26
  // passes — a shipped-soon pre-order is still orderable. `preOrderClosed`
@@ -17,6 +17,7 @@ export declare const config: {
17
17
  pluralLabel: string;
18
18
  chipLabel: string;
19
19
  browseRoute: string;
20
+ inGeneralCatalogue: boolean;
20
21
  hideDefault: "closed";
21
22
  isAvailable: (row: AvailabilityRow) => boolean;
22
23
  unavailableClauses: ({
@@ -18,6 +18,9 @@ export const config = {
18
18
  pluralLabel: "Prize Draws",
19
19
  chipLabel: "Prize Draws",
20
20
  browseRoute: String(ROUTES.PUBLIC.PRIZE_DRAWS),
21
+ // /prize-draws is in MAIN_NAV_ITEMS. Entry-based pricing reads wrong beside
22
+ // ordinary unit prices in the general grid.
23
+ inGeneralCatalogue: false,
21
24
  hideDefault: "closed",
22
25
  // `prizeRevealStatus`, NOT remaining entries: a draw can close with slots
23
26
  // unsold (the seller ends it early, or the reveal window lapses), and it can
@@ -17,6 +17,7 @@ export declare const config: {
17
17
  pluralLabel: string;
18
18
  chipLabel: string;
19
19
  browseRoute: string;
20
+ inGeneralCatalogue: boolean;
20
21
  hideDefault: "sold";
21
22
  isAvailable: typeof alwaysAvailable;
22
23
  unavailableClauses: readonly import("../availability").UnavailableClause[];
@@ -17,6 +17,8 @@ export const config = {
17
17
  pluralLabel: "Products",
18
18
  chipLabel: "Standard",
19
19
  browseRoute: String(ROUTES.PUBLIC.PRODUCTS),
20
+ // The general catalogue IS this type's page.
21
+ inGeneralCatalogue: true,
20
22
  hideDefault: "sold",
21
23
  // Runs out rather than ending — no time dimension, so the shared
22
24
  // stock-only rules are the whole story for this type.
@@ -17,6 +17,7 @@ export declare const config: {
17
17
  pluralLabel: string;
18
18
  chipLabel: string;
19
19
  browseRoute: string;
20
+ inGeneralCatalogue: boolean;
20
21
  hideDefault: "sold";
21
22
  isAvailable: typeof alwaysAvailable;
22
23
  unavailableClauses: readonly import("../availability").UnavailableClause[];
@@ -20,6 +20,8 @@ export const config = {
20
20
  // have no page of their own, which is why this points at ART rather than
21
21
  // a `/stickers` route that does not exist.
22
22
  browseRoute: String(ROUTES.PUBLIC.ART),
23
+ // /art ("Art & Stickers") is in MAIN_NAV_ITEMS and spans art + stickers.
24
+ inGeneralCatalogue: false,
23
25
  hideDefault: "sold",
24
26
  // Runs out rather than ending — no time dimension, so the shared
25
27
  // stock-only rules are the whole story for this type.
@@ -2,7 +2,17 @@
2
2
  import { normalizeError } from "../../errors/normalize";
3
3
  import { CLIENT_ERROR_ENDPOINTS } from "../../constants/api-endpoints";
4
4
  const SEEN = new Map();
5
- const DEDUPE_WINDOW_MS = 5000;
5
+ /**
6
+ * How long an identical error is suppressed before it is reported again.
7
+ *
8
+ * This is a rate limit on a beacon that costs a function invocation per send,
9
+ * so the window has to be sized against the worst case rather than the typical
10
+ * one: a component stuck in a render loop re-throws the SAME error forever, and
11
+ * at the original 5s that was 12 invocations/minute/tab, indefinitely, from a
12
+ * single broken page. The first occurrence is still reported immediately —
13
+ * widening this loses no distinct error, only repeats of one already recorded.
14
+ */
15
+ const DEDUPE_WINDOW_MS = 60000;
6
16
  const MAX_SEEN = 32;
7
17
  function topFrame(stack) {
8
18
  if (!stack)
package/dist/client.d.ts CHANGED
@@ -61,6 +61,10 @@ export { SideModal } from "./ui/components/SideModal";
61
61
  export { UnsavedChangesModal } from "./ui/components/UnsavedChangesModal";
62
62
  export { LoginRequiredModal } from "./ui/components/LoginRequiredModal";
63
63
  export type { LoginRequiredModalProps } from "./ui/components/LoginRequiredModal";
64
+ export { GatedPrice, PricesOnly, GATED_PRICE_CLASS } from "./ui/components/GatedPrice";
65
+ export type { GatedPriceProps } from "./ui/components/GatedPrice";
66
+ export { useCanSeePrices } from "./react/hooks/useCanSeePrices";
67
+ export type { CanSeePricesState } from "./react/hooks/useCanSeePrices";
64
68
  export { isAuthError } from "./utils/auth-error";
65
69
  export { useToast } from "./ui/components/Toast";
66
70
  export { StickyToolbar } from "./ui/components/StickyToolbar";
@@ -410,6 +414,8 @@ export { NavPermissionsManager } from "./features/site-settings/components/NavPe
410
414
  export type { NavPermissionsManagerProps, NavGroup as NavPermissionsGroup, NavItem as NavPermissionsItem } from "./features/site-settings/components/NavPermissionsManager";
411
415
  export { CodeRevealPanel } from "./_internal/client/features/digital-code/CodeRevealPanel";
412
416
  export type { CodeRevealPanelProps, RevealedCode } from "./_internal/client/features/digital-code/CodeRevealPanel";
417
+ export { DigitalContentPoolManager } from "./_internal/client/features/digital-code/DigitalContentPoolManager";
418
+ export type { DigitalContentPoolManagerProps, PoolEntry } from "./_internal/client/features/digital-code/DigitalContentPoolManager";
413
419
  export { PhysicalLocationModal } from "./features/seller/components/PhysicalLocationModal";
414
420
  export type { PhysicalLocation } from "./features/seller/components/PhysicalLocationModal";
415
421
  export { Toggle } from "./ui/components/Toggle";
package/dist/client.js CHANGED
@@ -89,6 +89,10 @@ export { UnsavedChangesModal } from "./ui/components/UnsavedChangesModal";
89
89
  // [CLIENT-ONLY]-Cannot run in SSR mode — uses browser-only APIs (window.location).
90
90
  // LoginRequiredModal - Modal prompting unauthenticated users to log in.
91
91
  export { LoginRequiredModal } from "./ui/components/LoginRequiredModal";
92
+ // GatedPrice - a money amount only signed-in visitors may read. GATED_PRICE_CLASS
93
+ // is a contract with the JSON-LD paywall markup; see the component.
94
+ export { GatedPrice, PricesOnly, GATED_PRICE_CLASS } from "./ui/components/GatedPrice";
95
+ export { useCanSeePrices } from "./react/hooks/useCanSeePrices";
92
96
  // isAuthError - Detects auth/authorization errors from server actions or fetch responses.
93
97
  export { isAuthError } from "./utils/auth-error";
94
98
  // [CLIENT-ONLY]-Cannot run in SSR mode â€" uses browser-only APIs (window, navigator, localStorage, matchMedia, DOM events) that do not exist in Node.js.
@@ -420,6 +424,12 @@ export { ActionPermissionsManager } from "./features/site-settings/components/Ac
420
424
  export { NavPermissionsManager } from "./features/site-settings/components/NavPermissionsManager";
421
425
  // ── Digital-code code-reveal panel (still used directly on order pages) ───────
422
426
  export { CodeRevealPanel } from "./_internal/client/features/digital-code/CodeRevealPanel";
427
+ /*
428
+ * The SELLER half. Without a surface the pool routes are unreachable, which is
429
+ * the same "finished feature nobody can get to" gap that left the pool empty in
430
+ * the first place (its only writer answered 501).
431
+ */
432
+ export { DigitalContentPoolManager } from "./_internal/client/features/digital-code/DigitalContentPoolManager";
423
433
  export { PhysicalLocationModal } from "./features/seller/components/PhysicalLocationModal";
424
434
  // [CLIENT-ONLY] — Toggle switch primitive.
425
435
  export { Toggle } from "./ui/components/Toggle";
@@ -18,7 +18,17 @@ import { useQueryClient } from "@tanstack/react-query";
18
18
  import { apiClient } from "../../http";
19
19
  import { CART_ENDPOINTS, ACCOUNT_ENDPOINTS } from "../../constants/api-endpoints";
20
20
  import { getCartOps, clearCartOps, getWishlistOps, clearWishlistOps, } from "../../features/cart/utils/pending-ops";
21
- const SYNC_INTERVAL_MS = 30000;
21
+ /**
22
+ * Cadence for replaying queued cart/wishlist ops.
23
+ *
24
+ * A tick with no pending ops is nearly free, but a tick WITH ops also
25
+ * invalidates the cart and wishlist queries, so each one can cascade into
26
+ * refetches. At 30s that ran for every signed-in tab for as long as it stayed
27
+ * open. 2 minutes is ample for an offline-replay backstop — the queue is also
28
+ * flushed eagerly on `visibilitychange` and `pagehide` (see below), which is
29
+ * what actually catches the tab-closing case that matters.
30
+ */
31
+ const SYNC_INTERVAL_MS = 120000;
22
32
  async function replayCartOps() {
23
33
  const ops = getCartOps();
24
34
  if (ops.length === 0)
@@ -1,6 +1,11 @@
1
1
  import { useMutation, useQuery } from "@tanstack/react-query";
2
2
  import { apiClient } from "../../../http";
3
3
  import { NOTIFICATIONS_ENDPOINTS } from "../../../constants/api-endpoints";
4
+ /**
5
+ * Poll cadence for the notification badge. See the comment at the
6
+ * `refetchInterval` below for why this is minutes rather than seconds.
7
+ */
8
+ const NOTIFICATIONS_POLL_MS = 5 * 60000;
4
9
  export function useNotifications(limit = 10, options) {
5
10
  const notificationsEndpoint = options?.notificationsEndpoint ?? NOTIFICATIONS_ENDPOINTS.LIST;
6
11
  const readAllEndpoint = options?.readAllEndpoint ?? NOTIFICATIONS_ENDPOINTS.READ_ALL;
@@ -8,12 +13,27 @@ export function useNotifications(limit = 10, options) {
8
13
  const { data, isLoading, refetch } = useQuery({
9
14
  queryKey: ["notifications", "list", String(limit)],
10
15
  queryFn: () => apiClient.get(`${notificationsEndpoint}?limit=${limit}`),
11
- staleTime: 30000,
16
+ staleTime: NOTIFICATIONS_POLL_MS,
12
17
  // Notifications originate server-side (order/bid/moderation events) with
13
- // no client-driven local-first path to short-circuit, unlike cart/
14
- // wishlist. Poll on the same ~30s cadence as useSyncManager so the bell
15
- // badge picks up new notifications without requiring a window refocus.
16
- refetchInterval: 30000,
18
+ // no client-driven local-first path to short-circuit, unlike cart/wishlist,
19
+ // so the badge does need a poll rather than a local-first read.
20
+ //
21
+ // 🛑 This hook is mounted in TitleBar, i.e. on EVERY page of the site, so
22
+ // its interval is multiplied by every open tab of every signed-in user for
23
+ // as long as those tabs exist — it is wall-clock, not activity-gated, so a
24
+ // tab left open overnight keeps firing. At the original 30s that was 120
25
+ // invocations/hour/tab for an unread COUNT, and it was a material share of
26
+ // the Vercel function-invocation spend that paused the account (2026-09-14).
27
+ //
28
+ // 5 minutes + refetch-on-focus is the trade: a user who is actually looking
29
+ // at the tab gets a fresh badge the moment they return to it, and an
30
+ // abandoned tab costs 12 invocations/hour instead of 120.
31
+ refetchInterval: NOTIFICATIONS_POLL_MS,
32
+ // The global QueryClient default is `refetchOnWindowFocus: false`
33
+ // (src/app/[locale]/QueryProvider.tsx). Opt back in HERE specifically,
34
+ // because it is what makes the longer interval acceptable rather than
35
+ // simply slower.
36
+ refetchOnWindowFocus: true,
17
37
  });
18
38
  const { mutate: markRead } = useMutation({
19
39
  mutationFn: (id) => apiClient.patch(readEndpoint(id), {}),
@@ -2,6 +2,7 @@
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { useState } from "react";
4
4
  import { useBottomActions } from "../../layout/hooks/useBottomActions";
5
+ import { useCanSeePrices } from "../../../react/hooks/useCanSeePrices";
5
6
  import { formatCurrency } from "../../../utils/number.formatter";
6
7
  import { ACTION_ID, ACTION_META } from "../../products/constants/action-defs";
7
8
  import { useCountdown } from "../../../react";
@@ -40,6 +41,7 @@ export function AuctionBottomActions(props) {
40
41
  const { currentBid, currency, bidCount, isEnded, auctionEndDate, buyNowPrice } = props;
41
42
  const [open, setOpen] = useState(false);
42
43
  const remaining = useCountdown(auctionEndDate ?? undefined);
44
+ const { canSeePrices } = useCanSeePrices();
43
45
  const placeBidMeta = ACTION_META[ACTION_ID.PLACE_BID];
44
46
  const buyNowMeta = ACTION_META[ACTION_ID.BUY_NOW_AUCTION];
45
47
  // Same single predicate the form and the card use. `!!props.onBuyNow` matters
@@ -54,14 +56,20 @@ export function AuctionBottomActions(props) {
54
56
  ? [
55
57
  {
56
58
  ...buyNowMeta,
57
- label: `Buy Now — ${formatCurrency(buyNowPrice, currency)}`,
59
+ // Both of these are plain strings on the bottom bar rather
60
+ // than slots, so the gate happens as they are built.
61
+ label: canSeePrices
62
+ ? `Buy Now — ${formatCurrency(buyNowPrice, currency)}`
63
+ : "Buy Now",
58
64
  onClick: () => setOpen(true),
59
65
  },
60
66
  ]
61
67
  : []),
62
68
  ],
63
69
  secondaryLabel: formatCountdownLabel(remaining),
64
- infoLabel: `${formatCurrency(currentBid, currency)} · ${bidCount} bid${bidCount !== 1 ? "s" : ""}`,
70
+ infoLabel: canSeePrices
71
+ ? `${formatCurrency(currentBid, currency)} · ${bidCount} bid${bidCount !== 1 ? "s" : ""}`
72
+ : `Sign in to see the bid · ${bidCount} bid${bidCount !== 1 ? "s" : ""}`,
65
73
  desktop: "after-scroll",
66
74
  });
67
75
  return (_jsx(Modal, { isOpen: open, onClose: () => setOpen(false), size: "md", title: "Place your bid", children: _jsx(PlaceBidFormClient, { ...props }) }));
@@ -17,7 +17,7 @@ import { normalizeRichTextHtml } from "../../../utils/string.formatter";
17
17
  import { safeDisplayName } from "../../../security";
18
18
  import { getSiteSettingsGlobal } from "../../admin/utils/getSiteSettingsGlobal";
19
19
  import { isBuyNowAvailable, resolveMinBid, resolveMinBidIncrement, } from "../../../_internal/shared/features/auctions/config";
20
- import { Button, Container, CountdownDisplay, Div, Heading, Input, Main, Nav, RichText, Row, Section, Span, Stack, Text, Ul, Li, Dl, Dt, Dd, } from "../../../ui";
20
+ import { Button, Container, CountdownDisplay, Div, GatedPrice, PricesOnly, Heading, Input, Main, Nav, RichText, Row, Section, Span, Stack, Text, Ul, Li, Dl, Dt, Dd, } from "../../../ui";
21
21
  import { AuctionDetailView } from "../../products/components/AuctionDetailView";
22
22
  import { ProductTabsShell } from "../../products/components/ProductTabsShell";
23
23
  import { CustomSectionTabContent } from "../../products/components/CustomSectionTabContent";
@@ -48,7 +48,7 @@ function toDescriptionHtml(raw) {
48
48
  }
49
49
  function renderAuctionInfoPanel(props) {
50
50
  const { productId, title, currentBid, currency, bidCount, isEnded, endDate, buyNowPrice, buyNowAvailable, featured, freeShipping, condition, finalSale, category, categoryName, brand, brandSlug, productFeatures, features, descriptionHtml, safeSeller, storeHref } = props;
51
- return (_jsxs(Stack, { gap: "md", children: [_jsxs(Div, { children: [_jsxs(Row, { gap: "xs", wrap: true, className: "mb-2", children: [_jsx(Span, { size: "xs", weight: "semibold", className: CLS_LIVE_BADGE, children: "\uD83C\uDFF7\uFE0F Live Auction" }), isEnded ? (_jsx(Span, { color: "error", surface: "danger-surface", size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", children: "Ended" })) : (_jsx(Span, { color: "success", surface: "success-surface", size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", children: "Active" }))] }), _jsx(Heading, { level: 1, className: "leading-snug", smSize: "2xl", color: "primary", size: "xl", weight: "bold", children: title })] }), _jsxs(Div, { children: [_jsx(Text, { className: "mb-0.5", color: "muted", size: "xs", children: "Current bid" }), _jsx(LiveBidPrice, { productId: productId, currentBid: currentBid, bidCount: bidCount, currency: currency, isEnded: isEnded, priceSize: "2xl", badgeSize: "sm" }), endDate && !isEnded && (_jsxs(Text, { className: "mt-1.5", color: "muted", size: "sm", children: ["Ends in ", _jsx(Span, { weight: "medium", color: "muted", children: _jsx(CountdownDisplay, { targetDate: endDate, format: "auto", expiredLabel: "Ended" }) })] })), endDate && _jsxs(Text, { className: "mt-0.5", color: "faint", size: "xs", children: [isEnded ? "Ended" : "Ends", " ", endDate.toLocaleString()] })] }), buyNowAvailable && buyNowPrice !== null && (_jsxs(Row, { align: "center", gap: "sm", className: "border border-[var(--appkit-color-primary-200)] dark:border-[var(--appkit-color-primary-800)] bg-primary-50 dark:bg-primary-900/20", padding: "inlineSm", rounded: "lg", children: [_jsx(Span, { size: "xs", color: "muted", children: "Buy Now:" }), _jsx(Span, { size: "base", weight: "bold", className: "text-primary-700 dark:text-primary-300", children: formatCurrency(buyNowPrice, currency) }), _jsx(Span, { size: "xs", color: "faint", children: "\u2014 in \u201CPlace a bid\u201D" })] })), _jsx(ProductFeatureBadges, { featured: featured, freeShipping: freeShipping, condition: condition ?? undefined, finalSale: finalSale, labels: { featured: "Featured", fasterDelivery: "Faster Delivery", ratedSeller: "Rated Seller", condition: "Condition", conditionNew: "New", conditionUsed: "Used", conditionBroken: "For Parts", conditionRefurbished: "Refurbished", returnable: "Returnable", finalSale: "Final Sale", freeShipping: "Free Shipping", codAvailable: "Cash on Delivery", emiAvailable: "EMI Available", wishlistCount: (n) => `${n} wishlisted`, categoryProductCount: (n, cat) => `${n} in ${cat}` } }), (categoryName || category || brand) && (_jsxs(Row, { gap: "sm", wrap: true, children: [category && _jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(category)), className: "inline-flex items-center rounded-full border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] px-[var(--appkit-space-2-5)] py-[var(--appkit-space-1)] text-[length:var(--appkit-text-xs)] font-medium text-[var(--appkit-color-text-muted)] transition-colors hover:border-primary-300 hover:bg-primary-surface hover:text-primary-700 dark:hover:border-primary-700/60 dark:hover:text-primary-400", children: categoryName || category }), !category && categoryName && _jsx(Span, { layout: "inline-flex", size: "xs", weight: "medium", border: "subtle", rounded: "full", padding: "pill-sm-tall", surface: "muted", color: "muted", children: categoryName }), brand && brandSlug && _jsx(Link, { href: String(ROUTES.PUBLIC.BRAND_DETAIL(brandSlug)), className: "inline-flex items-center rounded-full border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] px-[var(--appkit-space-2-5)] py-[var(--appkit-space-1)] text-[length:var(--appkit-text-xs)] font-medium text-[var(--appkit-color-text-muted)] transition-colors hover:border-primary-300 hover:bg-primary-surface hover:text-primary-700 dark:hover:border-primary-700/60 dark:hover:text-primary-400", children: brand }), brand && !brandSlug && _jsx(Span, { layout: "inline-flex", size: "xs", weight: "medium", border: "subtle", rounded: "full", padding: "pill-sm-tall", surface: "muted", color: "muted", children: brand })] })), productFeatures && features.length > 0 && _jsx(FeatureBadgeList, { productFeatureIds: features, features: productFeatures }), !productFeatures && features.length > 0 && (_jsxs(Div, { border: "subtle", surface: "muted", padding: "inline", rounded: "xl", children: [_jsx(Text, { className: "mb-2 tracking-wide", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "About this item" }), _jsx(Ul, { spacing: "comfortable", size: "sm", color: "primary", children: features.map((f, i) => _jsxs(Li, { layout: "flex-start", gap: "2", children: [_jsx(Span, { className: "mt-0.5 flex-shrink-0 text-primary-500", children: "\u2022" }), f] }, i)) })] })), descriptionHtml && _jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm max-w-none dark:prose-invert prose-p:my-0", className: "text-[length:var(--appkit-text-sm)] leading-relaxed text-[var(--appkit-color-text-muted)] line-clamp-4" }), safeSeller && (_jsx(Div, { className: `${__P.p3}`, border: "subtle", rounded: "xl", surface: "muted", children: _jsxs(Row, { justify: "between", align: "center", children: [_jsxs(Div, { children: [_jsx(Text, { className: "text-[10px] tracking-wide mb-0.5", color: "faint", transform: "uppercase", children: "Listed by" }), _jsx(Text, { size: "sm", weight: "semibold", color: "primary", children: safeSeller })] }), storeHref && _jsx(Link, { href: storeHref, className: "shrink-0 rounded-lg bg-primary/10 dark:bg-primary/20 px-[var(--appkit-space-3)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-xs)] font-semibold text-primary-700 dark:text-primary-300 hover:bg-primary/20 dark:hover:bg-primary/30 transition-colors", children: "Visit Store \u2192" })] }) }))] }));
51
+ return (_jsxs(Stack, { gap: "md", children: [_jsxs(Div, { children: [_jsxs(Row, { gap: "xs", wrap: true, className: "mb-2", children: [_jsx(Span, { size: "xs", weight: "semibold", className: CLS_LIVE_BADGE, children: "\uD83C\uDFF7\uFE0F Live Auction" }), isEnded ? (_jsx(Span, { color: "error", surface: "danger-surface", size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", children: "Ended" })) : (_jsx(Span, { color: "success", surface: "success-surface", size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", children: "Active" }))] }), _jsx(Heading, { level: 1, className: "leading-snug", smSize: "2xl", color: "primary", size: "xl", weight: "bold", children: title })] }), _jsxs(Div, { children: [_jsx(Text, { className: "mb-0.5", color: "muted", size: "xs", children: "Current bid" }), _jsx(LiveBidPrice, { productId: productId, currentBid: currentBid, bidCount: bidCount, currency: currency, isEnded: isEnded, priceSize: "2xl", badgeSize: "sm" }), endDate && !isEnded && (_jsxs(Text, { className: "mt-1.5", color: "muted", size: "sm", children: ["Ends in ", _jsx(Span, { weight: "medium", color: "muted", children: _jsx(CountdownDisplay, { targetDate: endDate, format: "auto", expiredLabel: "Ended" }) })] })), endDate && _jsxs(Text, { className: "mt-0.5", color: "faint", size: "xs", children: [isEnded ? "Ended" : "Ends", " ", endDate.toLocaleString()] })] }), buyNowAvailable && buyNowPrice !== null && (_jsxs(Row, { align: "center", gap: "sm", className: "border border-[var(--appkit-color-primary-200)] dark:border-[var(--appkit-color-primary-800)] bg-primary-50 dark:bg-primary-900/20", padding: "inlineSm", rounded: "lg", children: [_jsx(Span, { size: "xs", color: "muted", children: "Buy Now:" }), _jsx(Span, { size: "base", weight: "bold", className: "text-primary-700 dark:text-primary-300", children: _jsx(GatedPrice, { label: "Sign in to see the Buy Now price", children: formatCurrency(buyNowPrice, currency) }) }), _jsx(Span, { size: "xs", color: "faint", children: "\u2014 in \u201CPlace a bid\u201D" })] })), _jsx(ProductFeatureBadges, { featured: featured, freeShipping: freeShipping, condition: condition ?? undefined, finalSale: finalSale, labels: { featured: "Featured", fasterDelivery: "Faster Delivery", ratedSeller: "Rated Seller", condition: "Condition", conditionNew: "New", conditionUsed: "Used", conditionBroken: "For Parts", conditionRefurbished: "Refurbished", returnable: "Returnable", finalSale: "Final Sale", freeShipping: "Free Shipping", codAvailable: "Cash on Delivery", emiAvailable: "EMI Available", wishlistCount: (n) => `${n} wishlisted`, categoryProductCount: (n, cat) => `${n} in ${cat}` } }), (categoryName || category || brand) && (_jsxs(Row, { gap: "sm", wrap: true, children: [category && _jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(category)), className: "inline-flex items-center rounded-full border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] px-[var(--appkit-space-2-5)] py-[var(--appkit-space-1)] text-[length:var(--appkit-text-xs)] font-medium text-[var(--appkit-color-text-muted)] transition-colors hover:border-primary-300 hover:bg-primary-surface hover:text-primary-700 dark:hover:border-primary-700/60 dark:hover:text-primary-400", children: categoryName || category }), !category && categoryName && _jsx(Span, { layout: "inline-flex", size: "xs", weight: "medium", border: "subtle", rounded: "full", padding: "pill-sm-tall", surface: "muted", color: "muted", children: categoryName }), brand && brandSlug && _jsx(Link, { href: String(ROUTES.PUBLIC.BRAND_DETAIL(brandSlug)), className: "inline-flex items-center rounded-full border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] px-[var(--appkit-space-2-5)] py-[var(--appkit-space-1)] text-[length:var(--appkit-text-xs)] font-medium text-[var(--appkit-color-text-muted)] transition-colors hover:border-primary-300 hover:bg-primary-surface hover:text-primary-700 dark:hover:border-primary-700/60 dark:hover:text-primary-400", children: brand }), brand && !brandSlug && _jsx(Span, { layout: "inline-flex", size: "xs", weight: "medium", border: "subtle", rounded: "full", padding: "pill-sm-tall", surface: "muted", color: "muted", children: brand })] })), productFeatures && features.length > 0 && _jsx(FeatureBadgeList, { productFeatureIds: features, features: productFeatures }), !productFeatures && features.length > 0 && (_jsxs(Div, { border: "subtle", surface: "muted", padding: "inline", rounded: "xl", children: [_jsx(Text, { className: "mb-2 tracking-wide", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "About this item" }), _jsx(Ul, { spacing: "comfortable", size: "sm", color: "primary", children: features.map((f, i) => _jsxs(Li, { layout: "flex-start", gap: "2", children: [_jsx(Span, { className: "mt-0.5 flex-shrink-0 text-primary-500", children: "\u2022" }), f] }, i)) })] })), descriptionHtml && _jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm max-w-none dark:prose-invert prose-p:my-0", className: "text-[length:var(--appkit-text-sm)] leading-relaxed text-[var(--appkit-color-text-muted)] line-clamp-4" }), safeSeller && (_jsx(Div, { className: `${__P.p3}`, border: "subtle", rounded: "xl", surface: "muted", children: _jsxs(Row, { justify: "between", align: "center", children: [_jsxs(Div, { children: [_jsx(Text, { className: "text-[10px] tracking-wide mb-0.5", color: "faint", transform: "uppercase", children: "Listed by" }), _jsx(Text, { size: "sm", weight: "semibold", color: "primary", children: safeSeller })] }), storeHref && _jsx(Link, { href: storeHref, className: "shrink-0 rounded-lg bg-primary/10 dark:bg-primary/20 px-[var(--appkit-space-3)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-xs)] font-semibold text-primary-700 dark:text-primary-300 hover:bg-primary/20 dark:hover:bg-primary/30 transition-colors", children: "Visit Store \u2192" })] }) }))] }));
52
52
  }
53
53
  /**
54
54
  * Store reviews for the auction's seller.
@@ -218,7 +218,16 @@ export async function AuctionDetailPageView({ id, initialAuction, onPlaceBid, on
218
218
  storeName: typeof p.storeName === "string" ? p.storeName : undefined,
219
219
  } }), _jsxs(Container, { size: "xl", padding: "y-lg", children: [_jsxs(Row, { className: "mb-4", align: "center", justify: "between", gap: "sm", wrap: true, children: [_jsxs(Nav, { "aria-label": "Breadcrumb", layout: "flex-wrap", gap: "2xs", textSize: "xs", color: "muted", children: [_jsx(Link, { href: String(ROUTES.HOME), className: CLS_BREADCRUMB_LINK, children: "Home" }), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.AUCTIONS), className: CLS_BREADCRUMB_LINK, children: "Auctions" }), category && (_jsxs(_Fragment, { children: [_jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(category)), className: CLS_BREADCRUMB_LINK, children: categoryName || category })] })), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Span, { className: "truncate max-w-[200px]", color: "muted", children: title })] }), _jsx(ShareButton, { title: title })] }), _jsx(AuctionDetailView, { renderGallery: () => (_jsx(ProductGalleryClient, { images: images, video: productVideo, productName: title })), renderInfo: () => renderAuctionInfoPanel({ productId: String(product.id), title, currentBid, currency, bidCount, isEnded, endDate, buyNowPrice, buyNowAvailable, featured, freeShipping, condition, finalSale: isFinalSale(p), category, categoryName, brand, brandSlug, productFeatures, features, descriptionHtml, safeSeller, storeHref }), renderBidForm: () => onPlaceBid ? (_jsx(Stack, { id: "auction-bid-form", gap: "3", children: _jsxs(Stack, { className: `${__P.p5}`, border: "subtle", gap: "3", rounded: "xl", surface: "muted", children: [_jsxs(Row, { justify: "between", align: "baseline", children: [_jsx(LiveBidPrice, { productId: String(product.id), currentBid: currentBid, bidCount: bidCount, currency: currency, isEnded: isEnded, priceSize: "xl", badgeSize: "xs" }), !isEnded && (_jsx(LiveMinIncrement, { productId: String(product.id), currentBid: currentBid, bidCount: bidCount, currency: currency, isEnded: isEnded, tiers: bidIncrementTiers, minBidIncrementOverride: minBidIncrementOverride }))] }), !isEnded && endDate && (_jsxs(Text, { align: "center", size: "xs", color: "muted", children: ["Ends in ", _jsx(CountdownDisplay, { targetDate: endDate, format: "auto", expiredLabel: "Ended" })] })), _jsx(PlaceBidModalButton, { productId: String(product.id), currentBid: currentBid, startingBid: startingBid, tiers: bidIncrementTiers, minBidIncrementOverride: minBidIncrementOverride, currency: currency, isEnded: isEnded, auctionEndDate: endDate, buyNowPrice: buyNowPrice, bidCount: bidCount, tags: tags, onPlaceBid: onPlaceBid, onBuyNow: onBuyNow, triggerClassName: "w-full" })] }) })) : (
220
220
  /* Read-only bid panel — shown when no bid action is wired (preview/demo) */
221
- _jsxs(Stack, { className: `${__P.p5}`, border: "subtle", gap: "md", rounded: "xl", surface: "muted", children: [_jsx(Stack, { gap: "xs", children: _jsxs(Text, { size: "xs", color: "muted", children: ["Starting bid: ", formatCurrency(startingBid, currency), !isEnded && _jsxs(_Fragment, { children: [" \u00B7 min increment ", formatCurrency(initialMinBidIncrement, currency)] })] }) }), _jsxs(Stack, { gap: "sm", children: [_jsx(Input, { type: "number", placeholder: `At least ${formatCurrency(initialMinBid, currency)}`, min: initialMinBid, "aria-label": "Your bid amount", disabled: isEnded }), !isEnded && endDate && (_jsxs(Text, { align: "center", size: "xs", color: "muted", children: ["Ends in ", _jsx(CountdownDisplay, { targetDate: endDate, format: "auto", expiredLabel: "Ended" })] })), _jsx(Button, { variant: "primary", size: "md", className: "w-full", disabled: isEnded, children: isEnded ? "Auction Ended" : "Place Bid" }), buyNowAvailable && buyNowPrice !== null && (_jsxs(Text, { align: "center", size: "xs", color: "muted", children: ["Buy Now available at ", formatCurrency(buyNowPrice, currency)] }))] }), tags.length > 0 && (_jsx(Div, { border: "default", className: "border-t", padding: "t-md", children: _jsx(Row, { wrap: true, gap: "xs", children: tags.map((tag) => (_jsx(Span, { size: "xs", rounded: "full", padding: "pill-sm-tall", surface: "subtle", color: "muted", children: tag }, tag))) }) }))] })), renderMobileBidForm: () => !isEnded && onPlaceBid ? (_jsxs(Stack, { className: `lg:hidden ${__P.p4}`, border: "subtle", gap: "3", rounded: "xl", surface: "muted", children: [_jsx(LiveBidPrice, { productId: String(product.id), currentBid: currentBid, bidCount: bidCount, currency: currency, isEnded: isEnded, priceSize: "base", badgeSize: "xs" }), endDate && (_jsxs(Text, { align: "center", size: "xs", color: "muted", children: ["Ends in ", _jsx(CountdownDisplay, { targetDate: endDate, format: "auto", expiredLabel: "Ended" })] })), _jsx(PlaceBidModalButton, { productId: String(product.id), currentBid: currentBid, startingBid: startingBid, tiers: bidIncrementTiers, minBidIncrementOverride: minBidIncrementOverride, currency: currency, isEnded: isEnded, auctionEndDate: endDate, buyNowPrice: buyNowPrice, bidCount: bidCount, tags: tags, onPlaceBid: onPlaceBid, onBuyNow: onBuyNow, triggerClassName: "w-full" })] })) : !isEnded ? (_jsxs(Div, { className: `${__P.p4} lg:hidden`, border: "subtle", rounded: "xl", surface: "muted", children: [_jsxs(Row, { align: "center", gap: "sm", className: "mb-3", children: [_jsx(Span, { size: "base", weight: "bold", className: "text-primary-600 dark:text-primary-400", children: formatCurrency(currentBid, currency) }), _jsxs(Span, { size: "xs", color: "muted", children: [bidCount, " bids"] })] }), endDate && (_jsxs(Text, { align: "center", className: "mb-3", size: "xs", color: "muted", children: ["Ends in ", _jsx(CountdownDisplay, { targetDate: endDate, format: "auto", expiredLabel: "Ended" })] })), _jsx(Button, { variant: "primary", size: "md", className: "w-full", children: "Place Bid" })] })) : null, renderSublistingSection: sublistingCategoryId
221
+ _jsxs(Stack, { className: `${__P.p5}`, border: "subtle", gap: "md", rounded: "xl", surface: "muted", children: [_jsx(Stack, { gap: "xs", children: _jsx(Text, { size: "xs", color: "muted", children: _jsxs(GatedPrice, { label: "Sign in to see the starting bid", children: ["Starting bid: ", formatCurrency(startingBid, currency), !isEnded && _jsxs(_Fragment, { children: [" \u00B7 min increment ", formatCurrency(initialMinBidIncrement, currency)] })] }) }) }), _jsxs(Stack, { gap: "sm", children: [_jsx(Input, { type: "number",
222
+ // The minimum is stated in the gated line above rather than
223
+ // in the placeholder: a placeholder is a plain string prop,
224
+ // so it cannot be gated in place and would print the bid
225
+ // floor to signed-out visitors.
226
+ placeholder: "Enter your bid", min: initialMinBid, "aria-label": "Your bid amount", disabled: isEnded }), !isEnded && endDate && (_jsxs(Text, { align: "center", size: "xs", color: "muted", children: ["Ends in ", _jsx(CountdownDisplay, { targetDate: endDate, format: "auto", expiredLabel: "Ended" })] })), _jsx(Button, { variant: "primary", size: "md", className: "w-full", disabled: isEnded, children: isEnded ? "Auction Ended" : "Place Bid" }), buyNowAvailable && buyNowPrice !== null && (
227
+ // Hidden rather than prompted: the Buy Now chip at the top
228
+ // of the panel already carries the gate, so repeating the
229
+ // prompt here would be the same affordance twice.
230
+ _jsx(PricesOnly, { children: _jsxs(Text, { align: "center", size: "xs", color: "muted", children: ["Buy Now available at ", formatCurrency(buyNowPrice, currency)] }) }))] }), tags.length > 0 && (_jsx(Div, { border: "default", className: "border-t", padding: "t-md", children: _jsx(Row, { wrap: true, gap: "xs", children: tags.map((tag) => (_jsx(Span, { size: "xs", rounded: "full", padding: "pill-sm-tall", surface: "subtle", color: "muted", children: tag }, tag))) }) }))] })), renderMobileBidForm: () => !isEnded && onPlaceBid ? (_jsxs(Stack, { className: `lg:hidden ${__P.p4}`, border: "subtle", gap: "3", rounded: "xl", surface: "muted", children: [_jsx(LiveBidPrice, { productId: String(product.id), currentBid: currentBid, bidCount: bidCount, currency: currency, isEnded: isEnded, priceSize: "base", badgeSize: "xs" }), endDate && (_jsxs(Text, { align: "center", size: "xs", color: "muted", children: ["Ends in ", _jsx(CountdownDisplay, { targetDate: endDate, format: "auto", expiredLabel: "Ended" })] })), _jsx(PlaceBidModalButton, { productId: String(product.id), currentBid: currentBid, startingBid: startingBid, tiers: bidIncrementTiers, minBidIncrementOverride: minBidIncrementOverride, currency: currency, isEnded: isEnded, auctionEndDate: endDate, buyNowPrice: buyNowPrice, bidCount: bidCount, tags: tags, onPlaceBid: onPlaceBid, onBuyNow: onBuyNow, triggerClassName: "w-full" })] })) : !isEnded ? (_jsxs(Div, { className: `${__P.p4} lg:hidden`, border: "subtle", rounded: "xl", surface: "muted", children: [_jsxs(Row, { align: "center", gap: "sm", className: "mb-3", children: [_jsx(Span, { size: "base", weight: "bold", className: "text-primary-600 dark:text-primary-400", children: _jsx(GatedPrice, { label: "Sign in to see the current bid", children: formatCurrency(currentBid, currency) }) }), _jsxs(Span, { size: "xs", color: "muted", children: [bidCount, " bids"] })] }), endDate && (_jsxs(Text, { align: "center", className: "mb-3", size: "xs", color: "muted", children: ["Ends in ", _jsx(CountdownDisplay, { targetDate: endDate, format: "auto", expiredLabel: "Ended" })] })), _jsx(Button, { variant: "primary", size: "md", className: "w-full", children: "Place Bid" })] })) : null, renderSublistingSection: sublistingCategoryId
222
231
  ? () => (_jsx(SublistingCarouselSection, { sublistingCategoryId: sublistingCategoryId, currentListingId: String(product.id) }))
223
232
  : undefined, renderTabs: () => (_jsx(ProductTabsShell, { descriptionContent: descriptionHtml ? (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm sm:prose max-w-none dark:prose-invert", className: "text-[var(--appkit-color-text-muted)]" })) : undefined, specsContent: specs.length > 0 ? (_jsx(Dl, { divide: "subtle", rounded: "xl", border: "subtle", className: "overflow-hidden", children: specs.map((s, i) => (_jsxs(Row, { gap: "md", oddEven: "zebra", surface: "default", padding: "inline", children: [_jsx(Dt, { className: "w-36 flex-shrink-0", color: "primary", weight: "medium", children: s.name }), _jsxs(Dd, { className: "flex-1", color: "muted", children: [s.value, s.unit ? ` ${s.unit}` : ""] })] }, i))) })) : undefined, customTabs: customSections.map((s) => ({
224
233
  id: s.id,