@mohasinac/appkit 4.47.0 → 4.48.1

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 (173) hide show
  1. package/dist/.build-stamp.json +4 -4
  2. package/dist/_internal/server/features/checkout/expand-order-items.js +3 -0
  3. package/dist/_internal/server/features/messages/actions.d.ts +68 -0
  4. package/dist/_internal/server/features/messages/actions.js +249 -0
  5. package/dist/_internal/server/features/parts/buildable.d.ts +47 -0
  6. package/dist/_internal/server/features/parts/buildable.js +159 -0
  7. package/dist/_internal/server/features/products/list-public.d.ts +17 -0
  8. package/dist/_internal/server/features/products/list-public.js +92 -5
  9. package/dist/_internal/server/features/products/og.d.ts +7 -0
  10. package/dist/_internal/server/features/products/og.js +38 -0
  11. package/dist/_internal/server/features/site-settings/adapters.d.ts +1 -1
  12. package/dist/_internal/server/features/site-settings/adapters.js +6 -0
  13. package/dist/_internal/server/jobs/core/cleanupRtdbEvents.js +28 -0
  14. package/dist/_internal/server/jobs/core/notificationPrune.js +87 -3
  15. package/dist/_internal/server/jobs/core/priceIndexRollup.js +36 -1
  16. package/dist/_internal/shared/actions/action-registry.js +8 -0
  17. package/dist/_internal/shared/features/notifications/action-url.js +8 -0
  18. package/dist/_internal/shared/features/notifications/email-eligibility.js +3 -0
  19. package/dist/_internal/shared/features/site-settings/types.d.ts +4 -0
  20. package/dist/client.d.ts +10 -0
  21. package/dist/client.js +8 -0
  22. package/dist/constants/api-endpoints.d.ts +48 -0
  23. package/dist/constants/api-endpoints.js +30 -0
  24. package/dist/constants/notification-types.d.ts +1 -1
  25. package/dist/features/about/components/GstExplainerView.d.ts +28 -0
  26. package/dist/features/about/components/GstExplainerView.js +59 -0
  27. package/dist/features/about/components/GuidesHubView.d.ts +28 -0
  28. package/dist/features/about/components/GuidesHubView.js +58 -0
  29. package/dist/features/about/components/PublicProfileView.js +5 -0
  30. package/dist/features/about/components/SpotAFakeView.d.ts +29 -0
  31. package/dist/features/about/components/SpotAFakeView.js +49 -0
  32. package/dist/features/admin/actions/notification-actions.js +4 -0
  33. package/dist/features/admin/schemas/firestore.d.ts +13 -3
  34. package/dist/features/admin/schemas/firestore.js +8 -0
  35. package/dist/features/blog/repository/blog.repository.js +21 -7
  36. package/dist/features/categories/api/route.js +41 -1
  37. package/dist/features/categories/components/BreadcrumbTrail.d.ts +39 -17
  38. package/dist/features/categories/components/BreadcrumbTrail.js +46 -50
  39. package/dist/features/categories/components/CategoryDetailPageView.js +92 -38
  40. package/dist/features/categories/components/ContextualTrail.d.ts +62 -0
  41. package/dist/features/categories/components/ContextualTrail.js +81 -0
  42. package/dist/features/categories/components/LastCategoryBeacon.d.ts +18 -0
  43. package/dist/features/categories/components/LastCategoryBeacon.js +33 -0
  44. package/dist/features/categories/repository/categories.repository.d.ts +21 -0
  45. package/dist/features/categories/repository/categories.repository.js +36 -3
  46. package/dist/features/categories/repository/price-index.repository.d.ts +25 -0
  47. package/dist/features/email/notification-templates.js +4 -0
  48. package/dist/features/events/repository/events.repository.js +10 -3
  49. package/dist/features/faq/constants/read-caps.d.ts +29 -0
  50. package/dist/features/faq/constants/read-caps.js +29 -0
  51. package/dist/features/faq/repository/faqs.repository.d.ts +0 -2
  52. package/dist/features/faq/repository/faqs.repository.js +19 -16
  53. package/dist/features/homepage/components/AdSlot.js +10 -4
  54. package/dist/features/homepage/hooks/useActiveAd.d.ts +18 -1
  55. package/dist/features/homepage/hooks/useActiveAd.js +42 -2
  56. package/dist/features/homepage/lib/section-renderer.js +12 -2
  57. package/dist/features/layout/AppLayoutShell.d.ts +4 -1
  58. package/dist/features/layout/AppLayoutShell.js +2 -2
  59. package/dist/features/layout/TitleBarLayout.d.ts +4 -1
  60. package/dist/features/layout/TitleBarLayout.js +8 -4
  61. package/dist/features/media/AvatarUpload.js +8 -1
  62. package/dist/features/media/MediaAvatar.js +1 -1
  63. package/dist/features/media/MediaImage.js +3 -1
  64. package/dist/features/media/MediaLightbox.js +4 -1
  65. package/dist/features/media/MediaPickerModal.js +6 -1
  66. package/dist/features/media/MediaVideo.js +3 -1
  67. package/dist/features/messages/components/ConversationThreadView.d.ts +3 -0
  68. package/dist/features/messages/components/ConversationThreadView.js +75 -0
  69. package/dist/features/messages/components/ConversationsInboxView.d.ts +1 -0
  70. package/dist/features/messages/components/ConversationsInboxView.js +39 -0
  71. package/dist/features/messages/components/MessageSellerButton.d.ts +17 -0
  72. package/dist/features/messages/components/MessageSellerButton.js +33 -0
  73. package/dist/features/messages/hooks/useConversationChannel.d.ts +24 -0
  74. package/dist/features/messages/hooks/useConversationChannel.js +89 -0
  75. package/dist/features/messages/hooks/useMessages.d.ts +347 -0
  76. package/dist/features/messages/hooks/useMessages.js +113 -0
  77. package/dist/features/messages/repository/conversations.repository.d.ts +44 -0
  78. package/dist/features/messages/repository/conversations.repository.js +173 -0
  79. package/dist/features/messages/repository/messages.repository.d.ts +42 -0
  80. package/dist/features/messages/repository/messages.repository.js +142 -0
  81. package/dist/features/messages/schemas/firestore.d.ts +99 -0
  82. package/dist/features/messages/schemas/firestore.js +46 -0
  83. package/dist/features/orders/repository/orders.repository.js +21 -7
  84. package/dist/features/orders/schemas/firestore.d.ts +11 -0
  85. package/dist/features/products/components/CityCollectiblesView.d.ts +31 -0
  86. package/dist/features/products/components/CityCollectiblesView.js +82 -0
  87. package/dist/features/products/components/MarketPriceAnchor.d.ts +45 -0
  88. package/dist/features/products/components/MarketPriceAnchor.js +62 -0
  89. package/dist/features/products/components/ProductDetailPageView.js +71 -5
  90. package/dist/features/products/constants/meetup-cities.d.ts +47 -0
  91. package/dist/features/products/constants/meetup-cities.js +49 -0
  92. package/dist/features/products/repository/category-assignment.d.ts +82 -11
  93. package/dist/features/products/repository/category-assignment.js +93 -12
  94. package/dist/features/products/repository/products.repository.d.ts +12 -1
  95. package/dist/features/products/repository/products.repository.js +66 -34
  96. package/dist/features/products/schemas/firestore.d.ts +24 -2
  97. package/dist/features/products/schemas/firestore.js +13 -1
  98. package/dist/features/products/schemas/index.d.ts +2 -2
  99. package/dist/features/promotions/repository/coupons.repository.js +11 -4
  100. package/dist/features/reviews/repository/reviews.repository.js +11 -4
  101. package/dist/features/scams/repository/scammer.repository.js +21 -7
  102. package/dist/features/seller/components/SellerProductShell.d.ts +1 -1
  103. package/dist/features/seller/components/SellerProductShell.js +2 -2
  104. package/dist/features/seller/repository/offer.repository.js +21 -6
  105. package/dist/features/seller/schemas/product-form.d.ts +3 -3
  106. package/dist/features/seller/schemas/product-form.js +1 -1
  107. package/dist/features/server-errors/repository/server-errors.repository.js +11 -0
  108. package/dist/features/server-errors/schemas/firestore.d.ts +17 -0
  109. package/dist/features/server-errors/schemas/firestore.js +2 -0
  110. package/dist/features/store-extensions/repository/store-extensions.repositories.d.ts +17 -1
  111. package/dist/features/store-extensions/repository/store-extensions.repositories.js +31 -0
  112. package/dist/features/store-extensions/schemas/firestore.d.ts +1 -1
  113. package/dist/features/store-extensions/schemas/firestore.js +4 -0
  114. package/dist/features/stores/repository/store.repository.js +11 -3
  115. package/dist/features/tester/seed-data/authored/community-support__messages.d.ts +2 -0
  116. package/dist/features/tester/seed-data/authored/community-support__messages.js +200 -0
  117. package/dist/features/tester/seed-data/authored/index.js +2 -0
  118. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +64 -1
  119. package/dist/index.d.ts +8 -2
  120. package/dist/index.js +1 -1
  121. package/dist/next/routing/route-map.d.ts +14 -0
  122. package/dist/next/routing/route-map.js +26 -0
  123. package/dist/providers/db-firebase/rtdb-paths.d.ts +8 -0
  124. package/dist/providers/db-firebase/rtdb-paths.js +8 -0
  125. package/dist/repositories/index.d.ts +1 -0
  126. package/dist/repositories/index.js +4 -0
  127. package/dist/schemas/registry.d.ts +3 -3
  128. package/dist/security/index.d.ts +1 -1
  129. package/dist/security/index.js +1 -1
  130. package/dist/security/pii-schemas.d.ts +17 -0
  131. package/dist/security/pii-schemas.js +15 -7
  132. package/dist/seed/_helpers/category-content-generated.d.ts +3 -3
  133. package/dist/seed/_helpers/category-content-generated.js +23 -7
  134. package/dist/seed/_helpers/category-models.d.ts +1 -1
  135. package/dist/seed/_helpers/category-models.js +25 -1
  136. package/dist/seed/_helpers/category-names.d.ts +54 -0
  137. package/dist/seed/_helpers/category-names.js +118 -0
  138. package/dist/seed/_helpers/character-features.d.ts +1 -1
  139. package/dist/seed/_helpers/character-features.js +5 -4
  140. package/dist/seed/_helpers/final-sale-fixtures.js +1 -1
  141. package/dist/seed/categories-seed-data.js +55 -1
  142. package/dist/seed/grouped-listings-seed-data.js +2 -2
  143. package/dist/seed/products-art-seed-data.js +2 -1
  144. package/dist/seed/products-auctions-seed-data.js +42 -22
  145. package/dist/seed/products-classifieds-seed-data.js +21 -3
  146. package/dist/seed/products-digital-codes-seed-data.js +2 -1
  147. package/dist/seed/products-live-items-seed-data.js +2 -1
  148. package/dist/seed/products-preorders-seed-data.js +26 -11
  149. package/dist/seed/products-prize-draws-seed-data.js +8 -1
  150. package/dist/seed/products-standard-seed-data.js +26 -25
  151. package/dist/seed/products-stickers-seed-data.js +12 -11
  152. package/dist/server.d.ts +8 -0
  153. package/dist/server.js +15 -0
  154. package/dist/styles.css +113 -1
  155. package/dist/tailwind-utilities.css +1 -1
  156. package/dist/tokens/themes/sunset.js +17 -0
  157. package/dist/tokens/tokens.css +5 -0
  158. package/dist/ui/components/Breadcrumb.d.ts +8 -1
  159. package/dist/ui/components/Breadcrumb.js +2 -2
  160. package/dist/ui/components/Breadcrumb.style.css +37 -0
  161. package/dist/ui/components/Details.d.ts +11 -1
  162. package/dist/ui/components/Details.js +2 -2
  163. package/dist/ui/components/ImageMosaic.d.ts +43 -0
  164. package/dist/ui/components/ImageMosaic.js +46 -0
  165. package/dist/ui/components/ImageMosaic.style.css +69 -0
  166. package/dist/ui/components/SplitTile.d.ts +34 -0
  167. package/dist/ui/components/SplitTile.js +28 -0
  168. package/dist/ui/components/index.style.css +1 -0
  169. package/dist/ui/index.d.ts +4 -0
  170. package/dist/ui/index.js +4 -0
  171. package/dist/utils/search-txt-query.d.ts +77 -0
  172. package/dist/utils/search-txt-query.js +50 -28
  173. package/package.json +1 -1
