@mohasinac/appkit 4.60.1 → 4.62.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 (202) hide show
  1. package/dist/.build-stamp.json +3 -3
  2. package/dist/_internal/client/features/lottery/LotteryAdminEditView.js +19 -3
  3. package/dist/_internal/client/features/maintenance/views/AnalysisRunnerView.js +1 -1
  4. package/dist/_internal/client/features/maintenance/views/CloudLogsListView.js +1 -1
  5. package/dist/_internal/server/features/auctions/actions.d.ts +1 -1
  6. package/dist/_internal/server/features/auctions/actions.js +12 -1
  7. package/dist/_internal/server/features/blog/actions.d.ts +1 -1
  8. package/dist/_internal/server/features/blog/actions.js +12 -1
  9. package/dist/_internal/server/features/brands/actions.d.ts +1 -1
  10. package/dist/_internal/server/features/brands/actions.js +12 -1
  11. package/dist/_internal/server/features/cart/actions.d.ts +1 -1
  12. package/dist/_internal/server/features/cart/actions.js +12 -1
  13. package/dist/_internal/server/features/checkout/actions.js +0 -1
  14. package/dist/_internal/server/features/events/actions.d.ts +1 -1
  15. package/dist/_internal/server/features/events/actions.js +12 -1
  16. package/dist/_internal/server/features/gateway-events/redact.js +14 -2
  17. package/dist/_internal/server/features/history/actions.d.ts +1 -1
  18. package/dist/_internal/server/features/history/actions.js +12 -1
  19. package/dist/_internal/server/features/lottery/actions.d.ts +1 -1
  20. package/dist/_internal/server/features/lottery/actions.js +12 -1
  21. package/dist/_internal/server/features/orders/actions.d.ts +1 -1
  22. package/dist/_internal/server/features/orders/actions.js +12 -1
  23. package/dist/_internal/server/features/orders/cancellation-refund.js +0 -1
  24. package/dist/_internal/server/features/orders/service.js +31 -1
  25. package/dist/_internal/server/features/payouts/actions.d.ts +1 -1
  26. package/dist/_internal/server/features/payouts/actions.js +12 -1
  27. package/dist/_internal/server/features/points/award.js +9 -1
  28. package/dist/_internal/server/features/pre-orders/actions.d.ts +1 -1
  29. package/dist/_internal/server/features/pre-orders/actions.js +12 -1
  30. package/dist/_internal/server/features/prize-draws/actions.d.ts +1 -1
  31. package/dist/_internal/server/features/prize-draws/actions.js +12 -1
  32. package/dist/_internal/server/features/products/actions.d.ts +1 -1
  33. package/dist/_internal/server/features/products/actions.js +12 -1
  34. package/dist/_internal/server/features/promotions/actions.d.ts +1 -1
  35. package/dist/_internal/server/features/promotions/actions.js +12 -1
  36. package/dist/_internal/server/features/protection-claims/actions.d.ts +2 -2
  37. package/dist/_internal/server/features/protection-claims/actions.js +21 -12
  38. package/dist/_internal/server/features/raffle/actions.d.ts +1 -1
  39. package/dist/_internal/server/features/raffle/actions.js +12 -1
  40. package/dist/_internal/server/features/refunds/actions.d.ts +1 -3
  41. package/dist/_internal/server/features/refunds/actions.js +14 -2
  42. package/dist/_internal/server/features/reviews/actions.d.ts +1 -1
  43. package/dist/_internal/server/features/reviews/actions.js +12 -1
  44. package/dist/_internal/server/features/wishlist/actions.d.ts +1 -1
  45. package/dist/_internal/server/features/wishlist/actions.js +12 -1
  46. package/dist/_internal/server/jobs/core/obligationSweep.js +0 -1
  47. package/dist/_internal/server/jobs/core/paymentReconcile.js +93 -76
  48. package/dist/_internal/server/jobs/core/testDataCleanup.d.ts +1 -1
  49. package/dist/_internal/server/jobs/core/testDataCleanup.js +22 -1
  50. package/dist/_internal/server/jobs/handlers/onShipmentAllocationSync.js +16 -4
  51. package/dist/_internal/shared/actions/action-registry.js +149 -9
  52. package/dist/_internal/shared/features/promotions/schema.d.ts +66 -0
  53. package/dist/_internal/shared/features/promotions/schema.js +7 -0
  54. package/dist/_internal/shared/media/limits.d.ts +10 -0
  55. package/dist/_internal/shared/media/limits.js +10 -0
  56. package/dist/_internal/shared/obligations/deadlines.js +11 -1
  57. package/dist/_internal/shared/tester/window.d.ts +25 -0
  58. package/dist/_internal/shared/tester/window.js +66 -0
  59. package/dist/_internal/shared/types/action-result.js +15 -1
  60. package/dist/client/api/surface-error.js +16 -2
  61. package/dist/client/api/useApiMutation.js +17 -1
  62. package/dist/client.d.ts +1 -1
  63. package/dist/client.js +1 -1
  64. package/dist/constants/api-endpoints.d.ts +3 -0
  65. package/dist/constants/api-endpoints.js +1 -0
  66. package/dist/constants/field-names.d.ts +17 -0
  67. package/dist/constants/field-names.js +23 -0
  68. package/dist/core/server-action.js +19 -3
  69. package/dist/core/unit-of-work.js +15 -1
  70. package/dist/errors/error-mapping.js +26 -1
  71. package/dist/errors/firestore-codes.d.ts +37 -0
  72. package/dist/errors/firestore-codes.js +52 -0
  73. package/dist/errors/index.d.ts +0 -1
  74. package/dist/errors/index.js +12 -2
  75. package/dist/features/account/components/UserOffersPanel.js +5 -3
  76. package/dist/features/admin/components/AdminActionIndexView.js +100 -5
  77. package/dist/features/admin/components/AdminAddressClustersView.js +21 -2
  78. package/dist/features/admin/components/AdminBlogEditorView.js +8 -8
  79. package/dist/features/admin/components/AdminCarouselEditorView.js +1 -1
  80. package/dist/features/admin/components/AdminCarouselView.js +96 -4
  81. package/dist/features/admin/components/AdminCouponEditorView.js +8 -0
  82. package/dist/features/admin/components/AdminFulfillmentView.js +3 -2
  83. package/dist/features/admin/components/AdminGatewayEventsView.js +2 -2
  84. package/dist/features/admin/components/AdminMediaView.js +36 -3
  85. package/dist/features/admin/components/AdminNavigationView.js +4 -1
  86. package/dist/features/admin/components/AdminOffersView.js +2 -1
  87. package/dist/features/admin/components/AdminPaymentClustersView.js +19 -2
  88. package/dist/features/admin/components/AdminProductEditorView.js +2 -2
  89. package/dist/features/admin/components/AdminSectionsView.d.ts +10 -0
  90. package/dist/features/admin/components/AdminSectionsView.js +486 -224
  91. package/dist/features/admin/components/DataListingView.js +2 -0
  92. package/dist/features/admin/components/ReferenceRecordEditor.js +1 -1
  93. package/dist/features/admin/components/analytics/AdminStatCard.js +4 -1
  94. package/dist/features/admin/hooks/useOptimisticRowFlag.d.ts +33 -0
  95. package/dist/features/admin/hooks/useOptimisticRowFlag.js +87 -0
  96. package/dist/features/admin/repository/notification.repository.d.ts +15 -0
  97. package/dist/features/admin/repository/notification.repository.js +32 -0
  98. package/dist/features/admin/repository/site-settings.repository.d.ts +2 -1
  99. package/dist/features/admin/repository/site-settings.repository.js +7 -0
  100. package/dist/features/admin/schemas/action-index-form.d.ts +34 -0
  101. package/dist/features/admin/schemas/action-index-form.js +115 -0
  102. package/dist/features/auctions/components/MarketplaceAuctionCard.js +1 -1
  103. package/dist/features/auth/components/RegisterForm.js +2 -1
  104. package/dist/features/catalogue/actions/approve-catalogue-listing.d.ts +1 -1
  105. package/dist/features/catalogue/actions/approve-catalogue-listing.js +12 -1
  106. package/dist/features/catalogue/actions/list-from-catalogue.d.ts +1 -1
  107. package/dist/features/catalogue/actions/list-from-catalogue.js +12 -1
  108. package/dist/features/catalogue/actions/submit-for-approval.d.ts +1 -1
  109. package/dist/features/catalogue/actions/submit-for-approval.js +12 -1
  110. package/dist/features/catalogue/components/CatalogueItemEditorView.js +3 -3
  111. package/dist/features/categories/repository/price-observation.repository.d.ts +19 -0
  112. package/dist/features/categories/repository/price-observation.repository.js +21 -2
  113. package/dist/features/events/actions/event-actions.js +4 -1
  114. package/dist/features/events/components/AdminEventEditorView.js +79 -50
  115. package/dist/features/events/components/EventsListPageView.js +2 -1
  116. package/dist/features/events/schemas/firestore.d.ts +17 -0
  117. package/dist/features/events/schemas/firestore.js +1 -0
  118. package/dist/features/events/utils/sort-pinned.d.ts +33 -0
  119. package/dist/features/events/utils/sort-pinned.js +46 -0
  120. package/dist/features/gateway-events/repository/gateway-events.repository.js +3 -40
  121. package/dist/features/gateway-events/schemas/index.d.ts +2 -2
  122. package/dist/features/inventory/components/AdminStorageBoxesView.js +55 -39
  123. package/dist/features/inventory/components/ReceiveStationView.js +61 -17
  124. package/dist/features/inventory/repository/storage-boxes.repository.d.ts +3 -1
  125. package/dist/features/inventory/repository/storage-boxes.repository.js +36 -3
  126. package/dist/features/layout/hooks/useFormBottomActions.js +20 -1
  127. package/dist/features/obligations/repository/obligations.repository.js +10 -1
  128. package/dist/features/orders/components/EmiInstallmentsPanel.js +25 -7
  129. package/dist/features/orders/components/RefundRequestView.js +4 -4
  130. package/dist/features/orders/schemas/firestore.d.ts +19 -0
  131. package/dist/features/orders/schemas/firestore.js +19 -0
  132. package/dist/features/orders/schemas/index.d.ts +6 -6
  133. package/dist/features/payment-cases/repository/payment-cases.repository.d.ts +1 -0
  134. package/dist/features/payment-cases/repository/payment-cases.repository.js +3 -14
  135. package/dist/features/pre-orders/components/MarketplacePreorderCard.js +1 -1
  136. package/dist/features/products/components/GroupMemberPicker.js +4 -1
  137. package/dist/features/products/components/MarketplaceBundleCard.js +1 -1
  138. package/dist/features/products/components/MarketplacePrizeDrawCard.js +1 -1
  139. package/dist/features/products/components/PrizeDrawEntryActions.js +2 -1
  140. package/dist/features/products/components/ProductDetailActions.js +2 -1
  141. package/dist/features/products/components/ProductForm.js +2 -2
  142. package/dist/features/products/components/ProductGrid.js +1 -1
  143. package/dist/features/promotions/repository/claimed-coupons.repository.js +31 -1
  144. package/dist/features/promotions/schemas/firestore.d.ts +22 -0
  145. package/dist/features/promotions/schemas/firestore.js +22 -0
  146. package/dist/features/promotions/schemas/index.d.ts +479 -6
  147. package/dist/features/promotions/schemas/index.js +153 -0
  148. package/dist/features/protection-claims/repository/protection-claims.repository.js +22 -15
  149. package/dist/features/seller/components/FulfillmentView.js +3 -2
  150. package/dist/features/seller/components/SellerAddressesView.js +32 -4
  151. package/dist/features/seller/components/SellerAnalyticsAlertsView.js +62 -3
  152. package/dist/features/seller/components/SellerBundlesView.js +53 -14
  153. package/dist/features/seller/components/SellerGroupedListingsView.js +23 -3
  154. package/dist/features/seller/components/SellerListingTypeView.js +56 -10
  155. package/dist/features/seller/components/SellerPayoutMethodsView.js +33 -1
  156. package/dist/features/seller/components/SellerPayoutSettingsView.js +48 -9
  157. package/dist/features/seller/components/SellerProductsView.js +5 -1
  158. package/dist/features/seller/components/SellerShippingConfigsView.js +33 -1
  159. package/dist/features/seller/components/SellerShippingView.js +2 -1
  160. package/dist/features/seller/components/SellerStoreCategoriesView.js +33 -1
  161. package/dist/features/seller/components/SellerSupportView.js +69 -2
  162. package/dist/features/seller/schemas/coupon-form.d.ts +5 -0
  163. package/dist/features/seller/schemas/coupon-form.js +18 -0
  164. package/dist/features/shell/SectionForm.js +35 -9
  165. package/dist/features/shell/build-sections.js +14 -1
  166. package/dist/features/shipments/components/AdminShipmentLotItemsView.js +54 -1
  167. package/dist/features/shipments/components/AdminShipmentProjectionsView.js +3 -2
  168. package/dist/features/sticker-trace/components/StickerCommitPanel.js +2 -1
  169. package/dist/features/sticker-trace/components/StickerPrintPanel.js +2 -1
  170. package/dist/features/sticker-trace/components/StickerRetryPanel.js +24 -4
  171. package/dist/features/sticker-trace/components/StickerTraceStudio.js +2 -1
  172. package/dist/features/sticker-trace/server.d.ts +2 -0
  173. package/dist/features/tester/components/AdminTesterFeedbackIssuesView.js +3 -2
  174. package/dist/http/api-handler.js +4 -1
  175. package/dist/index.d.ts +8 -4
  176. package/dist/index.js +6 -4
  177. package/dist/next/routing/route-map.d.ts +4 -2
  178. package/dist/next/routing/route-map.js +19 -1
  179. package/dist/schemas/registry.d.ts +15 -10
  180. package/dist/schemas/sieve.d.ts +3 -3
  181. package/dist/server.d.ts +1 -2
  182. package/dist/server.js +6 -4
  183. package/dist/styles.css +51 -0
  184. package/dist/ui/components/Checkbox.js +10 -2
  185. package/dist/ui/components/DateInput.js +8 -1
  186. package/dist/ui/components/Input.js +22 -2
  187. package/dist/ui/components/PaginatedSelect.d.ts +8 -19
  188. package/dist/ui/components/PaginatedSelect.js +0 -0
  189. package/dist/ui/components/Radio.js +8 -1
  190. package/dist/ui/components/Select.js +10 -2
  191. package/dist/ui/components/TagInput.d.ts +8 -1
  192. package/dist/ui/components/TagInput.js +4 -3
  193. package/dist/ui/components/Textarea.js +10 -2
  194. package/dist/ui/forms/FieldCheckbox.js +1 -1
  195. package/dist/ui/forms/FormErrorList.d.ts +0 -16
  196. package/dist/ui/forms/FormErrorList.js +34 -1
  197. package/dist/ui/forms/apply-zod-issues.d.ts +19 -1
  198. package/dist/ui/forms/apply-zod-issues.js +23 -1
  199. package/package.json +1 -1
  200. package/scripts/seed-cli.mjs +1822 -1812
  201. package/dist/features/tester/utils/phases.d.ts +0 -48
  202. package/dist/features/tester/utils/phases.js +0 -139
