@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,208 @@
1
+ /**
2
+ * The digital-content pool for a `digital-code` listing.
3
+ *
4
+ * 🛑 THIS IS THE MISSING HALF OF THE FEATURE. Everything downstream already
5
+ * existed — `claimDigitalCodeForOrder` claims one at checkout, the buyer reveals
6
+ * it through `GET /api/orders/{id}/code`, a refund revokes unclaimed ones — but
7
+ * **nothing in the codebase had ever WRITTEN a row into the pool.** The one
8
+ * seller-facing route returned `501 "Digital code management is not implemented
9
+ * yet."` So every purchase of a digital-code listing hit
10
+ * `serverLogger.warn("claimDigitalCode: code pool exhausted")` and returned
11
+ * silently, the order completed normally, and the buyer's reveal panel answered
12
+ * 404. The seller form even has a "Code Pool Size" field, which made the pool
13
+ * look populated on the listing page while it was always empty.
14
+ *
15
+ * A "digital code" delivers digital CONTENT: a redemption string, a QR image, or
16
+ * a file. `contentKind` says which, defaulting to `"code"` so every pre-existing
17
+ * document keeps its meaning with no migration.
18
+ */
19
+ import { getAdminDb } from "../../../../providers/db-firebase/admin";
20
+ import { normalizeError } from "../../../../errors/normalize";
21
+ import { serverLogger } from "../../../../monitoring";
22
+ import { ValidationError } from "../../../../errors";
23
+ import { ORDER_FIELDS } from "../../../../constants/field-names";
24
+ import { PRODUCT_COLLECTION, PRODUCT_CODES_SUBCOLLECTION, } from "../../../../features/products/schemas/firestore";
25
+ /**
26
+ * Where a delivered asset's bytes live.
27
+ *
28
+ * Deliberately OUTSIDE every prefix the public media proxy knows how to resolve,
29
+ * and no `mediaAssets` row is ever created for it — a `mediaAssets` row is what
30
+ * mints a `/media/{shortId}` slug, and that slug is served without
31
+ * authentication. No row, no slug, no public URL.
32
+ */
33
+ export const DIGITAL_CONTENT_PREFIX = "private/digital-content";
34
+ /**
35
+ * 🛑 THE PERMISSION SPLIT, and the reason it is not simply "admins only".
36
+ *
37
+ * An image is the QR/voucher case and is the whole point of widening this from
38
+ * strings, so a seller must be able to add one — they are the person who has it.
39
+ * Any OTHER file type is admin-only to upload: the upload surface accepts bytes
40
+ * that will later be streamed to a buyer, and a `.html` renamed `.pdf` is stored
41
+ * XSS the moment anything renders it inline. Narrowing who can put a non-image
42
+ * file into that path is the cheap half of the defence; the magic-byte check and
43
+ * the forced `Content-Disposition: attachment` are the other half.
44
+ *
45
+ * READ is a third thing again and is not on this axis at all: whoever may
46
+ * upload, only the buyer who paid for that order (or staff) may download. See
47
+ * `assertCanDownloadCodeAsset`.
48
+ */
49
+ export function assertCanUploadContentKind(kind, role) {
50
+ const isStaff = role === "admin" || role === "moderator" || role === "employee";
51
+ if (kind === "file" && !isStaff) {
52
+ throw new ValidationError("Only an administrator can attach a downloadable file to a listing. Sellers can attach a code or a QR image.");
53
+ }
54
+ }
55
+ function assertPoolEntryShape(input) {
56
+ if (input.contentKind === "code") {
57
+ if (!input.code || !input.code.trim()) {
58
+ throw new ValidationError("A code entry needs a code.");
59
+ }
60
+ return;
61
+ }
62
+ if (!input.assetPath) {
63
+ throw new ValidationError(`A ${input.contentKind} entry needs an uploaded asset.`);
64
+ }
65
+ /*
66
+ * Refuse anything outside the private prefix rather than "fixing it up".
67
+ * A caller that hands us `tmp/uploads/<uid>/x.png` is handing us a path the
68
+ * public proxy can already serve, and silently rewriting it would hide the
69
+ * mistake instead of surfacing it.
70
+ */
71
+ if (!input.assetPath.startsWith(`${DIGITAL_CONTENT_PREFIX}/`)) {
72
+ throw new ValidationError("Asset is not in the private digital-content store.");
73
+ }
74
+ }
75
+ /**
76
+ * Append entries to a product's pool and re-sync the two counters.
77
+ *
78
+ * `codePoolSize` and `codesAvailable` are recomputed from the subcollection, not
79
+ * incremented from the input — the seller form lets a human type `codePoolSize`
80
+ * by hand, so treating that number as authoritative would let the listing page
81
+ * advertise stock that does not exist. Counting is the only thing that cannot
82
+ * drift (Root Cause #42: a mirror is only as good as every writer of it).
83
+ */
84
+ export async function addPoolEntries(entries) {
85
+ if (entries.length === 0)
86
+ throw new ValidationError("Nothing to add.");
87
+ const productId = entries[0].productId;
88
+ for (const e of entries) {
89
+ if (e.productId !== productId) {
90
+ throw new ValidationError("All entries must belong to the same listing.");
91
+ }
92
+ assertPoolEntryShape(e);
93
+ }
94
+ const db = getAdminDb();
95
+ const codesRef = db
96
+ .collection(PRODUCT_COLLECTION)
97
+ .doc(productId)
98
+ .collection(PRODUCT_CODES_SUBCOLLECTION);
99
+ const now = new Date();
100
+ const batch = db.batch();
101
+ for (const e of entries) {
102
+ const doc = {
103
+ productId,
104
+ code: e.contentKind === "code" ? e.code.trim() : "",
105
+ contentKind: e.contentKind,
106
+ status: "available",
107
+ createdAt: now,
108
+ updatedAt: now,
109
+ ...(e.assetPath ? { assetPath: e.assetPath } : {}),
110
+ ...(e.fileName ? { fileName: e.fileName } : {}),
111
+ ...(e.contentType ? { contentType: e.contentType } : {}),
112
+ ...(e.expiresAt ? { expiresAt: e.expiresAt } : {}),
113
+ };
114
+ batch.set(codesRef.doc(), doc);
115
+ }
116
+ await batch.commit();
117
+ const counts = await recountPool(productId);
118
+ return { added: entries.length, ...counts };
119
+ }
120
+ /**
121
+ * Recount the pool and write both counters onto the product.
122
+ *
123
+ * 🛑 `codesAvailable` HAD NO WRITER EITHER. `claimDigitalCodeForOrder` flips a
124
+ * code to `claimed` and never decrements it, so the counter only ever held
125
+ * whatever the seed or the seller typed. Since `hasCodesAvailable()` is what the
126
+ * availability predicate reads, a sold-out listing stayed "available" forever and
127
+ * a listing that never had codes still advertised stock. Both counters are
128
+ * derived here, so neither can drift from the subcollection.
129
+ */
130
+ export async function recountPool(productId) {
131
+ const db = getAdminDb();
132
+ const codesRef = db
133
+ .collection(PRODUCT_COLLECTION)
134
+ .doc(productId)
135
+ .collection(PRODUCT_CODES_SUBCOLLECTION);
136
+ // Aggregation counts, not document reads — a pool can be large and this runs
137
+ // on every claim (Rule #6).
138
+ const [availableAgg, totalAgg] = await Promise.all([
139
+ codesRef.where(ORDER_FIELDS.STATUS, "==", "available").count().get(),
140
+ codesRef.count().get(),
141
+ ]);
142
+ const available = availableAgg.data().count;
143
+ const total = totalAgg.data().count;
144
+ try {
145
+ await db.collection(PRODUCT_COLLECTION).doc(productId).update({
146
+ "digitalCode.codesAvailable": available,
147
+ "digitalCode.codePoolSize": total,
148
+ updatedAt: new Date(),
149
+ });
150
+ }
151
+ catch (err) {
152
+ void normalizeError(err);
153
+ // Non-fatal: the pool itself is the source of truth and a stale counter only
154
+ // affects a badge. Failing the claim over it would be worse.
155
+ serverLogger.error("recountPool: counter write failed", { error: normalizeError(err).message, productId });
156
+ }
157
+ return { available, total };
158
+ }
159
+ /** The seller/admin view of a pool. Never returns `code` or `assetPath`. */
160
+ export async function listPoolEntries(productId) {
161
+ const db = getAdminDb();
162
+ const snap = await db
163
+ .collection(PRODUCT_COLLECTION)
164
+ .doc(productId)
165
+ .collection(PRODUCT_CODES_SUBCOLLECTION)
166
+ .orderBy(ORDER_FIELDS.CREATED_AT, "desc")
167
+ .limit(200)
168
+ .get();
169
+ /*
170
+ * 🛑 An ALLOW-LIST, not a spread-and-delete. The whole value of this
171
+ * subcollection is that the seller can see how many codes are left without
172
+ * seeing the codes, and a buyer's claimed code is not the seller's to read
173
+ * back either. Root Cause #70: a projection built by deleting keys publishes
174
+ * every field nobody thought to delete.
175
+ */
176
+ return snap.docs.map((d) => {
177
+ const c = d.data();
178
+ return {
179
+ id: d.id,
180
+ contentKind: c.contentKind ?? "code",
181
+ status: c.status,
182
+ ...(c.fileName ? { fileName: c.fileName } : {}),
183
+ ...(c.orderId ? { orderId: c.orderId } : {}),
184
+ ...(c.claimedAt ? { claimedAt: new Date(c.claimedAt).toISOString() } : {}),
185
+ ...(c.createdAt ? { createdAt: new Date(c.createdAt).toISOString() } : {}),
186
+ };
187
+ });
188
+ }
189
+ /**
190
+ * Remove an UNCLAIMED entry. A claimed one is somebody's purchase and deleting
191
+ * it would make their reveal 404 with no trace of what they bought.
192
+ */
193
+ export async function deletePoolEntry(productId, codeId) {
194
+ const db = getAdminDb();
195
+ const ref = db
196
+ .collection(PRODUCT_COLLECTION)
197
+ .doc(productId)
198
+ .collection(PRODUCT_CODES_SUBCOLLECTION)
199
+ .doc(codeId);
200
+ const snap = await ref.get();
201
+ if (!snap.exists)
202
+ throw new ValidationError("Entry not found.");
203
+ if (snap.data().status === "claimed") {
204
+ throw new ValidationError("That entry has already been delivered to a buyer and cannot be removed.");
205
+ }
206
+ await ref.delete();
207
+ return recountPool(productId);
208
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Upload for delivered digital content.
3
+ *
4
+ * 🛑 A SEPARATE PIPELINE FROM `/api/media/*`, ON PURPOSE.
5
+ *
6
+ * The public media pipeline ends by creating a `mediaAssets` row, and that row
7
+ * IS the `/media/{shortId}` slug — which `GET /api/media/[...slug]` serves with
8
+ * no authentication at all. Reusing it for paid content and then "remembering
9
+ * not to publish the URL" is not a boundary; the slug exists the moment the row
10
+ * does. So this writes to `private/digital-content/...`, mints no row, and the
11
+ * bytes are only ever readable through `resolveCodeAssetForOrder`, which checks
12
+ * that the caller owns the order.
13
+ *
14
+ * Everything else about the flow is deliberately identical to the public one —
15
+ * a short-lived signed PUT so bytes never traverse the Next.js function (Rule
16
+ * #6 caps a request body at 4.5 MB), then a server-side finalize that verifies
17
+ * the file by its MAGIC BYTES rather than by what the client declared.
18
+ */
19
+ import type { ProductCodeContentKind } from "../../../../features/products/schemas/firestore";
20
+ /** The content kind a declared MIME maps to. */
21
+ export declare function contentKindForMime(mime: string): ProductCodeContentKind;
22
+ export interface SignDigitalContentInput {
23
+ productId: string;
24
+ fileName: string;
25
+ contentType: string;
26
+ size: number;
27
+ }
28
+ export interface SignedDigitalContent {
29
+ uploadUrl: string;
30
+ storagePath: string;
31
+ contentKind: ProductCodeContentKind;
32
+ expiresAt: string;
33
+ }
34
+ export declare function signDigitalContentUpload(input: SignDigitalContentInput): Promise<SignedDigitalContent>;
35
+ export interface FinalizedDigitalContent {
36
+ storagePath: string;
37
+ contentType: string;
38
+ fileName: string;
39
+ contentKind: ProductCodeContentKind;
40
+ size: number;
41
+ }
42
+ /**
43
+ * Verify an uploaded object and return the facts a pool entry needs.
44
+ *
45
+ * 🛑 THE DECLARED MIME IS NOT EVIDENCE. `finalize` on the public pipeline exists
46
+ * for exactly this reason and returns a structured 422 `MIME_MISMATCH`; the same
47
+ * rule has to hold here, and matters more, because the output is handed to a
48
+ * paying buyer. A mismatch DELETES the object rather than leaving it in the
49
+ * bucket for a later caller to reference.
50
+ */
51
+ export declare function finalizeDigitalContentUpload(storagePath: string, declaredContentType: string): Promise<FinalizedDigitalContent>;
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Upload for delivered digital content.
3
+ *
4
+ * 🛑 A SEPARATE PIPELINE FROM `/api/media/*`, ON PURPOSE.
5
+ *
6
+ * The public media pipeline ends by creating a `mediaAssets` row, and that row
7
+ * IS the `/media/{shortId}` slug — which `GET /api/media/[...slug]` serves with
8
+ * no authentication at all. Reusing it for paid content and then "remembering
9
+ * not to publish the URL" is not a boundary; the slug exists the moment the row
10
+ * does. So this writes to `private/digital-content/...`, mints no row, and the
11
+ * bytes are only ever readable through `resolveCodeAssetForOrder`, which checks
12
+ * that the caller owns the order.
13
+ *
14
+ * Everything else about the flow is deliberately identical to the public one —
15
+ * a short-lived signed PUT so bytes never traverse the Next.js function (Rule
16
+ * #6 caps a request body at 4.5 MB), then a server-side finalize that verifies
17
+ * the file by its MAGIC BYTES rather than by what the client declared.
18
+ */
19
+ import { randomUUID } from "node:crypto";
20
+ import { getAdminStorage } from "../../../../providers/db-firebase";
21
+ import { normalizeError } from "../../../../errors/normalize";
22
+ import { serverLogger } from "../../../../monitoring";
23
+ import { ValidationError } from "../../../../errors";
24
+ import { classifyMime, isAllowedMime, maxBytesFor, PDF_MAGIC, } from "../../../shared/media/limits";
25
+ import { DIGITAL_CONTENT_PREFIX } from "./pool";
26
+ /** Signed-PUT lifetime. Long enough for a slow upload, short enough to matter. */
27
+ const SIGNED_URL_TTL_MS = 15 * 60 * 1000;
28
+ /** Magic bytes are read from the head of the object; 4 KB is ample for every type. */
29
+ const MAGIC_BYTE_WINDOW = 4096;
30
+ /** The content kind a declared MIME maps to. */
31
+ export function contentKindForMime(mime) {
32
+ return classifyMime(mime) === "image" ? "image" : "file";
33
+ }
34
+ export async function signDigitalContentUpload(input) {
35
+ if (!isAllowedMime(input.contentType)) {
36
+ throw new ValidationError(`Unsupported file type: ${input.contentType}`);
37
+ }
38
+ const kind = classifyMime(input.contentType);
39
+ if (kind === "video") {
40
+ // Video as a delivered good is a different product with different economics
41
+ // (egress on a 50 MB file, per download, on the Hobby tier). Refuse loudly
42
+ // rather than accept it and discover the bill.
43
+ throw new ValidationError("Video cannot be delivered as digital content.");
44
+ }
45
+ const max = maxBytesFor(input.contentType);
46
+ if (!(input.size > 0))
47
+ throw new ValidationError("Empty file.");
48
+ if (max !== null && input.size > max) {
49
+ throw new ValidationError("File is too large.");
50
+ }
51
+ /*
52
+ * A RANDOM object name, which is the opposite of the convention everywhere
53
+ * else in this codebase. Public media filenames are content-derived for SEO —
54
+ * and therefore guessable, which is precisely the property that must not hold
55
+ * for a paid good. The private prefix already means Storage's world-read rule
56
+ * cannot be reached through any app route, and an unguessable name means a
57
+ * leaked path is not a leaked catalogue.
58
+ */
59
+ const safeName = input.fileName.replace(/[^\w.\-]+/g, "_").slice(-80) || "asset";
60
+ const storagePath = `${DIGITAL_CONTENT_PREFIX}/${input.productId}/${randomUUID()}-${safeName}`;
61
+ const expires = Date.now() + SIGNED_URL_TTL_MS;
62
+ const [uploadUrl] = await getAdminStorage()
63
+ .bucket()
64
+ .file(storagePath)
65
+ .getSignedUrl({
66
+ version: "v4",
67
+ action: "write",
68
+ expires,
69
+ contentType: input.contentType,
70
+ });
71
+ return {
72
+ uploadUrl,
73
+ storagePath,
74
+ contentKind: contentKindForMime(input.contentType),
75
+ expiresAt: new Date(expires).toISOString(),
76
+ };
77
+ }
78
+ /**
79
+ * Verify an uploaded object and return the facts a pool entry needs.
80
+ *
81
+ * 🛑 THE DECLARED MIME IS NOT EVIDENCE. `finalize` on the public pipeline exists
82
+ * for exactly this reason and returns a structured 422 `MIME_MISMATCH`; the same
83
+ * rule has to hold here, and matters more, because the output is handed to a
84
+ * paying buyer. A mismatch DELETES the object rather than leaving it in the
85
+ * bucket for a later caller to reference.
86
+ */
87
+ export async function finalizeDigitalContentUpload(storagePath, declaredContentType) {
88
+ if (!storagePath.startsWith(`${DIGITAL_CONTENT_PREFIX}/`)) {
89
+ throw new ValidationError("Not a digital-content upload path.");
90
+ }
91
+ const file = getAdminStorage().bucket().file(storagePath);
92
+ const [exists] = await file.exists();
93
+ if (!exists)
94
+ throw new ValidationError("Upload not found.");
95
+ const [meta] = await file.getMetadata();
96
+ const size = Number(meta.size ?? 0);
97
+ const declaredKind = classifyMime(declaredContentType);
98
+ if (!declaredKind) {
99
+ await file.delete({ ignoreNotFound: true });
100
+ throw new ValidationError("Unsupported file type.");
101
+ }
102
+ const max = maxBytesFor(declaredContentType);
103
+ if (size <= 0 || (max !== null && size > max)) {
104
+ await file.delete({ ignoreNotFound: true });
105
+ throw new ValidationError("File is empty or too large.");
106
+ }
107
+ const [head] = await file.download({ start: 0, end: MAGIC_BYTE_WINDOW - 1 });
108
+ let detectedKind = null;
109
+ try {
110
+ const { fileTypeFromBuffer } = await import("file-type");
111
+ const detected = await fileTypeFromBuffer(head);
112
+ detectedKind = detected ? classifyMime(detected.mime) : null;
113
+ if (!detectedKind && head.subarray(0, PDF_MAGIC.length).toString("latin1") === PDF_MAGIC) {
114
+ detectedKind = "pdf";
115
+ }
116
+ }
117
+ catch (err) {
118
+ const normalized = normalizeError(err);
119
+ /*
120
+ * Detection FAILING is treated exactly like detection DISAGREEING — the
121
+ * check below sees `detectedKind === null` and deletes the object. So this
122
+ * catch cannot hide a bad upload; it only records why the prover gave up
123
+ * (a corrupt head, or file-type failing to load). Logging it matters
124
+ * because a systematic failure here would look like "every upload is
125
+ * malformed" to the person uploading.
126
+ */
127
+ serverLogger.warn("digital-content: magic-byte detection failed", {
128
+ error: normalized.message,
129
+ storagePath,
130
+ });
131
+ }
132
+ if (!detectedKind || detectedKind !== declaredKind) {
133
+ await file.delete({ ignoreNotFound: true });
134
+ throw new ValidationError("The uploaded file is not the type it claims to be, so it was discarded.");
135
+ }
136
+ const fileName = storagePath.split("/").pop().replace(/^[0-9a-f-]{36}-/, "");
137
+ return {
138
+ storagePath,
139
+ contentType: declaredContentType,
140
+ fileName,
141
+ contentKind: contentKindForMime(declaredContentType),
142
+ size,
143
+ };
144
+ }
@@ -3,10 +3,13 @@
3
3
  *
4
4
  * Deliberately delegates to `renderBundleOgImage` rather than re-implementing
5
5
  * the layout: the card is the same shape (title, description, cover, item
6
- * count) minus the price, and `BundleOgData.priceLabel` / `.stockStatus` are
7
- * already nullable for exactly that case. Forking it would give two ~150-line
8
- * OG layouts to keep visually in step — the Duplication Framework's
9
- * "same prop surface, same output" consolidate rule.
6
+ * count), and `BundleOgData.stockStatus` is already nullable for the fields a
7
+ * group has no value for. Forking it would give two ~150-line OG layouts to
8
+ * keep visually in step — the Duplication Framework's "same prop surface, same
9
+ * output" consolidate rule.
10
+ *
11
+ * Neither card carries a price any more: an OG image is a public URL with the
12
+ * amount burned into the pixels, which no sign-in gate reaches.
10
13
  */
11
14
  import type { ReactElement } from "react";
12
15
  import type { GroupedListingDocument } from "../../../../features/grouped/schemas/firestore";
@@ -3,10 +3,13 @@
3
3
  *
4
4
  * Deliberately delegates to `renderBundleOgImage` rather than re-implementing
5
5
  * the layout: the card is the same shape (title, description, cover, item
6
- * count) minus the price, and `BundleOgData.priceLabel` / `.stockStatus` are
7
- * already nullable for exactly that case. Forking it would give two ~150-line
8
- * OG layouts to keep visually in step — the Duplication Framework's
9
- * "same prop surface, same output" consolidate rule.
6
+ * count), and `BundleOgData.stockStatus` is already nullable for the fields a
7
+ * group has no value for. Forking it would give two ~150-line OG layouts to
8
+ * keep visually in step — the Duplication Framework's "same prop surface, same
9
+ * output" consolidate rule.
10
+ *
11
+ * Neither card carries a price any more: an OG image is a public URL with the
12
+ * amount burned into the pixels, which no sign-in gate reaches.
10
13
  */
11
14
  import { renderBundleOgImage } from "../bundles/og";
12
15
  import { resolveOgImageUrl } from "../seo/og";
@@ -16,8 +19,6 @@ export function renderGroupedListingOg(doc, opts) {
16
19
  description: doc?.description?.slice(0, 140) ??
17
20
  `A curated group of related listings on ${opts.siteName}.`,
18
21
  coverImageUrl: resolveOgImageUrl(doc?.coverImage ?? null, opts.baseUrl),
19
- // A group has no price of its own — the buyer's selection is the price.
20
- priceLabel: null,
21
22
  itemCount: doc?.productIds?.length ?? null,
22
23
  stockStatus: null,
23
24
  }, opts.siteName);