@@ -41,6 +41,7 @@ import { PRODUCT_FIELDS } from "../../../../constants/field-names.js";
41
41
  import { TABLE_KEYS } from "../../../../constants/table-keys.js";
42
42
  import { sortBy } from "../../../../constants/sort.js";
43
43
  import { SIEVE_OP, sieveAnd, sieveFilter, expandSieveParam, } from "../../../../utils/sieve-builder.js";
44
+ import { planSearchTxt } from "../../../../utils/search-txt-query.js";
44
45
  import { ALL_LISTING_TYPES } from "../../../shared/listing-types/feature-flags.js";
45
46
  import { isListingRowAvailable, unavailableClausesFor, } from "../../../shared/listing-types/_registry.js";
46
47
  import { AVAILABILITY_VALUES, isAvailabilityFilter, } from "../../../../constants/field-names.js";
@@ -241,7 +242,15 @@ export function parsePublicProductParams(params, defaults) {
241
242
  * index in an order nobody declares — it fails with FAILED_PRECONDITION, which
242
243
  * upstream reads as a bare "no results".
243
244
  */
244
- function buildFirestoreSafeFilters(input) {
245
+ /**
246
+ * The Firestore-safe Sieve filter string.
247
+ *
248
+ * `omitCategory` exists for the one query Firestore cannot express as written:
249
+ * a search term inside a category. Both are array operators and Firestore
250
+ * permits one, so the category leaves `filters` and travels as `categoriesIn`,
251
+ * where the repository refines it in memory over the search window.
252
+ */
253
+ function buildFirestoreSafeFilters(input, opts) {
245
254
  const parts = [];
246
255
  // Published-only unless the caller explicitly asks otherwise. Without this
247
256
  // default, any refetch that omits `status` leaks drafts (Root Cause #30).
@@ -253,7 +262,7 @@ function buildFirestoreSafeFilters(input) {
253
262
  if (types.length > 0) {
254
263
  parts.push(sieveFilter(PRODUCT_FIELDS.LISTING_TYPE, SIEVE_OP.EQ, types.join("|")));
255
264
  }
256
- if (input.category) {
265
+ if (input.category && !opts?.omitCategory) {
257
266
  parts.push(expandSieveParam(PRODUCT_FIELDS.CATEGORY_SLUGS, input.category, SIEVE_OP.CONTAINS));
258
267
  }
259
268
  if (input.brand)
@@ -373,7 +382,15 @@ async function defaultExecutor(query) {
373
382
  // Without this second argument the repository's searchTxt read path is
374
383
  // unreachable on the default executor, which is what every SSR listing view
375
384
  // uses. It was implemented and simply never called.
376
- query.search ? { search: query.search } : undefined);
385
+ //
386
+ // `categoriesIn` rides along for the one query Firestore cannot express as
387
+ // two array clauses — a search inside a category. See `PublicProductQuery`.
388
+ query.search || query.categoriesIn?.length
389
+ ? {
390
+ ...(query.search ? { search: query.search } : {}),
391
+ ...(query.categoriesIn?.length ? { categoriesIn: [...query.categoriesIn] } : {}),
392
+ }
393
+ : undefined);
377
394
  return {
378
395
  items: result.items,
379
396
  total: result.total,
@@ -415,6 +432,38 @@ export async function listPublicProducts(input, opts) {
415
432
  const sorts = input.sorts || DEFAULT_SORTS;
416
433
  const requestedSortField = sorts.replace(/^-/, "");
417
434
  const sortDesc = sorts.startsWith("-");
435
+ /*
436
+ * 🛑 THE EMPTY GUARD, HERE RATHER THAN ONLY IN THE REPOSITORY — measured
437
+ * live: `/api/products?q=!!!` returned the WHOLE CATALOGUE (50+, capped),
438
+ * which is A2's bug still reachable in production.
439
+ *
440
+ * `productRepository.list` has carried this guard for a while, but this
441
+ * function prefers the colocated `listingProcessor` Function on the one
442
+ * route that serves `/api/products`, and that Function has no such guard: it
443
+ * is handed a filter string with no search clause in it and answers with an
444
+ * unfiltered page. So the rule has to live where BOTH executors pass, which
445
+ * is here.
446
+ *
447
+ * `parseSearchTxtQuery` normalises punctuation away, so `"!!!"`, `"—"` and
448
+ * `"@#$"` all reduce to no usable token — and a query that produced none
449
+ * must return NOTHING. Falling through to the other filters is what turns a
450
+ * one-character search into "here is everything".
451
+ */
452
+ if (planSearchTxt(input.q).empty) {
453
+ return {
454
+ items: [],
455
+ total: 0,
456
+ page: 1,
457
+ pageSize: 0,
458
+ totalPages: 0,
459
+ hasMore: false,
460
+ cursor: null,
461
+ // Not truncated — this is an exact zero, not a saturated window.
462
+ truncated: false,
463
+ filters: "",
464
+ sorts: input.sorts || DEFAULT_SORTS,
465
+ };
466
+ }
418
467
  const availability = input.availability ?? AVAILABILITY_VALUES.ALL;
419
468
  const wantAvailable = availability === AVAILABILITY_VALUES.AVAILABLE;
420
469
  const wantUnavailable = availability === AVAILABILITY_VALUES.UNAVAILABLE;
@@ -423,7 +472,43 @@ export async function listPublicProducts(input, opts) {
423
472
  // Firestore DOES accept one inequality when the query sorts by that same
424
473
  // field, so a matching date facet can still be pushed down.
425
474
  const canPushDate = hasDateRange && !wantAvailable && requestedSortField === dateField;
426
- const safeFilters = buildFirestoreSafeFilters(input);
475
+ /*
476
+ * 🛑 A SEARCH INSIDE A CATEGORY CANNOT RUN ON THE REMOTE EXECUTOR, and
477
+ * measured against the live container it returned **zero rows**, not a wrong
478
+ * set — which reads as "this category has nothing matching" and is the most
479
+ * convincing way to be wrong.
480
+ *
481
+ * The reason is Firestore's one-array-operator rule meeting two array
482
+ * clauses: the category travels inside `filters` as `categorySlugs @= …`
483
+ * (array-contains) and the search term becomes a second `array-contains` on
484
+ * `searchTxt` inside the repository. One of the two has to be refined in
485
+ * memory, and the colocated Function CANNOT do that — it is handed a filter
486
+ * string and returns a page.
487
+ *
488
+ * So when both are present this query runs on the DEFAULT executor, which
489
+ * holds the repository's refine (`beginSearchWindow` + the `categoriesIn`
490
+ * predicate). Measured live before the fix: `?q=dragoon` → 30,
491
+ * `?category=category-spinning-tops` → 50+, and the two together → 0.
492
+ *
493
+ * 🛑 The alternative — moving the category OUT of `filters` so the Function
494
+ * only sees the search — is strictly worse: the Function would then return
495
+ * the UNSCOPED catalogue and the page would silently widen instead of
496
+ * silently emptying. Same family as Root Cause #85: one handler reachable
497
+ * through two paths with different capabilities, where an env var decides
498
+ * which one answers.
499
+ */
500
+ const needsMemoryRefine = Boolean(input.q && input.category);
501
+ const executor = needsMemoryRefine ? defaultExecutor : (opts?.executor ?? defaultExecutor);
502
+ /*
503
+ * The category ids, as the repository's `categoriesIn` option rather than as
504
+ * a Sieve clause. Products carry their FULL ancestor chain, so a page passing
505
+ * its own id still matches its whole subtree — the refine and the pushdown
506
+ * agree by construction.
507
+ */
508
+ const categoriesIn = needsMemoryRefine
509
+ ? input.category.split("|").filter(Boolean)
510
+ : undefined;
511
+ const safeFilters = buildFirestoreSafeFilters(input, { omitCategory: needsMemoryRefine });
427
512
  const dateClauses = [
428
513
  ...(canPushDate && input.dateFrom
429
514
  ? [sieveFilter(dateField, SIEVE_OP.GTE, input.dateFrom)]
@@ -432,7 +517,6 @@ export async function listPublicProducts(input, opts) {
432
517
  ? [sieveFilter(dateField, SIEVE_OP.LTE, input.dateTo)]
433
518
  : []),
434
519
  ];
435
- const executor = opts?.executor ?? defaultExecutor;
436
520
  const types = asListingTypes(input.listingTypes);
437
521
  // ── Path B: one type whose unavailability IS a pushable inequality, and the
438
522
  // caller is already sorting by that field. Ended auctions sorted by end
@@ -465,6 +549,7 @@ export async function listPublicProducts(input, opts) {
465
549
  pageSize,
466
550
  cursor: input.cursor ?? null,
467
551
  search: input.q,
552
+ categoriesIn,
468
553
  });
469
554
  if (!result)
470
555
  return null;
@@ -502,6 +587,7 @@ export async function listPublicProducts(input, opts) {
502
587
  pageSize: PUBLIC_PRODUCT_MAX_PAGE_SIZE,
503
588
  cursor: null,
504
589
  search: input.q,
590
+ categoriesIn,
505
591
  });
506
592
  }));
507
593
  // One failed branch must not blank the whole scope — but it is already
@@ -617,6 +703,7 @@ export async function listPublicProducts(input, opts) {
617
703
  pageSize: inMemory ? windowSize : pageSize,
618
704
  cursor: inMemory ? null : (input.cursor ?? null),
619
705
  search: input.q,
706
+ categoriesIn,
620
707
  });
621
708
  if (!result)
622
709
  return null;
@@ -18,4 +18,11 @@ export declare function renderProductOg(doc: ProductDocLike | null | undefined,
18
18
  baseUrl?: string;
19
19
  }): ReactElement;
20
20
  export declare function renderProductOgImage(data: ProductOgData, siteName: string): ReactElement;
21
+ export interface CityOgData {
22
+ city: string;
23
+ state: string;
24
+ /** Exact listing count, or `null` when the read saturated its cap. */
25
+ listingCount: number | null;
26
+ }
27
+ export declare function renderCityOg(data: CityOgData, siteName: string): ReactElement;
21
28
  export {};
@@ -1,3 +1,4 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
2
  import { renderOgLayout } from "../seo/og-layout.js";
2
3
  import { resolveOgImageUrl } from "../seo/og.js";
3
4
  /** High-level OG renderer — accepts the raw product document from `getProductForDetail`. */
@@ -20,3 +21,40 @@ export function renderProductOgImage(data, siteName) {
20
21
  accentSlot: null,
21
22
  });
