@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
@@ -6,7 +6,7 @@ import { increment } from "../../../contracts/field-ops.js";
6
6
  import { withHistory } from "../../../_internal/shared/history/index.js";
7
7
  import { SCAMMER_TRACKED_FIELDS, SCAMMER_HISTORY_PII_FIELDS } from "../schemas/firestore.js";
8
8
  import { buildScammerSearchTxt } from "../../../utils/search-txt-builders.js";
9
- import { planSearchTxt, refineSearchTxt, emptySearchResult, } from "../../../utils/search-txt-query.js";
9
+ import { planSearchTxt, beginSearchWindow, emptySearchResult, } from "../../../utils/search-txt-query.js";
10
10
  import { SCAMMER_COLLECTION, SCAMMER_FIELDS, SCAMMER_INCIDENTS_SUBCOLLECTION, SCAMMER_COMMENTS_SUBCOLLECTION, DEFAULT_SCAMMER_DATA, } from "../schemas/firestore.js";
11
11
  class ScammerRepository extends BaseRepository {
12
12
  constructor() {
@@ -35,12 +35,19 @@ class ScammerRepository extends BaseRepository {
35
35
  if (plan.head) {
36
36
  baseQuery = baseQuery.where(SCAMMER_FIELDS.SEARCH_TXT, "array-contains", plan.head);
37
37
  }
38
- const result = await this.sieveQuery(model, ScammerRepository.SIEVE_FIELDS, {
38
+ /*
39
+ * One bounded window, one raised `maxPageSize`, one page slice — see
40
+ * `beginSearchWindow`. Hand-writing the three is how the other ten
41
+ * repositories each ended up with two of them, which reads as a working
42
+ * multi-word search and silently cannot reach page 2.
43
+ */
44
+ const win = beginSearchWindow(model, plan);
45
+ const result = await this.sieveQuery(win.model, ScammerRepository.SIEVE_FIELDS, {
39
46
  baseQuery,
40
47
  defaultPageSize: 20,
41
- maxPageSize: 50,
48
+ maxPageSize: win.maxPageSize(50),
42
49
  });
43
- return refineSearchTxt(result, plan.rest);
50
+ return win.finish(result);
44
51
  }
45
52
  /** Derived on every write path via `applyWriteHooks`. */
46
53
  buildSearchTxtFor(data) {
@@ -56,12 +63,19 @@ class ScammerRepository extends BaseRepository {
56
63
  if (plan.head) {
57
64
  baseQuery = baseQuery.where(SCAMMER_FIELDS.SEARCH_TXT, "array-contains", plan.head);
58
65
  }
59
- const result = await this.sieveQuery(model, ScammerRepository.SIEVE_FIELDS, {
66
+ /*
67
+ * One bounded window, one raised `maxPageSize`, one page slice — see
68
+ * `beginSearchWindow`. Hand-writing the three is how the other ten
69
+ * repositories each ended up with two of them, which reads as a working
70
+ * multi-word search and silently cannot reach page 2.
71
+ */
72
+ const win = beginSearchWindow(model, plan);
73
+ const result = await this.sieveQuery(win.model, ScammerRepository.SIEVE_FIELDS, {
60
74
  baseQuery,
61
75
  defaultPageSize: 50,
62
- maxPageSize: 200,
76
+ maxPageSize: win.maxPageSize(200),
63
77
  });
64
- return refineSearchTxt(result, plan.rest);
78
+ return win.finish(result);
65
79
  }
66
80
  async findBySeoSlug(seoSlug) {
67
81
  try {
@@ -53,7 +53,7 @@ export interface SellerProductDraft {
53
53
  video?: MediaField;
54
54
  youtubeId?: string;
55
55
  price?: number;
56
- compareAtPrice?: number;
56
+ originalPrice?: number;
57
57
  stockQuantity?: number;
58
58
  featured?: boolean;
59
59
  isPromoted?: boolean;
@@ -388,7 +388,7 @@ const TYPE_SPECIFIC_SECTIONS = {
388
388
  function StepPricing({ values, onChange, listingType, }) {
389
389
  const listingPlugin = pluginForMode(listingType);
390
390
  const priceLabel = listingPlugin.priceLabel;
391
- return (_jsxs(Stack, { gap: "md", children: [_jsx(Alert, { variant: "info", children: "All prices in Indian Rupees (\u20B9)." }), _jsxs(FormGroup, { columns: 2, children: [_jsx(FormField, { name: "price", label: priceLabel, type: "number", value: toRupeesString(values.price), onChange: (v) => onChange({ price: fromRupeesString(v) }), placeholder: "999" }), _jsx(FormField, { name: "compareAtPrice", label: "Compare-at Price (\u20B9, optional)", type: "number", value: toRupeesString(values.compareAtPrice), onChange: (v) => onChange({ compareAtPrice: v ? fromRupeesString(v) : undefined }), placeholder: "1299", helpText: "Original price shown as strikethrough" })] }), listingPlugin.showsStockQuantity && (_jsx(FormField, { name: "stockQuantity", label: "Stock Quantity", type: "number", value: String(values.stockQuantity ?? ""), onChange: (v) => onChange({ stockQuantity: Number(v) }), placeholder: "1" })), _jsxs(FormGroup, { columns: 2, children: [_jsx(Toggle, { checked: !!values.featured, onChange: (checked) => onChange({ featured: checked }), label: "Mark as Featured" }), _jsx(Toggle, { checked: !!values.isNew, onChange: (checked) => onChange({ isNew: checked }), label: "Mark as New Arrival" })] }), _jsx(Toggle, { checked: !!values.allowOffers, onChange: (checked) => onChange({
391
+ return (_jsxs(Stack, { gap: "md", children: [_jsx(Alert, { variant: "info", children: "All prices in Indian Rupees (\u20B9)." }), _jsxs(FormGroup, { columns: 2, children: [_jsx(FormField, { name: "price", label: priceLabel, type: "number", value: toRupeesString(values.price), onChange: (v) => onChange({ price: fromRupeesString(v) }), placeholder: "999" }), _jsx(FormField, { name: "originalPrice", label: "Compare-at Price (\u20B9, optional)", type: "number", value: toRupeesString(values.originalPrice), onChange: (v) => onChange({ originalPrice: v ? fromRupeesString(v) : undefined }), placeholder: "1299", helpText: "Original price shown as strikethrough" })] }), listingPlugin.showsStockQuantity && (_jsx(FormField, { name: "stockQuantity", label: "Stock Quantity", type: "number", value: String(values.stockQuantity ?? ""), onChange: (v) => onChange({ stockQuantity: Number(v) }), placeholder: "1" })), _jsxs(FormGroup, { columns: 2, children: [_jsx(Toggle, { checked: !!values.featured, onChange: (checked) => onChange({ featured: checked }), label: "Mark as Featured" }), _jsx(Toggle, { checked: !!values.isNew, onChange: (checked) => onChange({ isNew: checked }), label: "Mark as New Arrival" })] }), _jsx(Toggle, { checked: !!values.allowOffers, onChange: (checked) => onChange({
392
392
  allowOffers: checked,
393
393
  minOfferPercent: checked
394
394
  ? values.minOfferPercent ?? DEFAULT_MIN_OFFER_PERCENT
@@ -643,7 +643,7 @@ productId, onSave, onPublish, onDiscard, isLoading = false, storeSlug, renderCat
643
643
  required: true,
644
644
  quick: true,
645
645
  // `gstRate` / `hsnCode` deliberately absent — admin-only, see StepPricing.
646
- fields: ["price", "compareAtPrice", "stockQuantity", "allowOffers", "minOfferPercent"],
646
+ fields: ["price", "originalPrice", "stockQuantity", "allowOffers", "minOfferPercent"],
647
647
  render: ({ values, onChange }) => (_jsx(StepPricing, { values: values, onChange: onChange, listingType: listingType })),
648
648
  // Built per render because whether stock is required depends on the
649
649
  // listing type's own plugin — a digital code has no stock to speak of.
@@ -4,7 +4,7 @@
4
4
  * Data access layer for the `offers` Firestore collection.
5
5
  * Handles buyer make-an-offer, seller counter/accept/decline lifecycle.
6
6
  */
7
- import { planSearchTxt, refineSearchTxt, emptySearchResult, } from "../../../utils/search-txt-query.js";
7
+ import { planSearchTxt, beginSearchWindow, emptySearchResult, } from "../../../utils/search-txt-query.js";
8
8
  import { buildOfferSearchTxt } from "../../../utils/search-txt-builders.js";
9
9
  import { BaseRepository, prepareForFirestore, parseSieveDateValue, } from "../../../providers/db-firebase/index.js";
10
10
  import { OFFER_COLLECTION, OFFER_FIELDS, OFFER_TRACKED_FIELDS, createOfferId } from "../schemas/index.js";
@@ -43,12 +43,19 @@ class OfferRepository extends BaseRepository {
43
43
  if (plan.head) {
44
44
  baseQuery = baseQuery.where("searchTxt", "array-contains", plan.head);
45
45
  }
46
- const result = await this.sieveQuery(model, OfferRepository.SIEVE_FIELDS, {
46
+ /*
47
+ * One bounded window, one raised `maxPageSize`, one page slice — see
48
+ * `beginSearchWindow`. Hand-writing the three is how the other ten
49
+ * repositories each ended up with two of them, which reads as a working
50
+ * multi-word search and silently cannot reach page 2.
51
+ */
52
+ const win = beginSearchWindow(model, plan);
53
+ const result = await this.sieveQuery(win.model, OfferRepository.SIEVE_FIELDS, {
47
54
  baseQuery,
48
55
  defaultPageSize: 50,
49
- maxPageSize: 200,
56
+ maxPageSize: win.maxPageSize(200),
50
57
  });
51
- return refineSearchTxt(result, plan.rest);
58
+ return win.finish(result);
52
59
  }
53
60
  // --- Create --------------------------------------------------------------
54
61
  async create(input) {
@@ -113,10 +120,18 @@ class OfferRepository extends BaseRepository {
113
120
  if (plan.head) {
114
121
  baseQuery = baseQuery.where(OFFER_FIELDS.SEARCH_TXT, "array-contains", plan.head);
115
122
  }
116
- const result = await this.sieveQuery(model, OfferRepository.SIEVE_FIELDS, {
123
+ /*
124
+ * One bounded window, one raised `maxPageSize`, one page slice — see
125
+ * `beginSearchWindow`. Hand-writing the three is how the other ten
126
+ * repositories each ended up with two of them, which reads as a working
127
+ * multi-word search and silently cannot reach page 2.
128
+ */
129
+ const win = beginSearchWindow(model, plan);
130
+ const result = await this.sieveQuery(win.model, OfferRepository.SIEVE_FIELDS, {
117
131
  baseQuery,
132
+ maxPageSize: win.maxPageSize(100),
118
133
  });
119
- return refineSearchTxt(result, plan.rest);
134
+ return win.finish(result);
120
135
  }
121
136
  const snap = await this.getCollection()
122
137
  .where(OFFER_FIELDS.STORE_ID, "==", storeId)
@@ -65,7 +65,7 @@ export declare const productDraftSchema: z.ZodObject<{
65
65
  youtubeId: z.ZodOptional<z.ZodString>;
66
66
  externalVideoUrl: z.ZodOptional<z.ZodString>;
67
67
  price: z.ZodOptional<z.ZodNumber>;
68
- compareAtPrice: z.ZodOptional<z.ZodNumber>;
68
+ originalPrice: z.ZodOptional<z.ZodNumber>;
69
69
  stockQuantity: z.ZodOptional<z.ZodNumber>;
70
70
  featured: z.ZodOptional<z.ZodBoolean>;
71
71
  isPromoted: z.ZodOptional<z.ZodBoolean>;
@@ -155,7 +155,7 @@ export declare const productDraftSchema: z.ZodObject<{
155
155
  youtubeId: z.ZodOptional<z.ZodString>;
156
156
  externalVideoUrl: z.ZodOptional<z.ZodString>;
157
157
  price: z.ZodOptional<z.ZodNumber>;
158
- compareAtPrice: z.ZodOptional<z.ZodNumber>;
158
+ originalPrice: z.ZodOptional<z.ZodNumber>;
159
159
  stockQuantity: z.ZodOptional<z.ZodNumber>;
160
160
  featured: z.ZodOptional<z.ZodBoolean>;
161
161
  isPromoted: z.ZodOptional<z.ZodBoolean>;
@@ -245,7 +245,7 @@ export declare const productDraftSchema: z.ZodObject<{
245
245
  youtubeId: z.ZodOptional<z.ZodString>;
246
246
  externalVideoUrl: z.ZodOptional<z.ZodString>;
247
247
  price: z.ZodOptional<z.ZodNumber>;
248
- compareAtPrice: z.ZodOptional<z.ZodNumber>;
248
+ originalPrice: z.ZodOptional<z.ZodNumber>;
249
249
  stockQuantity: z.ZodOptional<z.ZodNumber>;
250
250
  featured: z.ZodOptional<z.ZodBoolean>;
251
251
  isPromoted: z.ZodOptional<z.ZodBoolean>;
@@ -64,7 +64,7 @@ export const productDraftSchema = z
64
64
  externalVideoUrl: z.string().optional(),
65
65
  // Pricing
66
66
  price: z.number().optional(),
67
- compareAtPrice: z.number().optional(),
67
+ originalPrice: z.number().optional(),
68
68
  stockQuantity: z.number().optional(),
69
69
  featured: z.boolean().optional(),
70
70
  isPromoted: z.boolean().optional(),
@@ -39,6 +39,17 @@ export class ServerErrorsRepository extends BaseRepository {
39
39
  requestId: input.requestId,
40
40
  ...(input.userAgent ? { userAgent: input.userAgent } : {}),
41
41
  ...(input.bodyDigest ? { bodyDigest: input.bodyDigest } : {}),
42
+ /*
43
+ * 🛑 D10, and the G2 sweep is what found it: this hand-picked
44
+ * field list is exactly Root Cause #38's shape. `entityType` and
45
+ * `entityId` were on `ServerErrorDocument` AND accepted by this
46
+ * method's own `Omit<…>` input type — so a caller passing them
47
+ * type-checked clean and the fields died right here, on the one
48
+ * line nobody re-reads. A write that succeeds is not a field that
49
+ * persists.
50
+ */
51
+ ...(input.entityType ? { entityType: input.entityType } : {}),
52
+ ...(input.entityId ? { entityId: input.entityId } : {}),
42
53
  };
43
54
  await ref.set(doc);
44
55
  }
@@ -31,6 +31,21 @@ export type ServerErrorDocument = {
31
31
  userAgent?: string;
32
32
  /** sha256(requestBody) — for diagnosis without leaking PII. */
33
33
  bodyDigest?: string;
34
+ /**
35
+ * D10 — WHAT the failure was about, when that is narrower than `route`.
36
+ *
37
+ * 🛑 Absent means UNKNOWN, never "not a page". Every row written before this
38
+ * field existed has no value, so any filter over it must treat a missing
39
+ * `entityType` as INCLUDED — a default-exclusive filter would hide every
40
+ * pre-existing row and manufacture a backfill nobody budgeted for.
41
+ *
42
+ * `"page"` is the one value a user-submitted report can produce (see
43
+ * `ReportEntityType`), which is what lets an operator line a visitor's
44
+ * "this page is broken" up against the server-side rows for the same route.
45
+ */
46
+ entityType?: "page" | "product" | "store" | "order" | "listing";
47
+ /** The entity the failure was about — a path for `entityType: "page"`. */
48
+ entityId?: string;
34
49
  };
35
50
  export declare const SERVER_ERROR_FIELDS: {
36
51
  readonly ID: "id";
@@ -46,6 +61,8 @@ export declare const SERVER_ERROR_FIELDS: {
46
61
  readonly REQUEST_ID: "requestId";
47
62
  readonly USER_AGENT: "userAgent";
48
63
  readonly BODY_DIGEST: "bodyDigest";
64
+ readonly ENTITY_TYPE: "entityType";
65
+ readonly ENTITY_ID: "entityId";
49
66
  };
50
67
  export declare const SERVER_ERROR_STACK_MAX_BYTES = 4096;
51
68
  export declare const SERVER_ERROR_RETENTION_DAYS = 30;
@@ -13,6 +13,8 @@ export const SERVER_ERROR_FIELDS = {
13
13
  REQUEST_ID: "requestId",
14
14
  USER_AGENT: "userAgent",
15
15
  BODY_DIGEST: "bodyDigest",
16
+ ENTITY_TYPE: "entityType",
17
+ ENTITY_ID: "entityId",
16
18
  };
17
19
  export const SERVER_ERROR_STACK_MAX_BYTES = 4096;
18
20
  export const SERVER_ERROR_RETENTION_DAYS = 30;
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { BaseRepository } from "../../../providers/db-firebase";
16
16
  import type { DocumentSnapshot } from "firebase-admin/firestore";
17
- import { type AnalyticsAlertDocument, type AnalyticsCardDocument, type ItemRequestDocument, type ListingTemplateDocument, type ModerationQueueDocument, type PayoutMethodDocument, type ReportDocument, type ShippingConfigDocument, type StoreCategoryDocument, type StoreGoogleConfigDocument, type StoreWhatsAppConfigDocument } from "../schemas/firestore";
17
+ import { type AnalyticsAlertDocument, type AnalyticsCardDocument, type ItemRequestDocument, type ItemRequestReply, type ListingTemplateDocument, type ModerationQueueDocument, type PayoutMethodDocument, type ReportDocument, type ShippingConfigDocument, type StoreCategoryDocument, type StoreGoogleConfigDocument, type StoreWhatsAppConfigDocument } from "../schemas/firestore";
18
18
  export declare class PayoutMethodsRepository extends BaseRepository<PayoutMethodDocument> {
19
19
  /**
20
20
  * Bank/UPI details, encrypted at rest by BaseRepository's write hooks.
@@ -86,6 +86,22 @@ export declare class ItemRequestsRepository extends BaseRepository<ItemRequestDo
86
86
  listByOwner(opUserId: string): Promise<{
87
87
  items: ItemRequestDocument[];
88
88
  }>;
89
+ /**
90
+ * Append one reply atomically.
91
+ *
92
+ * 🛑 F1 #9 — the route did `replies: [...doc.replies, entry]` plus
93
+ * `replyCount: doc.replyCount + 1`. That is a read-modify-write on both
94
+ * fields: two people replying within the same second each read N replies and
95
+ * each wrote N+1, so the second write silently DISCARDED the first reply and
96
+ * the count agreed with it. Nothing errored, and the losing reply simply
97
+ * never existed.
98
+ *
99
+ * `arrayUnion` + `increment` are server-side operations, so the two writes
100
+ * compose instead of racing. `arrayUnion` de-duplicates by deep equality,
101
+ * which is harmless here and faintly useful: the entry carries a unique
102
+ * `id`, so a retried request appends once rather than twice.
103
+ */
104
+ appendReply(id: string, reply: ItemRequestReply): Promise<void>;
89
105
  }
90
106
  export declare class StoreWhatsAppConfigRepository extends BaseRepository<StoreWhatsAppConfigDocument> {
91
107
  constructor();
@@ -13,6 +13,7 @@
13
13
  * The other ten hold no PII and use the BaseRepository public API only.
14
14
  */
15
15
  import { BaseRepository } from "../../../providers/db-firebase/index.js";
16
+ import { arrayUnion, increment } from "../../../contracts/field-ops.js";
16
17
  import { decryptPiiFields } from "../../../security/pii-encrypt.js";
17
18
  import { ANALYTICS_ALERTS_COLLECTION, ANALYTICS_CARDS_COLLECTION, ITEM_REQUESTS_COLLECTION, LISTING_TEMPLATES_COLLECTION, MODERATION_QUEUE_COLLECTION, PAYOUT_METHODS_COLLECTION, REPORTS_COLLECTION, SHIPPING_CONFIGS_COLLECTION, STORE_CATEGORIES_COLLECTION, STORE_GOOGLE_CONFIG_COLLECTION, STORE_WHATSAPP_CONFIG_COLLECTION, } from "../schemas/firestore.js";
18
19
  export class PayoutMethodsRepository extends BaseRepository {
@@ -117,6 +118,36 @@ export class ItemRequestsRepository extends BaseRepository {
117
118
  const items = await this.findBy("opUserId", opUserId);
118
119
  return { items };
119
120
  }
121
+ /**
122
+ * Append one reply atomically.
123
+ *
124
+ * 🛑 F1 #9 — the route did `replies: [...doc.replies, entry]` plus
125
+ * `replyCount: doc.replyCount + 1`. That is a read-modify-write on both
126
+ * fields: two people replying within the same second each read N replies and
127
+ * each wrote N+1, so the second write silently DISCARDED the first reply and
128
+ * the count agreed with it. Nothing errored, and the losing reply simply
129
+ * never existed.
130
+ *
131
+ * `arrayUnion` + `increment` are server-side operations, so the two writes
132
+ * compose instead of racing. `arrayUnion` de-duplicates by deep equality,
133
+ * which is harmless here and faintly useful: the entry carries a unique
134
+ * `id`, so a retried request appends once rather than twice.
135
+ */
136
+ async appendReply(id, reply) {
137
+ /*
138
+ * Written through the collection directly rather than `update()`, because
139
+ * `prepareForFirestore` would walk the sentinels as if they were plain
140
+ * values. Same shape `categories.repository` uses for its own
141
+ * `arrayUnion` writes.
142
+ */
143
+ await this.getCollection()
144
+ .doc(id)
145
+ .update({
146
+ replies: arrayUnion(reply),
147
+ replyCount: increment(1),
148
+ updatedAt: new Date(),
149
+ });
150
+ }
120
151
  }
121
152
  export class StoreWhatsAppConfigRepository extends BaseRepository {
122
153
  constructor() {
@@ -171,7 +171,7 @@ export interface ModerationQueueDocument extends BaseDocument {
171
171
  export declare const MODERATION_QUEUE_COLLECTION: "moderationQueue";
172
172
  export declare const MODERATION_QUEUE_INDEXED_FIELDS: readonly ["status", "mediaType", "entityType", "entityId", "storeId", "ownerId", "submittedAt", "createdAt"];
173
173
  export declare const DEFAULT_MODERATION_QUEUE_DATA: Partial<ModerationQueueDocument>;
174
- export type ReportEntityType = "product" | "store" | "review" | "event" | "user" | "blog" | "comment";
174
+ export type ReportEntityType = "product" | "store" | "review" | "event" | "user" | "blog" | "comment" | "page" | "message";
175
175
  export type ReportStatus = "pending" | "under-review" | "actioned" | "dismissed";
176
176
  export type ReportReason = "scam" | "counterfeit" | "prohibited" | "inappropriate" | "harassment" | "spam" | "ip-violation" | "other";
177
177
  export interface ReportDocument extends BaseDocument {
@@ -139,6 +139,10 @@ const REPORT_ENTITY_TYPE_MAP = {
139
139
  user: true,
140
140
  blog: true,
141
141
  comment: true,
142
+ // D10 — a broken PAGE. `entityId` carries the path.
143
+ page: true,
144
+ // D9 — a chat message.
145
+ message: true,
142
146
  };
143
147
  export const REPORT_ENTITY_TYPES = Object.keys(REPORT_ENTITY_TYPE_MAP);
144
148
  const REPORT_REASON_MAP = {
@@ -12,7 +12,7 @@ import { normalizeError } from "../../../errors/normalize.js";
12
12
  import { encryptSecret, decryptSecret, } from "../../../security/settings-encryption.js";
13
13
  import { withHistory } from "../../../_internal/shared/history/index.js";
14
14
  import { buildStoreSearchTxt } from "../../../utils/search-txt-builders.js";
15
- import { planSearchTxt, refineSearchTxt, emptySearchResult, } from "../../../utils/search-txt-query.js";
15
+ import { planSearchTxt, beginSearchWindow, emptySearchResult, } from "../../../utils/search-txt-query.js";
16
16
  export class StoreRepository extends BaseRepository {
17
17
  constructor() {
18
18
  super(STORE_COLLECTION);
@@ -220,9 +220,17 @@ export class StoreRepository extends BaseRepository {
220
220
  if (plan.head) {
221
221
  baseQuery = baseQuery.where(STORE_FIELDS.SEARCH_TXT, "array-contains", plan.head);
222
222
  }
223
+ /*
224
+ * One bounded window, one raised `maxPageSize`, one page slice — see
225
+ * `beginSearchWindow`. Hand-writing the three is how the other ten
226
+ * repositories each ended up with two of them, which reads as a working
227
+ * multi-word search and silently cannot reach page 2.
228
+ */
229
+ const win = beginSearchWindow(model, plan);
223
230
  const result = await applySieveToFirestore({
224
231
  baseQuery,
225
- model,
232
+ model: win.model,
233
+ options: { maxPageSize: win.maxPageSize(100) },
226
234
  fields: sieveFields,
227
235
  // Called DIRECTLY, not through `sieveQuery`, so the mapper has to be
228
236
  // passed by hand — and it is the list mapper, never `mapDoc`. With
@@ -230,7 +238,7 @@ export class StoreRepository extends BaseRepository {
230
238
  // WhatsApp bearer token and a list has no use for it.
231
239
  mapDoc: (snap) => this.mapDocForList(snap),
232
240
  });
233
- const searched = refineSearchTxt(result, plan.rest);
241
+ const searched = win.finish(result);
234
242
  /*
235
243
  * 🛑 The rating threshold is applied IN MEMORY, deliberately.
236
244
  *
@@ -0,0 +1,2 @@
1
+ import type { AuthoredCase } from "./_types";
2
+ export declare const authored: Record<string, AuthoredCase>;
@@ -0,0 +1,200 @@
1
+ /*
2
+ * WHY: Authored six-part procedures for the D9 chat pages (/messages).
3
+ * WHAT: 10 case(s), keyed by full checklist id.
4
+ *
5
+ * 🛑 CHAT IS OFF BY DEFAULT AND `undefined` READS AS OFF.
6
+ *
7
+ * `siteSettings.chat.enabled` gates every write path server-side
8
+ * (`assertChatEnabled`) AND the nav icon AND the Message Seller button. So the
9
+ * FIRST case here turns it on, and every case after it depends on that having
10
+ * happened — which is deliberate: the off-state behaviour is itself a case
11
+ * worth having, because "the feature silently half-works when disabled" is the
12
+ * shape the original chat feature died of.
13
+ *
14
+ * 🛑 AND IT IS A REAL SETTING, NOT A FIXTURE. The seed does not write it, so a
15
+ * fresh run starts with chat OFF. A case that assumes the icon is present
16
+ * without having turned it on is testing the wrong state and will answer `no`
17
+ * for a reason that is not a defect.
18
+ *
19
+ * 🛑 THE OLD FEATURE'S TWO BUGS ARE THE TWO MOST VALUABLE CASES HERE, and
20
+ * neither is visible from the UI alone:
21
+ *
22
+ * 1. `useConversation`/`useConversations` never called `signInWithToken`, so
23
+ * every RTDB rule evaluated false and the live channel was permission-
24
+ * denied — swallowed into `setIsConnected(false)` and a literal
25
+ * `// ignore`. The channel LOOKED implemented and was inert. Case
26
+ * `live-ping-surfaces-failure` is the control: the error must be VISIBLE.
27
+ * 2. `listByBuyer`/`listByStore` had no `.limit()`, and every message wrote
28
+ * 2–3 pings each triggering an unpaginated refetch for both participants.
29
+ * Twenty messages ≈ 1,600 reads. Case `inbox-read-is-bounded` is the
30
+ * negative control the plan names explicitly.
31
+ *
32
+ * 🛑 TWO ACCOUNTS ARE REQUIRED and both are seeded: admin@letitrip.in (admin)
33
+ * and the owner of store-beyblade-arena, user-tyson-blader. A conversation is
34
+ * between two DIFFERENT users and the server refuses a self-conversation, so a
35
+ * single-account run cannot exercise anything past case 2.
36
+ *
37
+ * audit-tester-created-id: the conversation id is generated by Firestore and is
38
+ * therefore not knowable in advance — every case that needs it says to read it
39
+ * off the URL after the Message Seller button navigates, which is the only
40
+ * honest instruction. It is not a seed fixture and must not become one: the
41
+ * point is to exercise the create path.
42
+ *
43
+ * @tag domain:tester
44
+ * @tag layer:seed
45
+ * @tag pattern:none
46
+ * @tag access:server-only
47
+ * @tag consumers:seed-data/authored/index.ts
48
+ * @tag sideEffects:none
49
+ */
50
+ export const authored = {
51
+ "checklist-community-support-messages-chat-off-hides-everything": {
52
+ roles: ["admin", "buyer", "guest"],
53
+ startPage: "/admin/site",
54
+ steps: [
55
+ "Sign in as admin@letitrip.in / TempPass123!.",
56
+ "Open /admin/site and confirm the Chat toggle is OFF (its stored value is absent on a fresh seed, which reads as off).",
57
+ "Open /products/product-beyblade-burst-valkyrie and look at the 'Sold by' card.",
58
+ "Look at the header icon row, between the notification bell and the wishlist heart.",
59
+ "Navigate directly to /messages.",
60
+ ],
61
+ expectedBehaviour: "With chat off, nothing chat-related renders anywhere: no Message Seller button on the store card, no Messages icon in the header, and no /api/messages/unread-count request fires at all (the hook is gated on the setting, not merely rendered-and-empty). /messages itself still resolves — it is a real route — and shows the signed-out or empty state rather than 404ing.",
62
+ expectedUiState: "The 'Sold by' card shows ONLY 'Visit Store →'. The header icon row has no speech-bubble icon. /messages renders its own chrome with no conversation rows.",
63
+ endResult: "Nothing persists; this is the baseline state. Reload and the three absences hold.",
64
+ },
65
+ "checklist-community-support-messages-enable-chat-reveals-entry-points": {
66
+ roles: ["admin", "buyer"],
67
+ startPage: "/admin/site",
68
+ steps: [
69
+ "Sign in as admin@letitrip.in / TempPass123!.",
70
+ "Open /admin/site, turn the Chat toggle ON, and save.",
71
+ "Reload /products/product-beyblade-burst-valkyrie.",
72
+ "Look at the 'Sold by' card and at the header icon row.",
73
+ ],
74
+ inputs: { "chat.enabled": true },
75
+ expectedBehaviour: "Turning it on makes both entry points appear WITHOUT a code change: the gate is a live settings read, not a build-time flag. The Messages icon appears in the header with no badge (zero unread).",
76
+ expectedUiState: "The 'Sold by' card now shows a 'Message Seller' button beside 'Visit Store →'. A speech-bubble icon appears in the header icon row.",
77
+ expectedData: { "siteSettings.chat.enabled": true },
78
+ endResult: "Reload: both still present. The setting is stored, so it survives a sign-out and back in.",
79
+ },
80
+ "checklist-community-support-messages-message-seller-creates-thread": {
81
+ roles: ["buyer", "seller"],
82
+ startPage: "/products/product-beyblade-burst-valkyrie",
83
+ steps: [
84
+ "With chat enabled (previous case), sign in as admin@letitrip.in / TempPass123!.",
85
+ "Open /products/product-beyblade-burst-valkyrie and click 'Message Seller' on the 'Sold by' card.",
86
+ "Read the URL you land on and note the conversation id — it is a Firestore-generated id, not a slug.",
87
+ "Click 'Message Seller' on the SAME product a second time (go back and click again).",
88
+ "Compare the URL to the one you noted.",
89
+ ],
90
+ expectedBehaviour: "The first click resolves store-beyblade-arena's storeId to its owner's uid SERVER-SIDE (a product page knows the store slug, never the owner's Auth UID) and creates one conversation. The second click finds the existing thread and does NOT create a second one — the thread is between two USERS, not per listing, so a buyer with three questions about three items gets one thread.",
91
+ expectedUiState: "Both clicks land on /messages/{sameId} showing an empty thread with a composer and a 'Block' control in the header.",
92
+ expectedData: { "conversations: documents between this pair": 1 },
93
+ endResult: "The conversation persists. /messages now lists it. Clicking Message Seller again forever reuses it.",
94
+ },
95
+ "checklist-community-support-messages-send-and-recipient-sees-it": {
96
+ roles: ["buyer", "seller"],
97
+ startPage: "/messages",
98
+ steps: [
99
+ "Signed in as admin@letitrip.in, open the conversation created in the previous case.",
100
+ "Type 'Does this come with the original launcher?' and press Enter.",
101
+ "Watch the bubble while it sends.",
102
+ "Sign out, sign in as the owner of store-beyblade-arena (user-tyson-blader), and open /messages.",
103
+ "Open the conversation and read the message.",
104
+ ],
105
+ inputs: { body: "Does this come with the original launcher?" },
106
+ expectedBehaviour: "The message appears immediately as a pending bubble (optimistic), then resolves to a sent one. Server-side the body is PII-stripped and encrypted at rest, a truncated preview is denormalised onto the conversation, the recipient's unread counter is incremented with a real Firestore increment (not a read-modify-write), and a bell-only `message_received` notification is written — no email, because EMAIL_ELIGIBLE_TYPES says false for it.",
107
+ expectedUiState: "Sender side: the bubble is right-aligned and reads 'Sending…' briefly, then settles. Recipient side: /messages shows the thread with an unread badge of 1 and the preview text; opening it shows the message left-aligned and clears the badge.",
108
+ expectedData: { "recipient unread before opening": 1, "recipient unread after opening": 0 },
109
+ endResult: "Reload on both sides: the message is still there, the badge stays cleared for the recipient, and the preview on the inbox row matches the message.",
110
+ },
111
+ "checklist-community-support-messages-pii-is-stripped-from-the-body": {
112
+ roles: ["buyer"],
113
+ startPage: "/messages",
114
+ steps: [
115
+ "Open any conversation you are a participant in.",
116
+ "Send exactly: 'call me on 9876543210 or pay to tyson@paytm or tyson@okhdfc'",
117
+ "Read the message as it appears in the thread.",
118
+ "Reload the page and read it again.",
119
+ ],
120
+ inputs: { body: "call me on 9876543210 or pay to tyson@paytm or tyson@okhdfc" },
121
+ expectedBehaviour: "All three identifiers are replaced BEFORE the write, so the stored body never contains them. The rule order is specific-before-general and that ordering IS the fix: a bare `name@bank` VPA has no TLD dot so it matches neither the email nor the phone rule, and running phone first would mangle '9876543210@upi' into '[number removed]@upi' before the UPI rule ever saw it.",
122
+ expectedUiState: "The message renders as 'call me on [number removed] or pay to [email removed] or pay to [UPI removed]' — the ten-digit run, the dotted VPA and the bare VPA each replaced, none left through.",
123
+ expectedData: { "stored body contains '9876543210'": false, "stored body contains 'okhdfc'": false },
124
+ endResult: "Reload: the redacted form persists. The original text is not recoverable from the UI, which is the point — the redaction happened on the way in, not on the way out.",
125
+ },
126
+ "checklist-community-support-messages-retry-does-not-double-post": {
127
+ roles: ["buyer"],
128
+ startPage: "/messages",
129
+ steps: [
130
+ "Open a conversation and open DevTools → Network, set throttling to Offline.",
131
+ "Type 'retry test' and press Enter.",
132
+ "Observe the bubble's state.",
133
+ "Set throttling back to online and click the failed bubble's retry control.",
134
+ "Count the messages in the thread.",
135
+ ],
136
+ inputs: { body: "retry test" },
137
+ expectedBehaviour: "The failed send keeps its ORIGINAL clientMessageId — generated once at compose time, not per attempt — so the retry resolves to the same message server-side rather than creating a second one. The server uses the clientMessageId as the message document id and returns the existing document on a replay.",
138
+ expectedUiState: "Offline: the bubble reads 'Failed — tap to retry' rather than disappearing or silently succeeding. After retry: exactly ONE 'retry test' bubble in the thread, no duplicate.",
139
+ expectedData: { "messages with body 'retry test'": 1 },
140
+ endResult: "Reload: one message. A silent failure or a duplicate would both be defects — the first is how the old feature hid, the second is what idempotency exists to prevent.",
141
+ },
142
+ "checklist-community-support-messages-soft-ban-blocks-sending": {
143
+ roles: ["admin", "buyer"],
144
+ startPage: "/admin/users",
145
+ steps: [
146
+ "Sign in as admin@letitrip.in and open /admin/users.",
147
+ "Find the account you have been testing with as the buyer, open its editor, and apply a soft ban with action 'Send messages' and a reason.",
148
+ "Sign in as that banned account and open a conversation you are a participant in.",
149
+ "Type anything and try to send.",
150
+ "Read the refusal.",
151
+ ],
152
+ inputs: { "softBan.action": "send_messages", "softBan.reason": "QA test ban" },
153
+ expectedBehaviour: "The send is refused server-side with the ban's own reason quoted back. 🛑 This is the case that matters most about this flag: `send_messages` was a live BannedAction with NO READER anywhere in the codebase — an admin could set it and nothing checked it. The check lives in the domain function, so every caller (route, future job) is covered rather than just the one route someone remembered.",
154
+ expectedUiState: "An error surfaces naming the restriction and the reason — 'You are restricted from sending messages. Reason: QA test ban.' — not a generic failure and not a silently-dropped message.",
155
+ expectedData: { "messages added to the thread": 0 },
156
+ endResult: "Reload: no message was added. Remove the ban and the same send succeeds, which is what proves the ban was the cause rather than something else.",
157
+ },
158
+ "checklist-community-support-messages-block-stops-the-other-side": {
159
+ roles: ["buyer", "seller"],
160
+ startPage: "/messages",
161
+ steps: [
162
+ "Sign in as the seller (user-tyson-blader) and open the shared conversation.",
163
+ "Click 'Block' in the thread header and confirm.",
164
+ "Sign out, sign back in as the buyer (admin@letitrip.in), open the same conversation.",
165
+ "Try to send a message.",
166
+ ],
167
+ expectedBehaviour: "Blocking records the blocker's uid on the conversation. A send from the blocked party is refused with a clear reason rather than silently vanishing — a blocked sender who thinks their message went through is worse than one who is told.",
168
+ expectedUiState: "Blocker side: a confirmation, then the block persists. Blocked side: the send fails with 'The other participant is not accepting messages from you.'",
169
+ expectedData: { "messages added after the block": 0 },
170
+ endResult: "Reload on both sides: the block holds and the thread's existing history is still readable to both. Blocking stops new messages; it does not delete the past.",
171
+ },
172
+ "checklist-community-support-messages-live-ping-surfaces-failure": {
173
+ roles: ["buyer"],
174
+ startPage: "/messages",
175
+ steps: [
176
+ "Open a conversation and leave it open.",
177
+ "Open DevTools → Network and block requests to the Firebase Realtime Database host (or set throttling to Offline then back, which drops the subscription).",
178
+ "Watch the top of the thread for a notice.",
179
+ "In a second browser profile signed in as the other participant, send a message.",
180
+ "Reload the first tab.",
181
+ ],
182
+ expectedBehaviour: "🛑 THE CONTROL FOR THE BUG THAT KILLED THE OLD FEATURE. When the realtime channel cannot connect, the failure is SURFACED, never swallowed. The old hooks swallowed permission-denied into `setIsConnected(false)` and a literal `// ignore`, so the channel looked implemented and was inert for its entire life. The message is still delivered — it is durably in Firestore the moment the send returns — and the subscription only controls whether the open tab learns about it without a reload.",
183
+ expectedUiState: "A visible warning strip above the thread reading 'Live updates are unavailable for this conversation. New messages may not appear until you reload.' — NOT a silent nothing. After reload, the other participant's message is present.",
184
+ endResult: "The message was never lost; only the live notification was. That distinction is what the warning communicates, and a tester seeing no warning at all is the defect.",
185
+ },
186
+ "checklist-community-support-messages-inbox-read-is-bounded": {
187
+ roles: ["buyer"],
188
+ startPage: "/messages",
189
+ steps: [
190
+ "Open DevTools → Network and filter to /api/messages.",
191
+ "Load /messages and note the request and its response shape.",
192
+ "Open one conversation and note the thread request's response shape.",
193
+ "Scroll the thread to its oldest message.",
194
+ ],
195
+ expectedBehaviour: "🛑 THE NEGATIVE CONTROL THE PLAN NAMES. Both list reads are bounded and cursor-paginated: the inbox returns at most 50 conversations with a `nextCursor`, the thread at most 50 messages (default 30) with its own `nextCursor`. The absence of a `.limit()` is literally what turned 20 messages into ~1,600 reads the first time this feature existed. The unread badge sums the conversation list the inbox already holds and never issues a list query of its own.",
196
+ expectedUiState: "Each response carries `{ items: [...], nextCursor }` with `items.length` at or below the limit — never an unbounded array, and never a response whose size grows with total thread count.",
197
+ expectedData: { "inbox items.length <= 50": true, "thread items.length <= 50": true },
198
+ endResult: "Reload: the same bounded shape. A response returning every message in a thread is the defect this case exists to catch, and it is invisible from the rendered page — only the network panel shows it.",
199
+ },
200
+ };
@@ -71,6 +71,7 @@ import { authored as a_buying__user_dashboard_extras } from "./buying__user-dash
71
71
  import { authored as a_buying__offers } from "./buying__offers.js";
72
72
  import { authored as a_buying__cart } from "./buying__cart.js";
73
73
  import { authored as a_community_support__public_profile } from "./community-support__public-profile.js";
74
+ import { authored as a_community_support__messages } from "./community-support__messages.js";
74
75
  import { authored as a_community_support__support_tickets } from "./community-support__support-tickets.js";
75
76
  import { authored as a_content_discovery__blog } from "./content-discovery__blog.js";
76
77
  import { authored as a_content_discovery__coupons } from "./content-discovery__coupons.js";
@@ -191,6 +192,7 @@ export const AUTHORED_CASES = {
191
192
  ...a_buying__offers,
192
193
  ...a_buying__cart,
193
194
  ...a_community_support__public_profile,
195
+ ...a_community_support__messages,
194
196
  ...a_community_support__support_tickets,
195
197
  ...a_content_discovery__blog,
196
198
  ...a_content_discovery__coupons,