@@ -17,7 +17,6 @@ import type { ReactElement } from "react";
17
17
  export interface ItemRequestOgData {
18
18
  title: string;
19
19
  opDisplayName?: string | null;
20
- budgetLabel?: string | null;
21
20
  imageUrl?: string | null;
22
21
  status?: "open" | "closed" | "removed" | "approved" | null;
23
22
  replyCount?: number | null;
@@ -1,21 +1,14 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { resolveOgImageUrl } from "../seo/og";
3
- function formatPriceInr(amount) {
4
- return `≤ ₹${amount.toLocaleString("en-IN")}`;
5
- }
6
3
  function normalizeStatus(s) {
7
4
  if (s === "open" || s === "closed" || s === "removed" || s === "approved")
8
5
  return s;
9
6
  return null;
10
7
  }
11
8
  export function renderItemRequestOg(doc, opts) {
12
- const budgetLabel = typeof doc?.maxBudget === "number" && doc.maxBudget > 0
13
- ? formatPriceInr(doc.maxBudget)
14
- : null;
15
9
  return renderItemRequestOgImage({
16
10
  title: doc?.title ?? "Item Request",
17
11
  opDisplayName: doc?.opDisplayName ?? null,
18
- budgetLabel,
19
12
  imageUrl: resolveOgImageUrl(doc?.imageUrls?.[0] ?? null, opts.baseUrl),
20
13
  status: normalizeStatus(doc?.status),
21
14
  replyCount: doc?.replyCount ?? null,
@@ -49,7 +42,7 @@ const STATUS_BADGE = {
49
42
  },
50
43
  };
51
44
  export function renderItemRequestOgImage(data, siteName) {
52
- const { title, opDisplayName, budgetLabel, imageUrl, status, replyCount, category } = data;
45
+ const { title, opDisplayName, imageUrl, status, replyCount, category } = data;
53
46
  const statusBadge = status ? STATUS_BADGE[status] : null;
54
47
  const replyLabel = typeof replyCount === "number" && replyCount >= 0
55
48
  ? `${replyCount} repl${replyCount === 1 ? "y" : "ies"}`
@@ -113,16 +106,7 @@ export function renderItemRequestOgImage(data, siteName) {
113
106
  fontSize: 22,
114
107
  fontWeight: 700,
115
108
  border: `1px solid ${statusBadge.border}`,
116
- }, children: statusBadge.text })), budgetLabel && (_jsx("div", { style: {
117
- display: "inline-flex",
118
- padding: "10px 22px",
119
- borderRadius: 999,
120
- background: "rgba(34, 211, 238, 0.18)",
121
- color: "#cffafe",
122
- fontSize: 22,
123
- fontWeight: 700,
124
- border: "1px solid rgba(34, 211, 238, 0.4)",
125
- }, children: budgetLabel })), replyLabel && (_jsx("div", { style: {
109
+ }, children: statusBadge.text })), replyLabel && (_jsx("div", { style: {
126
110
  display: "inline-flex",
127
111
  padding: "10px 22px",
128
112
  borderRadius: 999,
@@ -3,7 +3,6 @@ import type { ProductDocument } from "../../../../features/products/schemas/fire
3
3
  export interface LiveItemOgData {
4
4
  title: string;
5
5
  species?: string | null;
6
- priceLabel?: string | null;
7
6
  imageUrl?: string | null;
8
7
  }
9
8
  interface LiveItemDocLike {
@@ -1,17 +1,9 @@
1
1
  import { renderOgLayout } from "../seo/og-layout";
2
2
  import { resolveOgImageUrl } from "../seo/og";
3
3
  export function renderLiveItemOg(doc, opts) {
4
- const priceLabel = doc?.price != null
5
- ? new Intl.NumberFormat("en-IN", {
6
- style: "currency",
7
- currency: doc.currency ?? "INR",
8
- maximumFractionDigits: 0,
9
- }).format(doc.price)
10
- : null;
11
4
  return renderLiveItemOgImage({
12
5
  title: doc?.title ?? "Live Listing",
13
6
  species: doc?.liveItem?.species ?? null,
14
- priceLabel,
15
7
  imageUrl: resolveOgImageUrl(doc?.mainImage || doc?.images?.[0] || null, opts.baseUrl),
16
8
  }, opts.siteName);
17
9
  }
@@ -21,7 +13,7 @@ export function renderLiveItemOgImage(data, siteName) {
21
13
  subtitle: data.species ?? undefined,
22
14
  imageUrl: data.imageUrl,
23
15
  siteName: `${siteName} · Live Listing`,
24
- accentSlot: data.priceLabel,
16
+ accentSlot: null,
25
17
  theme: { background: "#052e16", accentColor: "#86efac" },
26
18
  });
27
19
  }
@@ -4,18 +4,13 @@
4
4
  */
5
5
  import { renderOgLayout } from "../seo/og-layout";
6
6
  import { resolveOgImageUrl } from "../seo/og";
7
- function formatPriceInr(amount, currency) {
8
- return new Intl.NumberFormat("en-IN", {
9
- style: "currency",
10
- currency,
11
- maximumFractionDigits: 0,
12
- }).format(amount);
13
- }
14
7
  export function renderPrizeDrawOg(doc, opts) {
15
8
  const entryPrice = doc?.pricePerEntry ?? doc?.price ?? null;
16
- const pricePerEntryLabel = typeof entryPrice === "number" && entryPrice > 0
17
- ? `${formatPriceInr(entryPrice, doc?.currency ?? "INR")} / entry`
18
- : "Free entry";
9
+ // "Free entry" is kept — it is a fact about the draw, not a disclosed amount,
10
+ // and it is the half of this chip that actually earns a click. The priced case
11
+ // renders nothing: an OG image is a public URL with the value burned into the
12
+ // pixels, which no sign-in gate or paywall markup can reach.
13
+ const pricePerEntryLabel = typeof entryPrice === "number" && entryPrice > 0 ? null : "Free entry";
19
14
  const max = doc?.prizeMaxEntries ?? null;
20
15
  const current = doc?.prizeCurrentEntries ?? 0;
21
16
  const entriesLabel = typeof max === "number" && max > 0
@@ -9,11 +9,14 @@ export function renderProductOg(doc, opts) {
9
9
  }, opts.siteName);
10
10
  }
11
11
  export function renderProductOgImage(data, siteName) {
12
- const priceLabel = data.price ? `₹${data.price.toLocaleString("en-IN")}` : null;
12
+ // 🛑 No money in the accent slot. An OG image is a PUBLIC image URL anyone can
13
+ // fetch directly, with the amount burned into the pixels — no gate reaches it,
14
+ // and no paywall markup describes it. The auction and pre-order OGs already
15
+ // use a non-money accent (end date / release date); this matches them.
13
16
  return renderOgLayout({
14
17
  title: data.title,
15
18
  imageUrl: data.imageUrl,
16
19
  siteName,
17
- accentSlot: priceLabel,
20
+ accentSlot: null,
18
21
  });
19
22
  }
@@ -62,8 +62,16 @@ function staticPages(baseUrl) {
62
62
  const page = (path, changeFreq, priority) => ({ url: `${baseUrl}${path}`, lastModified: new Date(), changeFrequency: changeFreq, priority });
63
63
  return [
64
64
  page(String(ROUTES.HOME), "daily", 1.0),
65
- page(String(ROUTES.PUBLIC.PRODUCTS), "hourly", 0.9),
66
- page(String(ROUTES.PUBLIC.AUCTIONS), "hourly", 0.9),
65
+ // "daily", not "hourly". These two are the highest-priority URLs in the
66
+ // sitemap AND the entry points into the deepest faceted URL space
67
+ // (tab x sortKey x page), so an hourly hint invites recrawl of that whole
68
+ // surface at a rate a catalogue this size cannot justify — new listings
69
+ // arrive daily, not hourly. `changeFrequency` is only a hint and Google has
70
+ // said it largely disregards it, so treat this as removing a bad signal
71
+ // rather than as a lever: priority is unchanged and neither page loses
72
+ // indexability. 2026-09-14, Vercel usage reduction.
73
+ page(String(ROUTES.PUBLIC.PRODUCTS), "daily", 0.9),
74
+ page(String(ROUTES.PUBLIC.AUCTIONS), "daily", 0.9),
67
75
  page(String(ROUTES.PUBLIC.CATEGORIES), "weekly", 0.8),
68
76
  page(String(ROUTES.PUBLIC.BRANDS), "weekly", 0.7),
69
77
  page(String(ROUTES.PUBLIC.BLOG), "daily", 0.7),
@@ -4,7 +4,6 @@ export interface BaseListingClientShape {
4
4
  title: string;
5
5
  description: string;
6
6
  price: number;
7
- priceLabel: string;
8
7
  currency: string;
9
8
  mainImage: string | null;
10
9
  images: string[];
@@ -15,5 +14,5 @@ export interface BaseListingClientShape {
15
14
  status: ProductDocument["status"];
16
15
  createdAt: string;
17
16
  }
18
- /** Maps the 11 shared fields from a raw Firestore ProductDocument. */
17
+ /** Maps the shared fields from a raw Firestore ProductDocument. */
19
18
  export declare function mapBaseListingFields(doc: ProductDocument): BaseListingClientShape;
@@ -1,12 +1,14 @@
1
- import { formatCurrency } from "../../../../utils/number.formatter";
2
- /** Maps the 11 shared fields from a raw Firestore ProductDocument. */
1
+ /** Maps the shared fields from a raw Firestore ProductDocument. */
3
2
  export function mapBaseListingFields(doc) {
4
3
  return {
5
4
  id: doc.id,
6
5
  title: doc.title,
7
6
  description: doc.description,
8
7
  price: doc.price,
9
- priceLabel: formatCurrency(doc.price, doc.currency ?? "INR"),
8
+ // 🛑 No pre-formatted money string here. A `priceLabel` baked server-side
9
+ // is a ready-to-print amount that any component can render directly,
10
+ // bypassing <GatedPrice> entirely — and it had zero readers, so it was
11
+ // pure exposure. Clients format from `price` through the gate.
10
12
  currency: doc.currency ?? "INR",
11
13
  mainImage: doc.mainImage || doc.images?.[0] || null,
12
14
  images: doc.images ?? [],