22
23
  }
24
+ export function renderCityOg(data, siteName) {
25
+ const { city, state, listingCount } = data;
26
+ const sub = listingCount === null
27
+ ? "Classified listings — meet the seller, inspect, pay in person"
28
+ : listingCount === 0
29
+ ? "No classified listings here right now — everything on site ships nationwide"
30
+ : `${listingCount} classified listing${listingCount === 1 ? "" : "s"} — meet the seller, inspect, pay in person`;
31
+ return (_jsxs("div", { style: {
32
+ display: "flex",
33
+ width: "100%",
34
+ height: "100%",
35
+ background: "#0f172a",
36
+ fontFamily: "sans-serif",
37
+ position: "relative",
38
+ overflow: "hidden",
39
+ alignItems: "center",
40
+ justifyContent: "center",
41
+ }, children: [_jsx("div", { style: {
42
+ position: "absolute",
43
+ inset: 0,
44
+ background: "linear-gradient(135deg, #0f172a 0%, #1e293b 100%)",
45
+ } }), _jsxs("div", { style: {
46
+ position: "relative",
47
+ display: "flex",
48
+ flexDirection: "column",
49
+ alignItems: "center",
50
+ gap: 24,
51
+ padding: "60px",
52
+ textAlign: "center",
53
+ }, children: [_jsx("div", { style: {
54
+ fontSize: 18,
55
+ color: "#94a3b8",
56
+ fontWeight: 500,
57
+ letterSpacing: 2,
58
+ textTransform: "uppercase",
59
+ }, children: siteName }), _jsx("div", { style: { fontSize: 68, fontWeight: 800, color: "#f1f5f9", lineHeight: 1.1 }, children: city }), _jsx("div", { style: { fontSize: 28, color: "#cbd5e1", fontWeight: 600 }, children: state }), _jsx("div", { style: { fontSize: 24, color: "#94a3b8", maxWidth: 820, lineHeight: 1.5 }, children: sub })] })] }));
60
+ }
@@ -37,7 +37,7 @@ import type { PublicSiteSettings } from "../../../shared/features/site-settings/
37
37
  * was traced to a call site; anything without one belongs in the private list,
