@mohasinac/appkit 4.46.0 → 4.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) 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 +58 -13
  38. package/dist/features/categories/components/BreadcrumbTrail.js +63 -26
  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/CategoryInlineSelect.d.ts +17 -1
  103. package/dist/features/seller/components/CategoryInlineSelect.js +14 -3
  104. package/dist/features/seller/components/SellerProductShell.d.ts +27 -2
  105. package/dist/features/seller/components/SellerProductShell.js +32 -3
  106. package/dist/features/seller/repository/offer.repository.js +21 -6
  107. package/dist/features/seller/schemas/product-form.d.ts +3 -3
  108. package/dist/features/seller/schemas/product-form.js +1 -1
  109. package/dist/features/server-errors/repository/server-errors.repository.js +11 -0
  110. package/dist/features/server-errors/schemas/firestore.d.ts +17 -0
  111. package/dist/features/server-errors/schemas/firestore.js +2 -0
  112. package/dist/features/store-extensions/repository/store-extensions.repositories.d.ts +17 -1
  113. package/dist/features/store-extensions/repository/store-extensions.repositories.js +31 -0
  114. package/dist/features/store-extensions/schemas/firestore.d.ts +1 -1
  115. package/dist/features/store-extensions/schemas/firestore.js +4 -0
  116. package/dist/features/stores/repository/store.repository.js +11 -3
  117. package/dist/features/tester/seed-data/authored/community-support__messages.d.ts +2 -0
  118. package/dist/features/tester/seed-data/authored/community-support__messages.js +200 -0
  119. package/dist/features/tester/seed-data/authored/index.js +2 -0
  120. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +64 -1
  121. package/dist/index.d.ts +8 -2
  122. package/dist/index.js +1 -1
  123. package/dist/next/routing/route-map.d.ts +14 -0
  124. package/dist/next/routing/route-map.js +26 -0
  125. package/dist/providers/db-firebase/rtdb-paths.d.ts +8 -0
  126. package/dist/providers/db-firebase/rtdb-paths.js +8 -0
  127. package/dist/repositories/index.d.ts +1 -0
  128. package/dist/repositories/index.js +4 -0
  129. package/dist/schemas/registry.d.ts +3 -3
  130. package/dist/security/index.d.ts +1 -1
  131. package/dist/security/index.js +1 -1
  132. package/dist/security/pii-schemas.d.ts +17 -0
  133. package/dist/security/pii-schemas.js +15 -7
  134. package/dist/seed/_helpers/category-content-generated.d.ts +24 -0
  135. package/dist/seed/_helpers/category-content-generated.js +1853 -0
  136. package/dist/seed/_helpers/category-models.d.ts +1 -1
  137. package/dist/seed/_helpers/category-models.js +25 -1
  138. package/dist/seed/_helpers/category-names.d.ts +54 -0
  139. package/dist/seed/_helpers/category-names.js +118 -0
  140. package/dist/seed/_helpers/character-features.d.ts +1 -1
  141. package/dist/seed/_helpers/character-features.js +5 -4
  142. package/dist/seed/_helpers/final-sale-fixtures.js +1 -1
  143. package/dist/seed/categories-seed-data.js +55 -1
  144. package/dist/seed/grouped-listings-seed-data.js +2 -2
  145. package/dist/seed/products-art-seed-data.js +2 -1
  146. package/dist/seed/products-auctions-seed-data.js +42 -22
  147. package/dist/seed/products-classifieds-seed-data.js +21 -3
  148. package/dist/seed/products-digital-codes-seed-data.js +2 -1
  149. package/dist/seed/products-live-items-seed-data.js +2 -1
  150. package/dist/seed/products-preorders-seed-data.js +26 -11
  151. package/dist/seed/products-prize-draws-seed-data.js +8 -1
  152. package/dist/seed/products-standard-seed-data.js +26 -25
  153. package/dist/seed/products-stickers-seed-data.js +12 -11
  154. package/dist/server.d.ts +8 -0
  155. package/dist/server.js +15 -0
  156. package/dist/styles.css +113 -1
  157. package/dist/tailwind-utilities.css +1 -1
  158. package/dist/tokens/themes/sunset.js +17 -0
  159. package/dist/tokens/tokens.css +5 -0
  160. package/dist/ui/components/Breadcrumb.d.ts +8 -1
  161. package/dist/ui/components/Breadcrumb.js +2 -2
  162. package/dist/ui/components/Breadcrumb.style.css +37 -0
  163. package/dist/ui/components/Details.d.ts +11 -1
  164. package/dist/ui/components/Details.js +2 -2
  165. package/dist/ui/components/ImageMosaic.d.ts +43 -0
  166. package/dist/ui/components/ImageMosaic.js +46 -0
  167. package/dist/ui/components/ImageMosaic.style.css +69 -0
  168. package/dist/ui/components/SplitTile.d.ts +34 -0
  169. package/dist/ui/components/SplitTile.js +28 -0
  170. package/dist/ui/components/index.style.css +1 -0
  171. package/dist/ui/index.d.ts +4 -0
  172. package/dist/ui/index.js +4 -0
  173. package/dist/utils/search-txt-query.d.ts +77 -0
  174. package/dist/utils/search-txt-query.js +50 -28
  175. 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 {
@@ -6,5 +6,21 @@ export interface CategoryInlineSelectProps {
6
6
  disabled?: boolean;
7
7
  /** Show the "+ Create new category" option. Defaults to true — sellers can create categories inline. */
8
8
  allowCreate?: boolean;
9
+ /**
10
+ * I2 — the ADDITIONAL assignments, beyond `value`.
11
+ *
12
+ * 🛑 Omitting this pair keeps the component exactly single-select, which is
13
+ * what every existing call site gets. Multi-assignment is opt-in per surface
14
+ * rather than switched on globally, because `value`/`onChange` is a `string`
15
+ * contract that five call sites rely on and changing it under them is Root
16
+ * Cause #20 (a public prop change without its consumers in the same commit).
17
+ */
18
+ extraValues?: string[];
19
+ onExtraValuesChange?: (ids: string[]) => void;
20
+ /**
21
+ * How many assignments are allowed in total, `value` included. Defaults to
22
+ * `MAX_CATEGORY_ASSIGNMENTS`, which is what the repository refuses above.
23
+ */
24
+ maxAssignments?: number;
9
25
  }
10
- export declare function CategoryInlineSelect({ value, onChange, placeholder, disabled, allowCreate, }: CategoryInlineSelectProps): React.JSX.Element;
26
+ export declare function CategoryInlineSelect({ value, onChange, placeholder, disabled, allowCreate, extraValues, onExtraValuesChange, maxAssignments, }: CategoryInlineSelectProps): React.JSX.Element;
@@ -1,9 +1,12 @@
1
1
  "use client";
2
- import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { PaginatedSelect } from "../../../ui/components/PaginatedSelect.js";
4
4
  import { CategoryQuickCreateForm } from "../../admin/components/CategoryQuickCreateForm.js";
5
5
  import { apiClient } from "../../../http/index.js";
6
6
  import { ADMIN_ENDPOINTS, CATEGORY_ENDPOINTS } from "../../../constants/api-endpoints.js";
7
+ import { Stack } from "../../../ui/components/Layout.js";
8
+ import { Text } from "../../../ui/components/Typography.js";
9
+ import { MAX_CATEGORY_ASSIGNMENTS } from "../../products/repository/category-assignment.js";
7
10
  // Admin: paginated + server-side search via /api/admin/categories (admin/mod only).
8
11
  // Response via successResponse({ data: items, total }) → apiClient unwraps to { data, total, ... }.
9
12
  async function loadAdminCategoryOptions(query, page) {
@@ -35,9 +38,17 @@ async function loadPublicCategoryOptions(query, _page) {
35
38
  const items = q ? all.filter((c) => c.label.toLowerCase().includes(q)) : all;
36
39
  return { items, hasMore: false, nextPage: 2 };
37
40
  }
38
- export function CategoryInlineSelect({ value, onChange, placeholder = "Search categories…", disabled, allowCreate = true, }) {
41
+ export function CategoryInlineSelect({ value, onChange, placeholder = "Search categories…", disabled, allowCreate = true, extraValues, onExtraValuesChange, maxAssignments = MAX_CATEGORY_ASSIGNMENTS, }) {
39
42
  const loadOptions = allowCreate ? loadAdminCategoryOptions : loadPublicCategoryOptions;
40
- return (_jsx(PaginatedSelect, { value: value || null, onChange: (v) => onChange(v ?? ""), loadOptions: loadOptions, placeholder: placeholder, disabled: disabled, createLabel: "category", drawerTitle: "Create Category", renderCreateForm: allowCreate
43
+ const primary = (_jsx(PaginatedSelect, { value: value || null, onChange: (v) => onChange(v ?? ""), loadOptions: loadOptions, placeholder: placeholder, disabled: disabled, createLabel: "category", drawerTitle: "Create Category", renderCreateForm: allowCreate
41
44
  ? ({ onCreated, onCancel }) => (_jsx(CategoryQuickCreateForm, { onSaved: (id, name) => onCreated({ value: id, label: name }), onCancel: onCancel }))
42
45
  : undefined }));
46
+ // Single-select unless the caller opted in — byte-identical to before.
47
+ if (!onExtraValuesChange)
48
+ return primary;
49
+ const extras = extraValues ?? [];
50
+ const remaining = Math.max(0, maxAssignments - 1 - extras.length);
51
+ return (_jsxs(Stack, { gap: "sm", children: [primary, _jsx(PaginatedSelect, { multiple: true, value: extras, onChange: (v) => onExtraValuesChange(Array.isArray(v) ? v : []), loadOptions: loadOptions, placeholder: remaining > 0
52
+ ? `Also list under… (up to ${remaining} more)`
53
+ : "No more categories can be added", disabled: disabled || remaining <= 0 }), _jsx(Text, { size: "xs", color: "muted", children: "Optional. Add a category from a DIFFERENT tree \u2014 a franchise, for example. Two categories from the same tree will be refused, because the broader one is already implied by the narrower." })] }));
43
54
  }
@@ -6,6 +6,16 @@ export interface SellerProductDraft {
6
6
  slug?: string;
7
7
  description?: string;
8
8
  category?: string;
9
+ /**
10
+ * I2 — the assignment list, DEFAULT FIRST, with `category` mirroring `[0]`.
11
+ *
12
+ * 🛑 Declared on the TYPE as well as the Zod schema. The schema is
13
+ * `.passthrough()`, so an undeclared key survives at runtime — and stays
14
+ * untyped, so the picker could not read `values.categoryIds` at all. Both
15
+ * halves are needed, which is the same trap as a field missing from
16
+ * `PRODUCT_UPDATABLE_FIELDS` (Root Cause #101) one layer out.
17
+ */
18
+ categoryIds?: string[];
9
19
  brand?: string;
10
20
  condition?: string;
11
21
  tags?: string[];
@@ -43,7 +53,7 @@ export interface SellerProductDraft {
43
53
  video?: MediaField;
44
54
  youtubeId?: string;
45
55
  price?: number;
46
- compareAtPrice?: number;
56
+ originalPrice?: number;
47
57
  stockQuantity?: number;
48
58
  featured?: boolean;
49
59
  isPromoted?: boolean;
@@ -134,10 +144,25 @@ export interface SellerProductShellProps {
134
144
  onDiscard?: () => void;
135
145
  isLoading?: boolean;
136
146
  storeSlug?: string;
137
- /** Render a custom category selector (e.g. PaginatedSelect with createLabel). */
147
+ /**
148
+ * Render a custom category selector (e.g. PaginatedSelect with createLabel).
149
+ *
150
+ * I2 — `extraValues`/`onExtraValuesChange` carry the ADDITIONAL assignments.
151
+ * A selector that ignores them stays exactly single-select, which is what
152
+ * every surface except this one does.
153
+ */
138
154
  renderCategorySelector?: (props: {
139
155
  value: string;
140
156
  onChange: (v: string) => void;
157
+ /**
158
+ * 🛑 OPTIONAL, and that is the whole point. `QuickProductForm` is a second
159
+ * consumer of this slot and making these required broke it immediately —
160
+ * Root Cause #20, a public prop change without its consumers, which the
161
+ * comment above literally warns about. A selector that ignores them stays
162
+ * exactly single-select.
163
+ */
164
+ extraValues?: string[];
165
+ onExtraValuesChange?: (ids: string[]) => void;
141
166
  }) => React.ReactNode;
142
167
  /** Render a custom brand selector. */
143
168
  renderBrandSelector?: (props: {
@@ -46,6 +46,15 @@ const sellerProductSchema = z.object({
46
46
  description: z.string().max(10000).optional().or(z.literal("")),
47
47
  price: z.number().min(0).optional(),
48
48
  category: z.string().optional(),
49
+ /**
50
+ * I2 — the assignment list, DEFAULT FIRST. `category` mirrors `[0]`.
51
+ *
52
+ * Declared even though this schema is `.passthrough()`, because the draft's
53
+ * TYPE is what the render site reads `values.categoryIds` off — passthrough
54
+ * keeps an undeclared key at runtime and still leaves it untyped, so the
55
+ * picker could not see it.
56
+ */
57
+ categoryIds: z.array(z.string()).max(4).optional(),
49
58
  brand: z.string().optional(),
50
59
  isActive: z.boolean().optional(),
51
60
  images: z
@@ -106,7 +115,27 @@ function StepBasic({ values, onChange, renderCategorySelector, renderBrandSelect
106
115
  condition: CONDITION_OPTIONS.find((o) => o.value === (values.condition ?? "new"))?.label ?? "",
107
116
  siteName: "LetItRip",
108
117
  };
109
- return (_jsxs(Stack, { gap: "md", children: [_jsx(FormField, { name: "title", label: "Title", type: "text", value: values.title ?? "", onChange: (v) => onChange({ title: v }), placeholder: "e.g. Charizard Base Set PSA 9" }), _jsxs(FormGroup, { columns: 2, children: [renderCategorySelector ? (_jsxs(_Fragment, { children: [_jsx(Text, { className: "text-[var(--appkit-color-text)] mb-1", size: "sm", weight: "medium", children: "Category" }), renderCategorySelector({ value: values.category ?? "", onChange: (v) => onChange({ category: v }) })] })) : (_jsx(FormField, { name: "category", label: "Category", type: "text", value: values.category ?? "", onChange: (v) => onChange({ category: v }), placeholder: "e.g. Trading Cards" })), _jsx(FormField, { name: "condition", label: "Condition", type: "select", value: values.condition ?? "new", onChange: (v) => onChange({ condition: v }), options: CONDITION_OPTIONS })] }), renderTemplateSelector?.({
118
+ return (_jsxs(Stack, { gap: "md", children: [_jsx(FormField, { name: "title", label: "Title", type: "text", value: values.title ?? "", onChange: (v) => onChange({ title: v }), placeholder: "e.g. Charizard Base Set PSA 9" }), _jsxs(FormGroup, { columns: 2, children: [renderCategorySelector ? (_jsxs(_Fragment, { children: [_jsx(Text, { className: "text-[var(--appkit-color-text)] mb-1", size: "sm", weight: "medium", children: "Category" }), renderCategorySelector({
119
+ value: values.category ?? "",
120
+ onChange: (v) => onChange({ category: v }),
121
+ /*
122
+ * 🛑 `categoryIds[0]` is the DEFAULT assignment and is already
123
+ * rendered by the primary picker above, so the extras list is
124
+ * everything AFTER it. Passing the whole array would show the
125
+ * primary twice and let a seller "remove" it from a control that
126
+ * does not own it.
127
+ */
128
+ extraValues: (values.categoryIds ?? []).slice(1),
129
+ onExtraValuesChange: (ids) => onChange({
130
+ /*
131
+ * Rebuilt primary-first on every change, because the server
132
+ * derives the default by DEPTH and reorders the stored array
133
+ * anyway — sending it in a different order would make the
134
+ * form and the saved document disagree until the next load.
135
+ */
136
+ categoryIds: [values.category ?? "", ...ids].filter(Boolean),
137
+ }),
138
+ })] })) : (_jsx(FormField, { name: "category", label: "Category", type: "text", value: values.category ?? "", onChange: (v) => onChange({ category: v }), placeholder: "e.g. Trading Cards" })), _jsx(FormField, { name: "condition", label: "Condition", type: "select", value: values.condition ?? "new", onChange: (v) => onChange({ condition: v }), options: CONDITION_OPTIONS })] }), renderTemplateSelector?.({
110
139
  categoryId: values.category ?? "",
111
140
  condition: values.condition ?? "new",
112
141
  context: templateContext,
@@ -359,7 +388,7 @@ const TYPE_SPECIFIC_SECTIONS = {
359
388
  function StepPricing({ values, onChange, listingType, }) {
360
389
  const listingPlugin = pluginForMode(listingType);
361
390
  const priceLabel = listingPlugin.priceLabel;
362
- 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({
363
392
  allowOffers: checked,
364
393
  minOfferPercent: checked
365
394
  ? values.minOfferPercent ?? DEFAULT_MIN_OFFER_PERCENT
@@ -614,7 +643,7 @@ productId, onSave, onPublish, onDiscard, isLoading = false, storeSlug, renderCat
614
643
  required: true,
615
644
  quick: true,
616
645
  // `gstRate` / `hsnCode` deliberately absent — admin-only, see StepPricing.
617
- fields: ["price", "compareAtPrice", "stockQuantity", "allowOffers", "minOfferPercent"],
646
+ fields: ["price", "originalPrice", "stockQuantity", "allowOffers", "minOfferPercent"],
618
647
  render: ({ values, onChange }) => (_jsx(StepPricing, { values: values, onChange: onChange, listingType: listingType })),
619
648
  // Built per render because whether stock is required depends on the
620
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>;