@@ -211,6 +211,8 @@ export function DataListingView({ config, }) {
211
211
  * which is the same as having no label at all.
212
212
  */
213
213
  const rowEditLabel = React.useCallback((row) => {
214
+ // `string | undefined`, not `unknown`: a row primary is a label or it
215
+ // is absent, and the `typeof` test below is a real narrowing either way.
214
216
  const primary = row.primary;
215
217
  const name = typeof primary === "string" && primary.trim() ? primary.trim() : row.id;
216
218
  return `${ROW_ACTION_META[ROW_ACTION_ID.EDIT].label} ${name}`;
@@ -64,7 +64,7 @@ export function ReferenceRecordEditor({ endpoint, noun, fields, publicHref, }) {
64
64
  catch (err) {
65
65
  const e = normalizeError(err);
66
66
  if (!cancelled)
67
- setLoadError(e.message);
67
+ setLoadError(toUserMessage(e?.code, undefined, { fallback: "Could not load that record." }));
68
68
  }
69
69
  finally {
70
70
  if (!cancelled)
@@ -79,7 +79,10 @@ export function AdminStatCard({ label, value, sub, icon, gradient = "brand", cla
79
79
  : `${up ? "up" : "down"} ${Math.abs(trendPercent).toFixed(1).replace(/\.0$/, "")}%`;
80
80
  // The sparkline's colour follows the same good/bad rule, not its slope.
81
81
  const sparkRising = good || (flat && higherIsBetter);
82
- return (_jsxs(Div, { className: `relative border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] overflow-hidden ${className}`, rounded: "xl", shadow: "sm-hover-md", children: [_jsx(DynamicBgDiv, { background: resolvedGradient, className: "absolute top-0 left-0 right-0 h-[3px]", "aria-hidden": "true" }), _jsxs(Row, { paddingX: "x-5", paddingY: "b-md-lg", padding: "t-lg", align: "start", justify: "between", gap: "3", children: [_jsxs(Div, { className: "min-w-0 flex-1", children: [_jsx(Text, { className: "text-[11px] tracking-widest text-[var(--appkit-color-text-muted)]", weight: "semibold", transform: "uppercase", children: label }), _jsx(Heading, { level: 3, className: "mt-2 tabular-nums leading-none text-[var(--appkit-color-text)]", size: "2xl", weight: "bold", children: value }), sub && (_jsx(Text, { className: "mt-1.5 text-[var(--appkit-color-text-muted)]", size: "xs", children: sub }))] }), icon && (_jsx(DynamicBgDiv, { background: resolvedGradient, className: "flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-lg text-[length:var(--appkit-text-xl)]", children: icon }))] }), (showTrend || showSpark) && (_jsxs(Row, { paddingX: "x-5", padding: "b-md", align: "center", justify: "between", gap: "3", children: [showTrend ? (_jsxs(Text, { size: "xs", color: trendTone, weight: "semibold", children: [_jsxs("span", { "aria-hidden": "true", children: [trendArrow, " "] }), trendText, trendLabel ? (_jsxs(Text, { as: "span", size: "xs", color: "muted", weight: "normal", children: [" ", trendLabel] })) : null] })) : (_jsx("span", {})), showSpark && (
82
+ return (_jsxs(Div, { className: `relative border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] overflow-hidden ${className}`, rounded: "xl", shadow: "sm-hover-md", children: [_jsx(DynamicBgDiv, { background: resolvedGradient, className: "absolute top-0 left-0 right-0 h-[3px]", "aria-hidden": "true" }), _jsxs(Row, { paddingX: "x-5", paddingY: "b-md-lg", padding: "t-lg", align: "start", justify: "between", gap: "3", children: [_jsxs(Div, { className: "min-w-0 flex-1", children: [_jsx(Text, { className: "text-[11px] tracking-widest text-[var(--appkit-color-text-muted)]", weight: "semibold", transform: "uppercase", children: label }), _jsx(Heading, { level: 3, className: "mt-2 tabular-nums leading-none text-[var(--appkit-color-text)]", size: "2xl", weight: "bold", children: value }), sub && (_jsx(Text, { className: "mt-1.5 text-[var(--appkit-color-text-muted)]", size: "xs", children: sub }))] }), icon && (_jsx(DynamicBgDiv, { background: resolvedGradient, className: "flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-lg text-[length:var(--appkit-text-xl)]", children: icon }))] }), (showTrend || showSpark) && (_jsxs(Row, { paddingX: "x-5", padding: "b-md", align: "center", justify: "between", gap: "3", children: [showTrend ? (_jsxs(Text, { size: "xs", color: trendTone, weight: "semibold", children: [_jsxs(Text, { as: "span", "aria-hidden": "true", children: [trendArrow, " "] }), trendText, trendLabel ? (_jsxs(Text, { as: "span", size: "xs", color: "muted", weight: "normal", children: [" ", trendLabel] })) : null] })) : (
83
+ /* A layout placeholder keeping the grid cell occupied — <Div> is
84
+ the primitive for that; a bare <span/> carries no semantics. */
85
+ _jsx(Div, {})), showSpark && (
83
86
  /*
84
87
  🛑 `aria-hidden`, with NO accessible label. A sparkline is a
85
88
  decorative restatement of a trend already given in words
@@ -0,0 +1,33 @@
1
+ import { type QueryKey } from "@tanstack/react-query";
2
+ export interface UseOptimisticRowFlagOptions<T> {
3
+ /**
4
+ * Performs the write. Receives the row id and the value being set.
5
+ *
6
+ * 🛑 Send ONLY the field being changed. Several of this hook's consumers sit
7
+ * over repositories that merge a `config`/nested object by READING the
8
+ * document first; omitting that field short-circuits the read, so a toggle
9
+ * costs one write and no read.
10
+ */
11
+ mutate: (id: string, next: T) => Promise<unknown>;
12
+ /**
13
+ * Query keys to invalidate. Pass every key that renders this field —
14
+ * several of these records are read by a PUBLIC surface under a key of its
15
+ * own, and forgetting it leaves the storefront stale until its own TTL.
16
+ */
17
+ invalidate: readonly QueryKey[];
18
+ /** Toast copy. Receives the value that was set. */
19
+ successMessage: (next: T) => string;
20
+ /** Fallback when the thrown error carries no message. */
21
+ errorMessage?: string;
22
+ }
23
+ export interface UseOptimisticRowFlagReturn<T> {
24
+ /** `overrides[id] ?? row.value` — the value to render. */
25
+ overrides: Record<string, T>;
26
+ /** Resolve a row's current value against any pending override. */
27
+ resolve: (id: string, serverValue: T) => T;
28
+ /** The id currently being written, for a per-row disabled state. */
29
+ pendingId: string | null;
30
+ /** Fire the change. Safe to call from an `onChange` without awaiting. */
31
+ set: (id: string, next: T) => Promise<void>;
32
+ }
33
+ export declare function useOptimisticRowFlag<T>({ mutate, invalidate, successMessage, errorMessage, }: UseOptimisticRowFlagOptions<T>): UseOptimisticRowFlagReturn<T>;
@@ -0,0 +1,87 @@
1
+ "use client";
2
+ /*
3
+ * WHY: Eight listing views grew the same per-row "flip one field" control in
4
+ * one change — homepage sections (`enabled`), carousel slides (`active`),
5
+ * the six seller listing-type pages (`status`), seller bundles, grouped
6
+ * listings, store categories, shipping configs and payout methods
7
+ * (`isActive`). Every one needs an optimistic override so the switch
8
+ * moves under the finger, a PATCH, an invalidate, and a rollback.
9
+ *
10
+ * That is well past the Duplication Framework's Rule of Three, and the
11
+ * bug-fix multiplier is the real argument: the rollback is subtly wrong
12
+ * in the shape everyone writes first.
13
+ *
14
+ * 🛑 THE ROLLBACK BUG THIS EXISTS TO MAKE UNREPEATABLE
15
+ *
16
+ * The obvious implementation captures the previous override and writes it
17
+ * back on failure:
18
+ *
19
+ * const prev = overrides[id]; // `undefined` on a row's
20
+ * setOverrides((o) => ({...o, [id]: v})); // FIRST toggle
21
+ * catch { setOverrides((o) => ({...o, [id]: prev})); }
22
+ *
23
+ * `prev` is `undefined` the first time any given row is toggled, and writing
24
+ * `undefined` back is not the same as removing the key — a reader doing
25
+ * `overrides[id] ?? row.value` gets `undefined`, which for a boolean renders
26
+ * as `false`. So a FAILED toggle of a row the server holds as `true` leaves
27
+ * the UI asserting the opposite of the truth, which is worse than not having
28
+ * rolled back at all. `AdminProductsView.tsx` has this shape.
29
+ *
30
+ * The fix is to DELETE the key, which is what `clear` below does.
31
+ *
32
+ * 🛑 The key is cleared on SUCCESS too, and only AFTER the invalidation has
33
+ * settled. `invalidateQueries` resolves once active refetches finish, so by
34
+ * then `mapRows` is already reading the fresh server value and there is no
35
+ * visible flip-back-then-forward. An override that is never cleared pins the
36
+ * row forever and masks every later server-side change to it.
37
+ *
38
+ * EXPORTS: useOptimisticRowFlag, UseOptimisticRowFlagOptions
39
+ *
40
+ * @tag domain:admin,seller
41
+ * @tag layer:hook
42
+ * @tag pattern:none
43
+ * @tag access:client
44
+ * @tag consumers:AdminSectionsView,AdminCarouselView,SellerListingTypeView,SellerBundlesView,SellerGroupedListingsView,SellerStoreCategoriesView,SellerShippingConfigsView,SellerPayoutMethodsView
45
+ * @tag sideEffects:network
46
+ */
47
+ import React from "react";
48
+ import { useQueryClient } from "@tanstack/react-query";
49
+ import { useToast } from "../../../ui/index.js";
50
+ import { normalizeError } from "../../../errors/normalize.js";
51
+ import { toUserMessage } from "../../../errors/error-display-map.js";
52
+ export function useOptimisticRowFlag({ mutate, invalidate, successMessage, errorMessage = "Could not save that change.", }) {
53
+ const queryClient = useQueryClient();
54
+ const { showToast } = useToast();
55
+ const [overrides, setOverrides] = React.useState({});
56
+ const [pendingId, setPendingId] = React.useState(null);
57
+ const clear = React.useCallback((id) => {
58
+ setOverrides((prev) => {
59
+ if (!(id in prev))
60
+ return prev;
61
+ const next = { ...prev };
62
+ // DELETE, never write back a captured value — see the header.
63
+ delete next[id];
64
+ return next;
65
+ });
66
+ }, []);
67
+ const set = React.useCallback(async (id, next) => {
68
+ setOverrides((prev) => ({ ...prev, [id]: next }));
69
+ setPendingId(id);
70
+ try {
71
+ await mutate(id, next);
72
+ await Promise.all(invalidate.map((queryKey) => queryClient.invalidateQueries({ queryKey })));
73
+ clear(id);
74
+ showToast(successMessage(next), "success");
75
+ }
76
+ catch (err) {
77
+ const normalized = normalizeError(err);
78
+ clear(id);
79
+ showToast(toUserMessage(normalized.code, undefined, { fallback: errorMessage }), "error");
80
+ }
81
+ finally {
82
+ setPendingId(null);
83
+ }
84
+ }, [mutate, invalidate, queryClient, clear, showToast, successMessage, errorMessage]);
85
+ const resolve = React.useCallback((id, serverValue) => (id in overrides ? overrides[id] : serverValue), [overrides]);
86
+ return { overrides, resolve, pendingId, set };
87
+ }
@@ -30,6 +30,21 @@ export declare class NotificationRepository extends BaseRepository<NotificationD
30
30
  * Get unread notification count for a user.
31
31
  */
32
32
  getUnreadCount(userId: string): Promise<number>;
33
+ /**
34
+ * Mark a single notification UNREAD again.
35
+ *
36
+ * 🛑 `readAt` is cleared, not left behind. It is the timestamp of a read
37
+ * that the caller is now saying did not happen, and a row that is
38
+ * `isRead: false` with a `readAt` set is a contradiction the next reader has
39
+ * to resolve — the digest and the unread badge key on `isRead`, so they
40
+ * would disagree with anything that looked at `readAt`.
41
+ *
42
+ * There was no way to do this at all: the buyer's notifications page offered
43
+ * only Mark-read, and the PATCH route hard-coded `markAsRead(id)` without
44
+ * reading a body — so a notification clicked by accident was gone from the
45
+ * unread list for good.
46
+ */
47
+ markAsUnread(id: string): Promise<NotificationDocument | null>;
33
48
  /**
34
49
  * Mark a single notification as read.
35
50
  */
@@ -59,6 +59,38 @@ export class NotificationRepository extends BaseRepository {
59
59
  return 0;
60
60
  }
61
61
  }
62
+ /**
63
+ * Mark a single notification UNREAD again.
64
+ *
65
+ * 🛑 `readAt` is cleared, not left behind. It is the timestamp of a read
66
+ * that the caller is now saying did not happen, and a row that is
67
+ * `isRead: false` with a `readAt` set is a contradiction the next reader has
68
+ * to resolve — the digest and the unread badge key on `isRead`, so they
69
+ * would disagree with anything that looked at `readAt`.
70
+ *
71
+ * There was no way to do this at all: the buyer's notifications page offered
72
+ * only Mark-read, and the PATCH route hard-coded `markAsRead(id)` without
73
+ * reading a body — so a notification clicked by accident was gone from the
74
+ * unread list for good.
75
+ */
76
+ async markAsUnread(id) {
77
+ try {
78
+ const now = new Date();
79
+ await this.getCollection()
80
+ .doc(id)
81
+ .update(prepareForFirestore({
82
+ [NOTIFICATION_FIELDS.IS_READ]: false,
83
+ [NOTIFICATION_FIELDS.READ_AT]: null,
84
+ [NOTIFICATION_FIELDS.UPDATED_AT]: now,
85
+ }));
86
+ return this.findById(id);
87
+ }
88
+ catch (error) {
89
+ void normalizeError(error);
90
+ serverLogger.error("Failed to mark notification unread", { id, error });
91
+ throw error;
92
+ }
93
+ }
62
94
  /**
63
95
  * Mark a single notification as read.
64
96
  */
@@ -1,3 +1,4 @@
1
+ import type { FirestoreDocument } from "../../../schemas/types";
1
2
  /**
2
3
  * Site Settings Repository
3
4
  *
@@ -26,7 +27,7 @@ import { SiteSettingsDocument, SiteSettingsCredentialsMasked, SiteSettingsUpdate
26
27
  * anything whose prototype is not `Object.prototype`, which also covers
27
28
  * `FieldValue` sentinels and `Timestamp` itself.
28
29
  */
29
- export declare function toDottedPaths(input: Record<string, unknown>, prefix?: string): Record<string, unknown>;
30
+ export declare function toDottedPaths(input: FirestoreDocument, prefix?: string): FirestoreDocument;
30
31
  /**
31
32
  * Repository for site settings management
32
33
  *
@@ -38,6 +38,13 @@ import { decryptSecret, encryptSecret, maskSecret } from "../../../security/inde
38
38
  * anything whose prototype is not `Object.prototype`, which also covers
39
39
  * `FieldValue` sentinels and `Timestamp` itself.
40
40
  */
41
+ /*
42
+ * `FirestoreDocument`, not `Record<string, unknown>` — its own docstring says
43
+ * "use as the document shape for Firestore-backed repositories and mapDoc
44
+ * helpers". `unknown` values made every branch below unchecked, which is what
45
+ * audit-unknown-leakage reports; `FirestoreValue` admits exactly what Firestore
46
+ * round-trips (JSON + Date + undefined) and nothing else.
47
+ */
41
48
  export function toDottedPaths(input, prefix = "") {
42
49
  const out = {};
43
50
  for (const [key, value] of Object.entries(input)) {
@@ -0,0 +1,34 @@
1
+ import { z } from "zod";
2
+ import type { QuickFieldDef } from "../../shell/QuickFormDrawer";
3
+ /**
4
+ * 🛑 `keywords` is a COMMA-SEPARATED STRING here, not an array.
5
+ *
6
+ * `QuickFormDrawer` has no array control — its field types are text / number
7
+ * / select / toggle / date / textarea / email / url. Declaring `z.string()`
8
+ * and splitting at the call site keeps the schema honest about what the form
9
+ * actually produces, rather than describing a shape no control can emit.
10
+ * `splitKeywords` below is the one place that conversion happens.
11
+ */
12
+ export declare const actionIndexOverrideSchema: z.ZodObject<{
13
+ label: z.ZodString;
14
+ description: z.ZodOptional<z.ZodString>;
15
+ keywords: z.ZodOptional<z.ZodString>;
16
+ weight: z.ZodOptional<z.ZodNumber>;
17
+ }, "strip", z.ZodTypeAny, {
18
+ label: string;
19
+ weight?: number | undefined;
20
+ description?: string | undefined;
21
+ keywords?: string | undefined;
22
+ }, {
23
+ label: string;
24
+ weight?: number | undefined;
25
+ description?: string | undefined;
26
+ keywords?: string | undefined;
27
+ }>;
28
+ export type ActionIndexOverrideFormInput = z.infer<typeof actionIndexOverrideSchema>;
29
+ /** The drawer's fields, in the order an admin reads them. */
30
+ export declare const ACTION_INDEX_OVERRIDE_FIELDS: QuickFieldDef[];
31
+ /** The single place the comma-separated field becomes the stored array. */
32
+ export declare function splitKeywords(raw: string | undefined): string[] | undefined;
33
+ /** The reverse, for seeding the form from a stored override. */
34
+ export declare function joinKeywords(keywords: readonly string[] | undefined): string;
@@ -0,0 +1,115 @@
1
+ /*
2
+ * WHY: `/admin/action-index` told admins "Labels, descriptions and keywords
3
+ * can be edited here" while offering only a Shown toggle. The server has
4
+ * always accepted those fields — `ActionIndexOverride` carries `label`,
5
+ * `description`, `keywords` and `weight`, and `PATCH
6
+ * /api/admin/action-index` writes them through `setOverride`. The UI had
7
+ * simply never grown the control, so the sentence was a promise nothing
8
+ * kept.
9
+ *
10
+ * WHAT: The write contract for ONE entry override, as edited from the admin
11
+ * row action.
12
+ *
13
+ * ## What is deliberately absent
14
+ *
15
+ * `href`, `kind`, `portal` and every permission field. The view's own
16
+ * docstring draws this line and it is the right one: repointing a built-in
17
+ * route is routing, and relaxing a permission is access control. Neither
18
+ * belongs in a quick-edit drawer beside a label.
19
+ *
20
+ * `enabled` is also absent — it has its own control in the Shown column, and
21
+ * a second place to set it is a second source of truth for one boolean.
22
+ *
23
+ * EXPORTS:
24
+ * actionIndexOverrideSchema, ActionIndexOverrideFormInput,
25
+ * ACTION_INDEX_OVERRIDE_FIELDS
26
+ *
27
+ * @tag domain:admin,search
28
+ * @tag layer:schema
29
+ * @tag pattern:none
30
+ * @tag access:isomorphic
31
+ * @tag consumers:AdminActionIndexView
32
+ * @tag sideEffects:none
33
+ */
34
+ import { z } from "zod";
35
+ /**
36
+ * 🛑 `keywords` is a COMMA-SEPARATED STRING here, not an array.
37
+ *
38
+ * `QuickFormDrawer` has no array control — its field types are text / number
39
+ * / select / toggle / date / textarea / email / url. Declaring `z.string()`
40
+ * and splitting at the call site keeps the schema honest about what the form
41
+ * actually produces, rather than describing a shape no control can emit.
42
+ * `splitKeywords` below is the one place that conversion happens.
43
+ */
44
+ export const actionIndexOverrideSchema = z.object({
45
+ label: z
46
+ .string()
47
+ .trim()
48
+ .min(1, "A label is required — it is what the sidebar and search show.")
49
+ .max(80, "Keep labels under 80 characters so they fit the sidebar."),
50
+ description: z
51
+ .string()
52
+ .trim()
53
+ .max(200, "Keep descriptions under 200 characters.")
54
+ .optional(),
55
+ keywords: z
56
+ .string()
57
+ .trim()
58
+ .max(300, "Keep the keyword list under 300 characters.")
59
+ .optional(),
60
+ /*
61
+ * Ranking, not ordering. Higher sorts first in the search and the command
62
+ * palette. Bounded because an unbounded weight is how one entry ends up
63
+ * permanently above every other and nobody remembers why.
64
+ */
65
+ weight: z.coerce
66
+ .number()
67
+ .int("Weight must be a whole number.")
68
+ .min(0, "Weight cannot be negative.")
69
+ .max(100, "Weight is capped at 100.")
70
+ .optional(),
71
+ });
72
+ /** The drawer's fields, in the order an admin reads them. */
73
+ export const ACTION_INDEX_OVERRIDE_FIELDS = [
74
+ {
75
+ name: "label",
76
+ label: "Label",
77
+ type: "text",
78
+ required: true,
79
+ helperText: "What the sidebar, header search and command palette show.",
80
+ },
81
+ {
82
+ name: "description",
83
+ label: "Description",
84
+ type: "textarea",
85
+ placeholder: "One line explaining what this entry is for",
86
+ },
87
+ {
88
+ name: "keywords",
89
+ label: "Keywords",
90
+ type: "text",
91
+ placeholder: "refunds, returns, money back",
92
+ helperText: "Comma-separated. Extra words that should find this entry in search.",
93
+ },
94
+ {
95
+ name: "weight",
96
+ label: "Weight",
97
+ type: "number",
98
+ helperText: "Higher sorts first in search results. 0–100.",
99
+ },
100
+ ];
101
+ /** The single place the comma-separated field becomes the stored array. */
102
+ export function splitKeywords(raw) {
103
+ if (raw === undefined)
104
+ return undefined;
105
+ const parts = raw
106
+ .split(",")
107
+ .map((part) => part.trim())
108
+ .filter(Boolean);
109
+ // An empty box means "no keywords", which is `[]` — not "leave them alone".
110
+ return parts;
111
+ }
112
+ /** The reverse, for seeding the form from a stored override. */
113
+ export function joinKeywords(keywords) {
114
+ return (keywords ?? []).join(", ");
115
+ }
@@ -202,5 +202,5 @@ export function MarketplaceAuctionCard({ product, buyoutPrice, className = "", v
202
202
  * Sibling of the card, never a child — see the prop's docstring. `z-20`
203
203
  * clears this card's own overlays, which all sit at `z-10`.
204
204
  */
205
- return (_jsxs(Div, { className: "flex h-full flex-col", children: [card, _jsx(Div, { className: "flex justify-end px-[var(--appkit-space-3)] pb-[var(--appkit-space-3)]", children: ownerActions })] }));
205
+ return (_jsxs(Div, { layout: "flex-col", className: "h-full", children: [card, _jsx(Div, { layout: "flex", justify: "end", className: "px-[var(--appkit-space-3)] pb-[var(--appkit-space-3)]", children: ownerActions })] }));
206
206
  }
@@ -11,6 +11,7 @@ import { FormShellContext, useFormShellState } from "../../../ui/forms/FormShell
11
11
  import { registerSchema, registerPasswordSchema } from "../schemas/index.js";
12
12
  import { FormErrorSummary } from "../../../ui/forms/FormErrorSummary.js";
13
13
  import { SectionForm, useSectionFormNav } from "../../shell/SectionForm.js";
14
+ import { toUserMessage } from "../../../errors/error-display-map.js";
14
15
  const registerClientSchema = registerSchema.extend({
15
16
  displayName: z.string().min(1, "Enter your name"),
16
17
  confirmPassword: registerPasswordSchema,
@@ -81,7 +82,7 @@ export function RegisterForm({ onSubmit, isLoading = false, error, success, labe
81
82
  * email, a rejected password — which is the one thing they can act on.
82
83
  */
83
84
  const normalized = normalizeError(err);
84
- setFieldError("email", normalized.message || "Registration failed");
85
+ setFieldError("email", toUserMessage(normalized.code, undefined, { fallback: "We couldn't create your account. Please check your details and try again." }));
85
86
  }
86
87
  };
87
88
  return (_jsx(Row, { className: `min-h-[60vh] ${className}`, align: "center", justify: "center", padding: "x-md", children: _jsxs(Stack, { className: "max-w-md w-full", gap: "lg", children: [_jsxs(Div, { className: "text-center", children: [_jsx(Row, { justify: "center", className: "mb-3", children: _jsx(SiteMark, { size: "lg" }) }), _jsx(Heading, { level: 1, className: "font-extrabold", size: "3xl", children: labels.title ?? "Create Account" }), (labels.subtitle || renderLoginLink) && (_jsxs(Text, { className: "mt-2 text-[var(--appkit-color-text-muted)]", size: "sm", children: [labels.subtitle ?? "Already have an account?", " ", renderLoginLink?.()] }))] }), success && (_jsx(Alert, { variant: "success", compact: true, children: success })), error && (_jsx(Alert, { variant: "error", compact: true, children: error })), _jsxs(FormShellContext.Provider, { value: shellCtx, children: [_jsx(FormErrorSummary, {}), _jsx(SectionForm, { sections: sections, values: values, onChange: (partial) => setValues((prev) => Object.assign({}, prev, partial)), onSubmit: handleSubmit, schema: registerClientSchema, openIds: nav.openIds, onOpenChange: nav.setOpenIds, isLoading: isLoading, submitLabel: isLoading
@@ -1,4 +1,4 @@
1
- import { type ActionResult } from "@mohasinac/appkit/server";
1
+ import { type ActionResult } from "../../../_internal/shared/types/action-result";
2
2
  /**
3
3
  * Admin path — approves a buyer's "Request to sell", creating the product
4
4
  * under the platform's own consignment store (not the buyer's, since a
@@ -1,5 +1,16 @@
1
1
  "use server";
2
- import { wrapAction } from "@mohasinac/appkit/server";
2
+ /*
3
+ * 🛑 From the DEFINING module, never "@mohasinac/appkit/server".
4
+ *
5
+ * appkit importing its own public server barrel is a self-reference that drags
6
+ * the whole server surface in behind it — `dist/server.js` reaches next/og,
7
+ * next/navigation, next/headers and driver.js. Twenty `actions.ts` files did
8
+ * this for two symbols, and because `obligationSweep` reaches one of them, the
9
+ * Firebase Functions tsup bundle (noExternal: appkit) had to resolve Next:
10
+ * it failed on two `react-server-dom-webpack/*` requires and two @vercel/og
11
+ * .wasm files, naming none of the responsible files. Root Cause #18.
12
+ */
13
+ import { wrapAction } from "../../../_internal/shared/types/action-result.js";
3
14
  import { catalogueRepository } from "../../../repositories/index.js";
4
15
  import { requireRoleUser } from "../../../providers/auth-firebase/helpers.js";
5
16
  import { isAdminUser } from "../../../features/auth/role-predicates.js";
@@ -1,4 +1,4 @@
1
- import { type ActionResult } from "@mohasinac/appkit/server";
1
+ import { type ActionResult } from "../../../_internal/shared/types/action-result";
2
2
  /**
3
3
  * Direct-listing path — turns a catalogue item straight into a real listing,
4
4
  * no approval queue. Two callers:
@@ -1,5 +1,16 @@
1
1
  "use server";
2
- import { wrapAction } from "@mohasinac/appkit/server";
2
+ /*
3
+ * 🛑 From the DEFINING module, never "@mohasinac/appkit/server".
4
+ *
5
+ * appkit importing its own public server barrel is a self-reference that drags
6
+ * the whole server surface in behind it — `dist/server.js` reaches next/og,
7
+ * next/navigation, next/headers and driver.js. Twenty `actions.ts` files did
8
+ * this for two symbols, and because `obligationSweep` reaches one of them, the
9
+ * Firebase Functions tsup bundle (noExternal: appkit) had to resolve Next:
10
+ * it failed on two `react-server-dom-webpack/*` requires and two @vercel/og
11
+ * .wasm files, naming none of the responsible files. Root Cause #18.
12
+ */
13
+ import { wrapAction } from "../../../_internal/shared/types/action-result.js";
3
14
  import { catalogueRepository, storeRepository } from "../../../repositories/index.js";
4
15
  import { requireRoleUser } from "../../../providers/auth-firebase/helpers.js";
5
16
  import { isAdminUser } from "../../../features/auth/role-predicates.js";
@@ -1,4 +1,4 @@
1
- import { type ActionResult } from "@mohasinac/appkit/server";
1
+ import { type ActionResult } from "../../../_internal/shared/types/action-result";
2
2
  /**
3
3
  * Buyer path — requests admin list the item on their behalf. Flips
4
4
  * listingStatus to "pending_admin_approval"; the admin-notification fan-out
@@ -1,5 +1,16 @@
1
1
  "use server";
2
- import { wrapAction } from "@mohasinac/appkit/server";
2
+ /*
3
+ * 🛑 From the DEFINING module, never "@mohasinac/appkit/server".
4
+ *
5
+ * appkit importing its own public server barrel is a self-reference that drags
6
+ * the whole server surface in behind it — `dist/server.js` reaches next/og,
7
+ * next/navigation, next/headers and driver.js. Twenty `actions.ts` files did
8
+ * this for two symbols, and because `obligationSweep` reaches one of them, the
9
+ * Firebase Functions tsup bundle (noExternal: appkit) had to resolve Next:
10
+ * it failed on two `react-server-dom-webpack/*` requires and two @vercel/og
11
+ * .wasm files, naming none of the responsible files. Root Cause #18.
12
+ */
13
+ import { wrapAction } from "../../../_internal/shared/types/action-result.js";
3
14
  import { catalogueRepository } from "../../../repositories/index.js";
4
15
  import { requireRoleUser } from "../../../providers/auth-firebase/helpers.js";
5
16
  import { ValidationError, AuthorizationError } from "../../../errors/index.js";
@@ -95,11 +95,11 @@ export function CatalogueItemEditorView({ item, onSaved }) {
95
95
  // than pointing at a photo the user has since removed.
96
96
  onChange({ images: urls, mainImage: urls[0] ?? "" });
97
97
  }, onUpload: handleUpload, accept: IMAGE_ACCEPT_ATTR, maxItems: 8, helperText: "Photos older than 30 days must be refreshed before this item can be listed." })),
98
- categorySlugs: ({ values, onChange }) => (
98
+ categorySlugs: ({ values, onChange, errors }) => (
99
99
  // Explicit `<string>`: with `multiple`, `value` is `V[]`, so an
100
100
  // inferred V reads the array itself as the option type.
101
- _jsx(PaginatedSelect, { multiple: true, value: values.categorySlugs, onChange: (v) => onChange({ categorySlugs: v ?? [] }), loadOptions: loadCategoryOptions, placeholder: "Search categories\u2026", searchPlaceholder: "Type a category name\u2026", noResultsText: "No categories found", ariaLabel: "Categories" })),
102
- brandSlug: ({ values, onChange }) => (_jsx(PaginatedSelect, { value: values.brandSlug || null, onChange: (v) => onChange({ brandSlug: v ?? "" }), loadOptions: loadBrandOptions, placeholder: "Search brands\u2026", searchPlaceholder: "Type a brand name\u2026", noResultsText: "No brands found", ariaLabel: "Brand" })),
101
+ _jsx(PaginatedSelect, { multiple: true, name: "categorySlugs", error: errors.categorySlugs, value: values.categorySlugs, onChange: (v) => onChange({ categorySlugs: v ?? [] }), loadOptions: loadCategoryOptions, placeholder: "Search categories\u2026", searchPlaceholder: "Type a category name\u2026", noResultsText: "No categories found", ariaLabel: "Categories" })),
102
+ brandSlug: ({ values, onChange, errors }) => (_jsx(PaginatedSelect, { name: "brandSlug", error: errors.brandSlug, value: values.brandSlug || null, onChange: (v) => onChange({ brandSlug: v ?? "" }), loadOptions: loadBrandOptions, placeholder: "Search brands\u2026", searchPlaceholder: "Type a brand name\u2026", noResultsText: "No brands found", ariaLabel: "Brand" })),
103
103
  },
104
104
  }), [handleUpload]);
105
105
  const nav = useSectionFormNav(sections, form, { scope: "user:catalogue-item" });
@@ -113,6 +113,25 @@ export interface PriceObservationDocument {
113
113
  createdAt: Date;
114
114
  }
115
115
  export declare const PRICE_OBSERVATION_COLLECTION = "priceObservations";
116
+ /**
117
+ * Field names, so a query names them through a constant rather than a literal.
118
+ *
119
+ * `audit-route-nav-field-constants` R3 blocks a raw `where("categoryId", …)`,
120
+ * and the constants it suggested for this one were
121
+ * `MODEL_COMPOSITION_FIELDS.CATEGORY_ID | BEY_FIELDS.CATEGORY_ID` — two
122
+ * different documents that happen to share the field NAME. Borrowing either
123
+ * would make the query read as though it were about that document, which is
124
+ * worse than the literal it replaced. A price observation needs its own.
125
+ */
126
+ export declare const PRICE_OBSERVATION_FIELDS: {
127
+ readonly CATEGORY_ID: "categoryId";
128
+ readonly AMOUNT: "amount";
129
+ readonly CURRENCY: "currency";
130
+ readonly KIND: "kind";
131
+ readonly SEEN_AT: "seenAt";
132
+ readonly SOLD_OUT: "soldOut";
133
+ readonly SOURCE_KEY: "sourceKey";
134
+ };
116
135
  /**
117
136
  * Hard ceiling on one leaf's observation read.
118
137
  *
@@ -59,6 +59,25 @@
59
59
  */
60
60
  import { BaseRepository } from "../../../providers/db-firebase/index.js";
61
61
  export const PRICE_OBSERVATION_COLLECTION = "priceObservations";
62
+ /**
63
+ * Field names, so a query names them through a constant rather than a literal.
64
+ *
65
+ * `audit-route-nav-field-constants` R3 blocks a raw `where("categoryId", …)`,
66
+ * and the constants it suggested for this one were
67
+ * `MODEL_COMPOSITION_FIELDS.CATEGORY_ID | BEY_FIELDS.CATEGORY_ID` — two
68
+ * different documents that happen to share the field NAME. Borrowing either
69
+ * would make the query read as though it were about that document, which is
70
+ * worse than the literal it replaced. A price observation needs its own.
71
+ */
72
+ export const PRICE_OBSERVATION_FIELDS = {
73
+ CATEGORY_ID: "categoryId",
74
+ AMOUNT: "amount",
75
+ CURRENCY: "currency",
76
+ KIND: "kind",
77
+ SEEN_AT: "seenAt",
78
+ SOLD_OUT: "soldOut",
79
+ SOURCE_KEY: "sourceKey",
80
+ };
62
81
  /**
63
82
  * Hard ceiling on one leaf's observation read.
64
83
  *
@@ -88,8 +107,8 @@ export class PriceObservationRepository extends BaseRepository {
88
107
  */
89
108
  async findForLeaf(categoryId, limit = PRICE_OBSERVATION_SCAN_LIMIT) {
90
109
  const snap = await this.getCollection()
91
- .where("categoryId", "==", categoryId)
92
- .orderBy("seenAt", "desc")
110
+ .where(PRICE_OBSERVATION_FIELDS.CATEGORY_ID, "==", categoryId)
111
+ .orderBy(PRICE_OBSERVATION_FIELDS.SEEN_AT, "desc")
93
112
  .limit(limit)
94
113
  .get();
95
114
  return snap.docs.map((d) => this.mapDoc(d));
@@ -17,6 +17,7 @@ const ERR_EVENT_NOT_FOUND = "Event not found";
17
17
  import { coerceMediaField, getMediaUrl } from "../../../utils/index.js";
18
18
  import { finalizeStagedMediaField, finalizeStagedMediaObject, finalizeStagedMediaObjectArray, } from "../../media/finalize.js";
19
19
  import { hidePublicTestData } from "../../../_internal/server/features/tester/visibility.js";
20
+ import { sortPinnedFirst } from "../utils/sort-pinned.js";
20
21
  // --- Admin: Create Event --------------------------------------------------
21
22
  export async function createEvent(adminId, input) {
22
23
  const finalizedCoverUrl = await finalizeStagedMediaField(getMediaUrl(input.coverImage) ??
@@ -139,7 +140,9 @@ export async function listPublicEvents(params) {
139
140
  pageSize,
140
141
  });
141
142
  // PUBLIC by name — it backs the homepage strip and /events.
142
- return { ...result, items: hidePublicTestData(result.items) };
143
+ // `sortPinnedFirst` AFTER the test-data filter, so a pinned sandbox event
144
+ // cannot occupy the rail's first slot for a visitor who cannot see it.
145
+ return { ...result, items: sortPinnedFirst(hidePublicTestData(result.items)) };
143
146
  }
144
147
  /**
145
148
  * 🛑 ONLY `draft` IS HIDDEN. Every other status is public history.