38
38
  * because an unused public field is pure attack surface.
39
39
  */
40
- export declare const PUBLIC_SITE_SETTINGS_FIELDS: readonly ["contact", "payment", "listings", "notificationChannels", "announcementBar", "navConfig", "actionConfig", "background", "watermark", "disabledRoutes"];
40
+ export declare const PUBLIC_SITE_SETTINGS_FIELDS: readonly ["contact", "payment", "listings", "notificationChannels", "announcementBar", "navConfig", "actionConfig", "background", "watermark", "disabledRoutes", "chat"];
41
41
  /** Everything else, with the reason it stays server-side. */
42
42
  export declare const PRIVATE_SITE_SETTINGS_FIELDS: readonly ["id", "createdAt", "updatedAt", "credentials", "emailSettings", "legalPages", "commissions", "emi", "gst", "laborRate", "adSettings", "integrations", "platformLimits", "auctionConfig", "siteName", "tagline", "motto", "logo", "favicon", "seo", "socialLinks", "features", "shipping", "returns", "faq", "aboutContent", "navbarConfig", "footerConfig", "theme", "featuredResults", "messaging"];
43
43
  export interface ToPublicSiteSettingsOptions {
@@ -33,6 +33,10 @@ export const PUBLIC_SITE_SETTINGS_FIELDS = [
33
33
  * alternative is putting a per-request Firestore read back on the Edge.
34
34
  */
35
35
  "disabledRoutes",
36
+ // D9 — a signed-out visitor's listing/detail view needs to know whether to
37
+ // render a "Message seller" CTA at all. Narrowed to `enabled` only; there is
38
+ // nothing else in the group.
39
+ "chat",
36
40
  ];
37
41
  /** Everything else, with the reason it stays server-side. */
38
42
  export const PRIVATE_SITE_SETTINGS_FIELDS = [
@@ -106,5 +110,7 @@ export function toPublicSiteSettings(doc, opts) {
106
110
  // and leave the next person wondering whether the projection ran at all.
107
111
  disabledRoutes: doc.disabledRoutes ?? [],
108
112
  effectiveWatermark: opts?.effectiveWatermark,
113
+ // D9 — undefined reads as OFF, never a default-true `??`.
114
+ chat: { enabled: doc.chat?.enabled === true },
109
115
  };
110
116
  }
@@ -13,6 +13,9 @@ const BULK_STALE_MS = 15 * 60 * 1000;
13
13
  * exist to prevent.
14
14
  */
15
15
  const EMAIL_STALE_MS = 60 * 60 * 1000;
16
+ /** Long enough for an open thread to notice a ping before it prunes; the
17
+ * message itself lives in Firestore regardless — this only clears the ping. */
18
+ const CHAT_STALE_MS = 15 * 60 * 1000;
16
19
  const JOBS_TTL_DAYS = 30;
17
20
  export async function runCleanupRtdbEvents(ctx) {
18
21
  ctx.logger.info("Starting RTDB events cleanup");
@@ -108,6 +111,31 @@ export async function runCleanupRtdbEvents(ctx) {
108
111
  void normalizeError(emailErr);
109
112
  ctx.logger.error("Email events cleanup failed (non-fatal)", emailErr);
110
113
  }
114
+ /*
115
+ * D9 — chat new-message pings. Its own file says it: "an unpruned RTDB
116
+ * channel is precisely the thing that quietly accumulates… Do not make it
117
+ * the second" (see the email block above, which is the first). The message
118
+ * content never lived here — only a ping — so pruning this touches nothing
119
+ * a thread needs; the Firestore conversation/message documents are handled
120
+ * by `notificationPrune`'s retention stages, entirely separately.
121
+ */
122
+ try {
123
+ const chatSnap = await rtdb.ref(RTDB_PATHS.CHAT_EVENTS).get();
124
+ if (chatSnap.exists()) {
125
+ const allChatEvents = chatSnap.val();
126
+ const staleChatIds = Object.entries(allChatEvents)
127
+ .filter(([, node]) => (node.updatedAt ?? 0) < now - CHAT_STALE_MS)
128
+ .map(([id]) => id);
129
+ if (staleChatIds.length > 0) {
130
+ await Promise.all(staleChatIds.map((id) => rtdb.ref(`${RTDB_PATHS.CHAT_EVENTS}/${id}`).remove()));
131
+ ctx.logger.info("Chat event pings removed", { count: staleChatIds.length });
132
+ }
133
+ }
134
+ }
135
+ catch (chatErr) {
136
+ void normalizeError(chatErr);
137
+ ctx.logger.error("Chat events cleanup failed (non-fatal)", chatErr);
138
+ }
111
139
  try {
112
140
  const staleJobRefs = await jobsRepository.getStaleFinishedRefs(JOBS_TTL_DAYS);
113
141
  if (staleJobRefs.length > 0) {
@@ -1,13 +1,97 @@
1
+ import { normalizeError } from "../../../../errors/normalize.js";
1
2
  import { notificationRepository } from "../../../../repositories/index.js";
3
+ import { conversationsRepository } from "../../../../features/messages/repository/conversations.repository.js";
4
+ import { CONVERSATION_ACTIVE_DAYS, CONVERSATION_ARCHIVE_DAYS, } from "../../../../features/messages/schemas/firestore.js";
5
+ import { deleteAllMessages, listAllAttachmentUrls, } from "../../../../features/messages/repository/messages.repository.js";
6
+ import { mediaAssetsRepository } from "../../../../features/media/repository/media-assets.repository.js";
7
+ import { getAdminStorageLite } from "../../../../providers/db-firebase/admin-storage-lite.js";
2
8
  import { NOTIFICATION_TTL_DAYS } from "../handlers/messages.js";
3
9
  import { batchDelete } from "../handlers/_helpers.js";
10
+ const RETENTION_BATCH_LIMIT = 100;
11
+ const PROXY_URL_PREFIX = "/media/";
12
+ /**
13
+ * Delete a chat attachment's STORAGE OBJECT, then its `mediaAssets` doc.
14
+ *
15
+ * 🛑 Attachments die BEFORE the Firestore conversation document — the same
16
+ * class of defect as the missing TTL that got the feature deleted the first
17
+ * time: `mediaTmpCleanup` only sweeps `tmp/`, so a finalized `media/*` object
18
+ * would survive this prune as a permanent orphan if the document went first
19
+ * and nothing remembered which assets belonged to it.
20
+ */
21
+ async function deleteAttachmentByProxyUrl(ctx, url) {
22
+ if (!url.startsWith(PROXY_URL_PREFIX))
23
+ return;
24
+ const shortId = url.slice(PROXY_URL_PREFIX.length);
25
+ if (shortId.includes("/"))
26
+ return;
27
+ try {
28
+ const asset = await mediaAssetsRepository.findById(shortId);
29
+ if (!asset)
30
+ return;
31
+ await getAdminStorageLite().bucket().file(asset.storagePath).delete({ ignoreNotFound: true });
32
+ await mediaAssetsRepository.delete(shortId);
33
+ }
34
+ catch (err) {
35
+ void normalizeError(err);
36
+ ctx.logger.warn("notificationPrune: failed to delete chat attachment (non-fatal)", { url });
37
+ }
38
+ }
39
+ /**
40
+ * D9 retention, folded into this job per its own "prune user-facing records
41
+ * past retention" charter — query-based with `.limit()`, never a
42
+ * whole-collection snapshot.
43
+ *
44
+ * | Stage | Age since lastMessageAt | Action |
45
+ * |---|---|---|
46
+ * | Active | 0–30d | normal |
47
+ * | Archived | 30d–7mo | hidden from the default inbox, read-only |
48
+ * | Deleted | >7mo | document + subcollection + attachments |
49
+ */
50
+ async function runChatRetention(ctx) {
51
+ const now = Date.now();
52
+ const archiveCutoff = new Date(now - CONVERSATION_ACTIVE_DAYS * 24 * 60 * 60 * 1000);
53
+ const deleteCutoff = new Date(now - CONVERSATION_ARCHIVE_DAYS * 24 * 60 * 60 * 1000);
54
+ try {
55
+ const toArchive = await conversationsRepository.archiveStale(archiveCutoff, RETENTION_BATCH_LIMIT);
56
+ for (const conv of toArchive) {
57
+ await conversationsRepository.update(conv.id, { status: "archived" });
58
+ }
59
+ if (toArchive.length > 0) {
60
+ ctx.logger.info("Conversations archived", { count: toArchive.length });
61
+ }
62
+ }
63
+ catch (err) {
64
+ void normalizeError(err);
65
+ ctx.logger.error("Chat archive stage failed (non-fatal)", err);
66
+ }
67
+ try {
68
+ const toDelete = await conversationsRepository.findDeletable(deleteCutoff, RETENTION_BATCH_LIMIT);
69
+ for (const conv of toDelete) {
70
+ const attachmentUrls = await listAllAttachmentUrls(conv.id);
71
+ for (const url of attachmentUrls) {
72
+ await deleteAttachmentByProxyUrl(ctx, url);
73
+ }
74
+ await deleteAllMessages(conv.id);
75
+ await conversationsRepository.delete(conv.id);
76
+ }
77
+ if (toDelete.length > 0) {
78
+ ctx.logger.info("Conversations deleted past retention", { count: toDelete.length });
79
+ }
80
+ }
81
+ catch (err) {
82
+ void normalizeError(err);
83
+ ctx.logger.error("Chat delete stage failed (non-fatal)", err);
84
+ }
85
+ }
4
86
  export async function runNotificationPrune(ctx) {
5
87
  ctx.logger.info(`Pruning read notifications older than ${NOTIFICATION_TTL_DAYS} days`);
6
88
  const refs = await notificationRepository.getOldReadRefs(NOTIFICATION_TTL_DAYS);
7
89
  if (refs.length === 0) {
8
90
  ctx.logger.info("No stale read notifications found");
9
- return;
10
91
  }
11
- const deleted = await batchDelete(ctx, refs);
12
- ctx.logger.info("Notification prune complete", { deleted });
92
+ else {
93
+ const deleted = await batchDelete(ctx, refs);
94
+ ctx.logger.info("Notification prune complete", { deleted });
95
+ }
96
+ await runChatRetention(ctx);
13
97
  }
@@ -43,6 +43,7 @@
43
43
  */
44
44
  import { orderRepository, productRepository } from "../../../../repositories/index.js";
45
45
  import { priceIndexRepository, } from "../../../../features/categories/repository/price-index.repository.js";
46
+ import { normaliseColourway } from "../../../../features/parts/utils/colourway-match.js";
46
47
  /** How far back a band looks. */
47
48
  export const PRICE_INDEX_WINDOW_DAYS = 90;
48
49
  /**
@@ -160,6 +161,28 @@ function toBand(samples) {
160
161
  for (const [condition, bucket] of byConditionSamples) {
161
162
  byCondition[condition] = toConditionBand(bucket);
162
163
  }
164
+ /*
165
+ * The per-COLOURWAY slice (I9), on exactly the same terms as the condition
166
+ * slice above — skipped when unrecorded, omitted rather than emitted empty.
167
+ *
168
+ * 🛑 It earns an axis because for a part it is a BIGGER price variable than
169
+ * condition: the silver Metal Ball and the transparent-black one are the same
170
+ * mould with the same stats, and one is rare and costly while the other is
171
+ * ordinary. One band over both reports a median that describes neither.
172
+ */
173
+ const byColourwaySamples = new Map();
174
+ for (const s of samples) {
175
+ const key = normaliseColourway(s.colourway);
176
+ if (!key)
177
+ continue;
178
+ const bucket = byColourwaySamples.get(key) ?? [];
179
+ bucket.push(s);
180
+ byColourwaySamples.set(key, bucket);
181
+ }
182
+ const byColourway = {};
183
+ for (const [colourway, bucket] of byColourwaySamples) {
184
+ byColourway[colourway] = toConditionBand(bucket);
185
+ }
163
186
  return {
164
187
  min: amounts[0],
165
188
  median: median(amounts),
@@ -168,6 +191,7 @@ function toBand(samples) {
168
191
  sampleSize: samples.length,
169
192
  byPath,
170
193
  ...(Object.keys(byCondition).length > 0 ? { byCondition } : {}),
194
+ ...(Object.keys(byColourway).length > 0 ? { byColourway } : {}),
171
195
  ...(latest ? { lastSold: { amount: latest.amount, at: latest.at, via: latest.via } } : {}),
172
196
  };
173
197
  }
@@ -198,6 +222,7 @@ export async function runPriceIndexRollup(ctx) {
198
222
  const leafOf = new Map();
199
223
  const typeOf = new Map();
200
224
  const conditionOf = new Map();
225
+ const colourwayOf = new Map();
201
226
  for (const p of rows) {
202
227
  if (!p.id)
203
228
  continue;
@@ -208,6 +233,8 @@ export async function runPriceIndexRollup(ctx) {
208
233
  typeOf.set(p.id, p.listingType);
209
234
  if (p.condition)
210
235
  conditionOf.set(p.id, p.condition);
236
+ if (p.colourway)
237
+ colourwayOf.set(p.id, p.colourway);
211
238
  }
212
239
  const delivered = (await orderRepository.findByStatus("delivered"));
213
240
  /** leafId → listingType → samples */
@@ -259,8 +286,16 @@ export async function runPriceIndexRollup(ctx) {
259
286
  * the map only answers for orders written before that.
260
287
  */
261
288
  const condition = item.condition ?? (item.productId ? conditionOf.get(item.productId) : undefined);
289
+ // Snapshot first, join second — same rule, same reason as `condition`.
290
+ const colourway = item.colourway ?? (item.productId ? colourwayOf.get(item.productId) : undefined);
262
291
  const samples = types.get(listingType) ?? [];
263
- samples.push({ amount, at, via, ...(condition ? { condition } : {}) });
292
+ samples.push({
293
+ amount,
294
+ at,
295
+ via,
296
+ ...(condition ? { condition } : {}),
297
+ ...(colourway ? { colourway } : {}),
298
+ });
264
299
  types.set(listingType, samples);
265
300
  byLeaf.set(leaf, types);
266
301
  considered += 1;
@@ -160,6 +160,14 @@ export const ACTIONS = {
160
160
  description: "Open the native share sheet or copy the listing URL.",
161
161
  kind: "ghost",
162
162
  },
163
+ "message-seller": {
164
+ iconKey: "message",
165
+ id: "product.message-seller",
166
+ label: "Message Seller",
167
+ ariaLabel: "Message this listing's seller",
168
+ description: "Open or start a conversation with the seller. Absent entirely when chat is off.",
169
+ kind: "ghost",
170
+ },
163
171
  "compare": {
164
172
  iconKey: "analytics",
165
173
  id: "product.compare",
@@ -97,6 +97,12 @@ export function resolveNotificationActionUrl(relatedType, relatedId, audience) {
97
97
  return audience === "admin"
98
98
  ? String(ROUTES.ADMIN.STORE_DETAIL(relatedId))
99
99
  : String(ROUTES.STORE.DASHBOARD);
100
+ case "conversation":
101
+ // D9 — one universal inbox, reachable from any portal; there is no
102
+ // admin-specific thread view (admins moderate via reports, not a
103
+ // parallel inbox route). Lives under ROUTES.USER, same precedent as
104
+ // WISHLIST/CART — a real top-level path, not /user/messages.
105
+ return String(ROUTES.USER.MESSAGE_THREAD(relatedId));
100
106
  default: {
101
107
  /*
102
108
  * Exhaustiveness. A new `relatedType` cannot compile without an answer
@@ -164,4 +170,6 @@ export const TYPE_AUDIENCE = {
164
170
  */
165
171
  support_ticket_update: "buyer",
166
172
  scam_report_update: "buyer",
173
+ // D9 — one universal inbox; which human reads it is not role-dependent.
174
+ message_received: "buyer",
167
175
  };
@@ -111,6 +111,9 @@ export const EMAIL_ELIGIBLE_TYPES = {
111
111
  // digest. This was the second 100-way fan-out — `onScamReportCreate` sent one
112
112
  // notification per employee, up to 100, for every report filed.
113
113
  scam_report_update: false,
114
+ // D9 — the conversation lives in the UI, same reasoning as
115
+ // support_ticket_update. A chat message is not a deadline.
116
+ message_received: false,
114
117
  };
115
118
  /**
116
119
  * Prefer this over indexing the map directly — the lookup is `boolean`, and a
@@ -38,6 +38,10 @@ export interface PublicSiteSettings {
38
38
  listings: {
39
39
  listingTypes?: NonNullable<SiteSettingsDocument["listings"]>["listingTypes"];
40
40
  };
41
+ /** D9 — off by default; `enabled: false` is emitted rather than omitted, same `always-present` reasoning as `disabledRoutes` below. */
42
+ chat?: {
43
+ enabled: boolean;
44
+ };
41
45
  notificationChannels?: NotificationChannelConfig;
42
46
  announcementBar?: SiteSettingsDocument["announcementBar"];
43
47
  navConfig?: SiteSettingsDocument["navConfig"];
package/dist/client.d.ts CHANGED
@@ -300,6 +300,16 @@ export { useActiveAd } from "./features/homepage/hooks/useActiveAd";
300
300
  export type { ActiveAdRecord, ActiveAdCreative } from "./features/homepage/hooks/useActiveAd";
301
301
  export { registerAdSlot, registerAdSlots, unregisterAdSlot, clearAdRegistry, setAdConsentGranted, isAdConsentGranted, isAdSlotRenderable, } from "./features/homepage/ad-registry";
302
302
  export type { AdSlotId, AdProvider, AdSlotConfig } from "./features/homepage/ad-registry";
303
+ /**
304
+ * D9 — chat. Client-only (every hook here is "use client"); the write path
305
+ * (actions) is server-only and lives in server.ts, never here.
306
+ */
307
+ export { ConversationsInboxView } from "./features/messages/components/ConversationsInboxView";
308
+ export { ConversationThreadView } from "./features/messages/components/ConversationThreadView";
309
+ export { MessageSellerButton } from "./features/messages/components/MessageSellerButton";
310
+ export type { MessageSellerButtonProps } from "./features/messages/components/MessageSellerButton";
311
+ export { useConversations, useOpenConversation, useOpenConversationWithSeller, useConversationThread, useSendMessage, useMarkConversationRead, useBlockConversation, useUnreadMessageCount, } from "./features/messages/hooks/useMessages";
312
+ export type { PendingMessage } from "./features/messages/hooks/useMessages";
303
313
  export { WishlistView, useGuestWishlist, useWishlistWithGuest } from "./features/wishlist/index";
304
314
  export type { WishlistViewProps, GuestWishlistItem, WishlistItem, WishlistResponse, WishlistProductData, EnrichedWishlistItem } from "./features/wishlist/index";
305
315
  export { WishlistCapWatcher } from "./features/wishlist/components/WishlistCapWatcher";
package/dist/client.js CHANGED
@@ -332,6 +332,14 @@ export { UserSettingsView } from "./features/account/index.js";
332
332
  export { AdSlot } from "./features/homepage/components/AdSlot.js";
333
333
  export { useActiveAd } from "./features/homepage/hooks/useActiveAd.js";
334
334
  export { registerAdSlot, registerAdSlots, unregisterAdSlot, clearAdRegistry, setAdConsentGranted, isAdConsentGranted, isAdSlotRenderable, } from "./features/homepage/ad-registry.js";
335
+ /**
336
+ * D9 — chat. Client-only (every hook here is "use client"); the write path
337
+ * (actions) is server-only and lives in server.ts, never here.
338
+ */
339
+ export { ConversationsInboxView } from "./features/messages/components/ConversationsInboxView.js";
340
+ export { ConversationThreadView } from "./features/messages/components/ConversationThreadView.js";
341
+ export { MessageSellerButton } from "./features/messages/components/MessageSellerButton.js";
342
+ export { useConversations, useOpenConversation, useOpenConversationWithSeller, useConversationThread, useSendMessage, useMarkConversationRead, useBlockConversation, useUnreadMessageCount, } from "./features/messages/hooks/useMessages.js";
335
343
  export { WishlistView, useGuestWishlist, useWishlistWithGuest } from "./features/wishlist/index.js";
336
344
  export { WishlistCapWatcher } from "./features/wishlist/components/WishlistCapWatcher.js";
337
345
  // The single wishlist heart. Exported here too because it is a "use client"
@@ -312,6 +312,22 @@ export declare const CONTACT_ENDPOINTS: {
312
312
  export declare const AD_ENDPOINTS: {
313
313
  readonly ACTIVE_BY_SLOT: (slotId: string) => string;
314
314
  };
315
+ export declare const MESSAGE_ENDPOINTS: {
316
+ readonly LIST: (opts?: {
317
+ limit?: number;
318
+ cursor?: string;
319
+ }) => string;
320
+ readonly OPEN: "/api/messages";
321
+ readonly OPEN_WITH_SELLER: "/api/messages/with-seller";
322
+ readonly THREAD: (conversationId: string, opts?: {
323
+ limit?: number;
324
+ cursor?: string;
325
+ }) => string;
326
+ readonly READ: (conversationId: string) => string;
327
+ readonly BLOCK: (conversationId: string) => string;
328
+ readonly TOKEN: (conversationId: string) => string;
329
+ readonly UNREAD_COUNT: "/api/messages/unread-count";
330
+ };
315
331
  export declare const EVENT_ENDPOINTS: {
316
332
  readonly LIST: "/api/events";
317
333
  readonly BY_ID: (id: string) => string;
@@ -799,6 +815,22 @@ export declare const API_ENDPOINTS: {
799
815
  readonly ADS: {
800
816
  readonly ACTIVE_BY_SLOT: (slotId: string) => string;
801
817
  };
818
+ readonly MESSAGES: {
819
+ readonly LIST: (opts?: {
820
+ limit?: number;
821
+ cursor?: string;
822
+ }) => string;
823
+ readonly OPEN: "/api/messages";
824
+ readonly OPEN_WITH_SELLER: "/api/messages/with-seller";
825
+ readonly THREAD: (conversationId: string, opts?: {
826
+ limit?: number;
827
+ cursor?: string;
828
+ }) => string;
829
+ readonly READ: (conversationId: string) => string;
830
+ readonly BLOCK: (conversationId: string) => string;
831
+ readonly TOKEN: (conversationId: string) => string;
832
+ readonly UNREAD_COUNT: "/api/messages/unread-count";
833
+ };
802
834
  readonly EVENTS: {
803
835
  readonly LIST: "/api/events";
804
836
  readonly BY_ID: (id: string) => string;
@@ -1285,6 +1317,22 @@ export declare const API_ROUTES: {
1285
1317
  readonly ADS: {
1286
1318
  readonly ACTIVE_BY_SLOT: (slotId: string) => string;
1287
1319
  };
1320
+ readonly MESSAGES: {
1321
+ readonly LIST: (opts?: {
1322
+ limit?: number;
1323
+ cursor?: string;
1324
+ }) => string;
1325
+ readonly OPEN: "/api/messages";
1326
+ readonly OPEN_WITH_SELLER: "/api/messages/with-seller";
1327
+ readonly THREAD: (conversationId: string, opts?: {
1328
+ limit?: number;
1329
+ cursor?: string;
1330
+ }) => string;
1331
+ readonly READ: (conversationId: string) => string;
1332
+ readonly BLOCK: (conversationId: string) => string;
1333
+ readonly TOKEN: (conversationId: string) => string;
1334
+ readonly UNREAD_COUNT: "/api/messages/unread-count";
1335
+ };
1288
1336
  readonly EVENTS: {
1289
1337
  readonly LIST: "/api/events";
1290
1338
  readonly BY_ID: (id: string) => string;