@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
@@ -135,6 +135,7 @@ const DEFAULT_DRAFT = {
135
135
  status: "draft",
136
136
  createdBy: "",
137
137
  allowGuestParticipation: false,
138
+ isPinned: false,
138
139
  discountPercent: "10",
139
140
  saleBannerText: "",
140
141
  couponId: "",
@@ -211,6 +212,72 @@ function SpinPrizesEditor({ values, onChange, }) {
211
212
  return (_jsxs(Stack, { gap: "sm", rounded: "lg", border: "default", padding: "sm", children: [_jsx(Text, { size: "xs", weight: "medium", color: "muted", children: "Spin prizes (weighted random)" }), values.spinPrizes.length === 0 && (_jsx(Text, { size: "xs", color: "muted", children: "No spin prizes yet. Add at least one to enable spinning." })), values.spinPrizes.map((p) => (_jsxs(Grid, { align: "end", gap: "xs", className: "grid-cols-12", children: [_jsx(Div, { className: "col-span-5", children: _jsx(Input, { label: "Label", value: p.label, onChange: (e) => onChange({ spinPrizes: values.spinPrizes.map((sp) => sp.id === p.id ? { ...sp, label: e.target.value } : sp) }), placeholder: "\u20B9100 off" }) }), _jsxs(Div, { className: "col-span-3", children: [_jsx(Text, { size: "xs", weight: "medium", className: "mb-1", children: "Coupon" }), _jsx(CouponInlineSelect, { scope: "admin", value: p.couponId ?? "", onChange: (id) => onChange({ spinPrizes: values.spinPrizes.map((sp) => sp.id === p.id ? { ...sp, couponId: id || undefined } : sp) }), placeholder: "Search\u2026", allowCreate: false })] }), _jsx(Div, { className: "col-span-2", children: _jsx(Input, { label: "Weight", type: "number", value: String(p.weight), onChange: (e) => onChange({ spinPrizes: values.spinPrizes.map((sp) => sp.id === p.id ? { ...sp, weight: Number(e.target.value) || 0 } : sp) }) }) }), _jsx(Row, { centered: true, className: "col-span-1", padding: "b-xs", children: _jsx(Toggle, { checked: p.isActive, onChange: (v) => onChange({ spinPrizes: values.spinPrizes.map((sp) => sp.id === p.id ? { ...sp, isActive: v } : sp) }), label: "" }) }), _jsx(Row, { centered: true, className: "col-span-1", padding: "b-xs", children: _jsx(Button, { variant: "ghost", type: "button", onClick: () => onChange({ spinPrizes: values.spinPrizes.filter((sp) => sp.id !== p.id) }), className: CLS_REMOVE_BTN_LG, "aria-label": "Remove prize", children: "\u00D7" }) })] }, p.id))), _jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: () => onChange({ spinPrizes: [...values.spinPrizes, { id: `prize-${Date.now()}`, label: "", couponId: undefined, weight: 1, isActive: true }] }), children: "+ Add spin prize" }), _jsx(Input, { label: "Max spins per user", type: "number", value: values.spinMaxPerUser, onChange: (e) => onChange({ spinMaxPerUser: e.target.value }), placeholder: "1" }), _jsxs(Grid, { cols: 2, gap: "sm", children: [_jsx(Input, { label: "Spin window start", type: "datetime-local", value: values.spinWindowStart, onChange: (e) => onChange({ spinWindowStart: e.target.value }) }), _jsx(Input, { label: "Spin window end", type: "datetime-local", value: values.spinWindowEnd, onChange: (e) => onChange({ spinWindowEnd: e.target.value }) })] })] }));
212
213
  }
213
214
  // --- Component ---------------------------------------------------------------
215
+ /**
216
+ * The API's event shape -> the editor's draft shape.
217
+ *
218
+ * A pure adapter at module scope. The extraction is not cosmetic: this was
219
+ * 45 lines of field mapping inside a `useEffect` inside a 455-line
220
+ * component, which is exactly where a wrong default hides.
221
+ *
222
+ * Every `|| ""` and `Boolean()` is load-bearing. An ABSENT field — any event
223
+ * predating the one that added it — must still seed a CONTROLLED input;
224
+ * `undefined` makes React treat it as uncontrolled and the field stops
225
+ * accepting edits.
226
+ *
227
+ * 🛑 `requireLogin: event.surveyConfig?.requireLogin !== false` is the one
228
+ * default deliberately INVERTED — absent means true, because a survey that
229
+ * silently stopped requiring a login would collect anonymous entries nobody
230
+ * asked for. Do not "tidy" it into `Boolean(...)`.
231
+ */
232
+ function eventToDraft(event) {
233
+ return {
234
+ type: event.type || "sale",
235
+ title: event.title || "",
236
+ slug: event.slug || "",
237
+ description: event.description || "",
238
+ startsAt: toLocalDatetime(event.startsAt),
239
+ endsAt: toLocalDatetime(event.endsAt),
240
+ coverImageUrl: event.coverImageUrl || event.coverImage?.url || "",
241
+ tags: Array.isArray(event.tags) ? event.tags : [],
242
+ status: event.status || "draft",
243
+ createdBy: event.createdBy || "",
244
+ allowGuestParticipation: Boolean(event.allowGuestParticipation),
245
+ // `Boolean()` so an ABSENT field (every event predating the pin) seeds
246
+ // the toggle as off rather than leaving it `undefined`, which React
247
+ // would read as an uncontrolled input.
248
+ isPinned: Boolean(event.isPinned),
249
+ discountPercent: String(event.saleConfig?.discountPercent ?? 10),
250
+ saleBannerText: event.saleConfig?.bannerText || "",
251
+ couponId: event.offerConfig?.couponId || "",
252
+ displayCode: event.offerConfig?.displayCode || "",
253
+ offerBannerText: event.offerConfig?.bannerText || "",
254
+ pollOptions: event.pollConfig?.options?.length
255
+ ? event.pollConfig.options
256
+ : [{ id: "opt1", label: "" }, { id: "opt2", label: "" }],
257
+ allowMultiSelect: Boolean(event.pollConfig?.allowMultiSelect),
258
+ allowComment: Boolean(event.pollConfig?.allowComment),
259
+ resultsVisibility: event.pollConfig?.resultsVisibility || "always",
260
+ requireLogin: event.surveyConfig?.requireLogin !== false,
261
+ maxEntriesPerUser: String(event.surveyConfig?.maxEntriesPerUser ?? 1),
262
+ hasLeaderboard: Boolean(event.surveyConfig?.hasLeaderboard),
263
+ hasPointSystem: Boolean(event.surveyConfig?.hasPointSystem),
264
+ pointsLabel: event.surveyConfig?.pointsLabel ?? "Points",
265
+ entryReviewRequired: Boolean(event.surveyConfig?.entryReviewRequired),
266
+ surveyFields: Array.isArray(event.surveyConfig?.formFields) ? event.surveyConfig.formFields : [],
267
+ anonymous: Boolean(event.feedbackConfig?.anonymous),
268
+ feedbackFields: Array.isArray(event.feedbackConfig?.formFields) ? event.feedbackConfig.formFields : [],
269
+ hasRaffle: Boolean(event.hasRaffle),
270
+ raffleType: event.raffleType || "open_raffle",
271
+ raffleTopN: String(event.raffleTopN ?? 10),
272
+ rafflePrize: event.rafflePrize || "",
273
+ rafflePrizeCouponId: event.rafflePrizeCouponId || "",
274
+ rafflePrizeProductIds: Array.isArray(event.rafflePrizeProductIds) ? event.rafflePrizeProductIds : [],
275
+ spinPrizes: Array.isArray(event.spinPrizes) ? event.spinPrizes : [],
276
+ spinMaxPerUser: String(event.spinMaxPerUser ?? 1),
277
+ spinWindowStart: toLocalDatetime(event.spinWindowStart),
278
+ spinWindowEnd: toLocalDatetime(event.spinWindowEnd),
279
+ };
280
+ }
214
281
  export function AdminEventEditorView({ eventId, onSaved, embedded, allowedTypes, ...rest }) {
215
282
  const isEdit = Boolean(eventId);
216
283
  const [draft, setDraft] = React.useState(DEFAULT_DRAFT);
@@ -243,49 +310,8 @@ export function AdminEventEditorView({ eventId, onSaved, embedded, allowedTypes,
243
310
  const event = eventQuery.data?.data ?? eventQuery.data;
244
311
  if (!event?.id)
245
312
  return;
246
- setDraft({
247
- type: event.type || "sale",
248
- title: event.title || "",
249
- slug: event.slug || "",
250
- description: event.description || "",
251
- startsAt: toLocalDatetime(event.startsAt),
252
- endsAt: toLocalDatetime(event.endsAt),
253
- coverImageUrl: event.coverImageUrl || event.coverImage?.url || "",
254
- tags: Array.isArray(event.tags) ? event.tags : [],
255
- status: event.status || "draft",
256
- createdBy: event.createdBy || "",
257
- allowGuestParticipation: Boolean(event.allowGuestParticipation),
258
- discountPercent: String(event.saleConfig?.discountPercent ?? 10),
259
- saleBannerText: event.saleConfig?.bannerText || "",
260
- couponId: event.offerConfig?.couponId || "",
261
- displayCode: event.offerConfig?.displayCode || "",
262
- offerBannerText: event.offerConfig?.bannerText || "",
263
- pollOptions: event.pollConfig?.options?.length
264
- ? event.pollConfig.options
265
- : [{ id: "opt1", label: "" }, { id: "opt2", label: "" }],
266
- allowMultiSelect: Boolean(event.pollConfig?.allowMultiSelect),
267
- allowComment: Boolean(event.pollConfig?.allowComment),
268
- resultsVisibility: event.pollConfig?.resultsVisibility || "always",
269
- requireLogin: event.surveyConfig?.requireLogin !== false,
270
- maxEntriesPerUser: String(event.surveyConfig?.maxEntriesPerUser ?? 1),
271
- hasLeaderboard: Boolean(event.surveyConfig?.hasLeaderboard),
272
- hasPointSystem: Boolean(event.surveyConfig?.hasPointSystem),
273
- pointsLabel: event.surveyConfig?.pointsLabel ?? "Points",
274
- entryReviewRequired: Boolean(event.surveyConfig?.entryReviewRequired),
275
- surveyFields: Array.isArray(event.surveyConfig?.formFields) ? event.surveyConfig.formFields : [],
276
- anonymous: Boolean(event.feedbackConfig?.anonymous),
277
- feedbackFields: Array.isArray(event.feedbackConfig?.formFields) ? event.feedbackConfig.formFields : [],
278
- hasRaffle: Boolean(event.hasRaffle),
279
- raffleType: event.raffleType || "open_raffle",
280
- raffleTopN: String(event.raffleTopN ?? 10),
281
- rafflePrize: event.rafflePrize || "",
282
- rafflePrizeCouponId: event.rafflePrizeCouponId || "",
283
- rafflePrizeProductIds: Array.isArray(event.rafflePrizeProductIds) ? event.rafflePrizeProductIds : [],
284
- spinPrizes: Array.isArray(event.spinPrizes) ? event.spinPrizes : [],
285
- spinMaxPerUser: String(event.spinMaxPerUser ?? 1),
286
- spinWindowStart: toLocalDatetime(event.spinWindowStart),
287
- spinWindowEnd: toLocalDatetime(event.spinWindowEnd),
288
- });
313
+ // The 45-line field mapping lives in `eventToDraft` at module scope.
314
+ setDraft(eventToDraft(event));
289
315
  setSlugManual(true);
290
316
  setRaffleWinnerName(event.raffleWinnerDisplayName || "");
291
317
  setRaffleEntryCount(typeof event.raffleEntryCount === "number" ? event.raffleEntryCount : null);
@@ -304,6 +330,7 @@ export function AdminEventEditorView({ eventId, onSaved, embedded, allowedTypes,
304
330
  endsAt: toISOString(draft.endsAt),
305
331
  coverImageUrl: draft.coverImageUrl || undefined,
306
332
  allowGuestParticipation: draft.allowGuestParticipation,
333
+ isPinned: draft.isPinned,
307
334
  ...buildEventTypeConfig(draft),
308
335
  ...buildEventRaffleFields(draft),
309
336
  };
@@ -380,10 +407,10 @@ export function AdminEventEditorView({ eventId, onSaved, embedded, allowedTypes,
380
407
  // The per-step `validate` is gone: every rule it stated is already in
381
408
  // eventDraftSchema (title/startsAt/endsAt are .min(1)), and it only ever
382
409
  // acted as a gate to the next step.
383
- render: ({ values, onChange }) => (_jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Event Details" }), !isEdit && (_jsx(Select, { label: "Event type", value: values.type, options: allowedTypes ? EVENT_TYPE_OPTIONS.filter((o) => allowedTypes.includes(o.value)) : EVENT_TYPE_OPTIONS, onChange: (e) => onChange({ type: e.target.value }) })), _jsx(Input, { label: "Title", value: values.title, onChange: (e) => {
410
+ render: ({ values, onChange, errors }) => (_jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Event Details" }), !isEdit && (_jsx(Select, { label: "Event type", error: errors.type, value: values.type, options: allowedTypes ? EVENT_TYPE_OPTIONS.filter((o) => allowedTypes.includes(o.value)) : EVENT_TYPE_OPTIONS, onChange: (e) => onChange({ type: e.target.value }) })), _jsx(Input, { label: "Title", error: errors.title, value: values.title, onChange: (e) => {
384
411
  const v = e.target.value;
385
412
  onChange({ title: v, ...(!slugManual && { slug: toEventSlug(v) }) });
386
- }, placeholder: "Charizard Flash Sale 2026", required: true }), _jsx(Input, { label: "Slug", value: values.slug, onChange: (e) => { setSlugManual(true); onChange({ slug: e.target.value }); }, placeholder: "event-charizard-flash-sale-2026", helperText: "Auto-generated from title. Must start with 'event-'." }), _jsxs(Stack, { gap: "xs", children: [_jsx(Label, { children: "Description" }), _jsx(RichTextEditor, { value: values.description, onChange: (v) => onChange({ description: v }), minHeightClassName: "min-h-[120px]", placeholder: "Describe this event\u2026" })] }), _jsxs(Grid, { cols: 2, gap: "sm", children: [_jsx(Input, { label: "Starts at", type: "datetime-local", value: values.startsAt, onChange: (e) => onChange({ startsAt: e.target.value }), required: true }), _jsx(Input, { label: "Ends at", type: "datetime-local", value: values.endsAt, onChange: (e) => onChange({ endsAt: e.target.value }), required: true })] }), _jsx(TagInput, { label: "Tags", value: values.tags, onChange: (t) => onChange({ tags: t }), placeholder: "e.g. pokemon, sale, 2026" })] })),
413
+ }, placeholder: "Charizard Flash Sale 2026", required: true }), _jsx(Input, { label: "Slug", error: errors.slug, value: values.slug, onChange: (e) => { setSlugManual(true); onChange({ slug: e.target.value }); }, placeholder: "event-charizard-flash-sale-2026", helperText: "Auto-generated from title. Must start with 'event-'." }), _jsxs(Stack, { gap: "xs", children: [_jsx(Label, { children: "Description" }), _jsx(RichTextEditor, { value: values.description, onChange: (v) => onChange({ description: v }), minHeightClassName: "min-h-[120px]", placeholder: "Describe this event\u2026" })] }), _jsxs(Grid, { cols: 2, gap: "sm", children: [_jsx(Input, { label: "Starts at", error: errors.startsAt, type: "datetime-local", value: values.startsAt, onChange: (e) => onChange({ startsAt: e.target.value }), required: true }), _jsx(Input, { label: "Ends at", error: errors.endsAt, type: "datetime-local", value: values.endsAt, onChange: (e) => onChange({ endsAt: e.target.value }), required: true })] }), _jsx(TagInput, { label: "Tags", value: values.tags, onChange: (t) => onChange({ tags: t }), placeholder: "e.g. pokemon, sale, 2026" })] })),
387
414
  },
388
415
  {
389
416
  id: "media",
@@ -392,26 +419,28 @@ export function AdminEventEditorView({ eventId, onSaved, embedded, allowedTypes,
392
419
  // section mid-upload discards the transfer.
393
420
  keepMounted: true,
394
421
  fields: ["coverImageUrl"],
395
- render: ({ values, onChange }) => (_jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Media" }), _jsx(Input, { label: "Cover Image URL (optional)", value: values.coverImageUrl, onChange: (e) => onChange({ coverImageUrl: e.target.value }), placeholder: "https://\u2026", helperText: "Direct link to the event cover image." })] })),
422
+ render: ({ values, onChange, errors }) => (_jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Media" }), _jsx(Input, { label: "Cover Image URL (optional)", error: errors.coverImageUrl, value: values.coverImageUrl, onChange: (e) => onChange({ coverImageUrl: e.target.value }), placeholder: "https://\u2026", helperText: "Direct link to the event cover image." })] })),
396
423
  },
397
424
  {
398
425
  id: "settings",
399
426
  label: "Settings",
400
- fields: ["status", "allowGuestParticipation", "discountPercent", "saleBannerText", "couponId", "displayCode", "offerBannerText", "pollOptions", "resultsVisibility", "allowMultiSelect", "allowComment", "maxEntriesPerUser", "hasLeaderboard", "hasPointSystem", "pointsLabel", "entryReviewRequired", "surveyFields", "anonymous", "feedbackFields"],
427
+ fields: ["status", "allowGuestParticipation", "isPinned", "discountPercent", "saleBannerText", "couponId", "displayCode", "offerBannerText", "pollOptions", "resultsVisibility", "allowMultiSelect", "allowComment", "maxEntriesPerUser", "hasLeaderboard", "hasPointSystem", "pointsLabel", "entryReviewRequired", "surveyFields", "anonymous", "feedbackFields"],
401
428
  // Same: the offer-coupon, offer-display-code and poll-minimum-options
402
429
  // rules are all stated in eventDraftSchema's superRefine, which now
403
430
  // surfaces them inline on the field instead of as a step-blocking banner.
404
- render: ({ values, onChange }) => (_jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Event Settings" }), isEdit && (_jsx(Select, { label: "Status", value: values.status, options: EVENT_STATUS_OPTIONS, onChange: (e) => onChange({ status: e.target.value }) })), values.createdBy && (_jsxs(Stack, { gap: "none", children: [_jsx(Text, { size: "sm", weight: "medium", className: "mb-1", children: "Created by" }), _jsx(Text, { size: "sm", color: "muted", children: values.createdBy })] })), _jsxs(Stack, { gap: "xs", surface: "muted", rounded: "lg", border: "default", padding: "sm", children: [_jsx(Toggle, { checked: values.allowGuestParticipation, onChange: (v) => onChange({ allowGuestParticipation: v }), label: "Allow guest participation (no login required)" }), _jsx(Text, { size: "xs", color: "muted", children: "When enabled, unauthenticated visitors can participate in this event (poll vote, survey/feedback response, raffle entry, or wheel spin), identified by a hashed IP address instead of an account. Applies uniformly to whatever entry mechanism this event type uses." })] }), values.type === "sale" && (_jsxs(Stack, { gap: "xs", surface: "muted", rounded: "lg", border: "default", padding: "sm", children: [_jsx(Text, { size: "sm", weight: "semibold", color: "muted", children: "Sale configuration" }), _jsxs(Grid, { cols: 2, gap: "sm", children: [_jsx(Input, { label: "Discount %", type: "number", value: values.discountPercent, onChange: (e) => onChange({ discountPercent: e.target.value }), placeholder: "10" }), _jsx(Input, { label: "Banner text (optional)", value: values.saleBannerText, onChange: (e) => onChange({ saleBannerText: e.target.value }), placeholder: "Limited time \u2014 ends Sunday!" })] })] })), values.type === "offer" && (_jsxs(Stack, { gap: "xs", surface: "muted", rounded: "lg", border: "default", padding: "sm", children: [_jsx(Text, { size: "sm", weight: "semibold", color: "muted", children: "Offer configuration" }), _jsxs(Grid, { cols: 2, gap: "sm", children: [_jsxs(Div, { children: [_jsx(Text, { size: "sm", weight: "medium", className: "mb-1", children: "Coupon" }), _jsx(CouponInlineSelect, { scope: "admin", value: values.couponId, onChange: (id) => onChange({ couponId: id ?? "" }), placeholder: "Search coupons\u2026", allowCreate: false })] }), _jsx(Input, { label: "Display code", value: values.displayCode, onChange: (e) => onChange({ displayCode: e.target.value }), placeholder: "CHARIZARD25", required: true })] }), _jsx(Input, { label: "Banner text (optional)", value: values.offerBannerText, onChange: (e) => onChange({ offerBannerText: e.target.value }), placeholder: "Use CHARIZARD25 at checkout" })] })), values.type === "poll" && (_jsxs(Stack, { gap: "md", rounded: "xl", border: "default", padding: "md", children: [_jsx(Text, { size: "sm", weight: "semibold", color: "muted", children: "Poll configuration" }), _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "xs", color: "muted", children: "Options (minimum 2)" }), values.pollOptions.map((opt, idx) => (_jsxs(Row, { gap: "xs", children: [_jsx(Div, { className: "flex-1", children: _jsx(Input, { value: opt.label, onChange: (e) => onChange({ pollOptions: values.pollOptions.map((o) => o.id === opt.id ? { ...o, label: e.target.value } : o) }), placeholder: `Option ${idx + 1}` }) }), values.pollOptions.length > 2 && (_jsx(Button, { variant: "ghost", type: "button", onClick: () => onChange({ pollOptions: values.pollOptions.filter((o) => o.id !== opt.id) }), className: CLS_REMOVE_BTN_PX2, "aria-label": "Remove option", children: "\u00D7" }))] }, opt.id))), _jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: () => onChange({ pollOptions: [...values.pollOptions, { id: `opt${Date.now()}`, label: "" }] }), children: "+ Add option" })] }), _jsx(Select, { label: "Results visibility", value: values.resultsVisibility, options: POLL_VISIBILITY_OPTIONS, onChange: (e) => onChange({ resultsVisibility: e.target.value }) }), _jsxs(Stack, { gap: "sm", children: [_jsx(Toggle, { checked: values.allowMultiSelect, onChange: (v) => onChange({ allowMultiSelect: v }), label: "Allow multi-select" }), _jsx(Toggle, { checked: values.allowComment, onChange: (v) => onChange({ allowComment: v }), label: "Allow comment with vote" })] })] })), values.type === "survey" && (_jsxs(Stack, { gap: "md", rounded: "xl", border: "default", padding: "md", children: [_jsx(Text, { size: "sm", weight: "semibold", color: "muted", children: "Survey configuration" }), _jsxs(Grid, { cols: 2, gap: "sm", children: [_jsx(Input, { label: "Max entries per user", type: "number", value: values.maxEntriesPerUser, onChange: (e) => onChange({ maxEntriesPerUser: e.target.value }), placeholder: "1" }), values.hasPointSystem && (_jsx(Input, { label: "Points label", value: values.pointsLabel, onChange: (e) => onChange({ pointsLabel: e.target.value }), placeholder: "Points" }))] }), _jsxs(Stack, { gap: "xs", children: [_jsx(Toggle, { checked: values.requireLogin, onChange: (v) => onChange({ requireLogin: v }), label: "Require login to participate" }), _jsx(Toggle, { checked: values.hasLeaderboard, onChange: (v) => onChange({ hasLeaderboard: v }), label: "Show leaderboard" }), _jsx(Toggle, { checked: values.hasPointSystem, onChange: (v) => onChange({ hasPointSystem: v }), label: "Enable point system" }), _jsx(Toggle, { checked: values.entryReviewRequired, onChange: (v) => onChange({ entryReviewRequired: v }), label: "Require employee review before entry is approved" })] }), _jsx(FormFieldBuilder, { fields: values.surveyFields, setFields: (f) => onChange({ surveyFields: f }) })] })), values.type === "feedback" && (_jsxs(Stack, { gap: "md", rounded: "xl", border: "default", padding: "md", children: [_jsx(Text, { size: "sm", weight: "semibold", color: "muted", children: "Feedback configuration" }), _jsx(Toggle, { checked: values.anonymous, onChange: (v) => onChange({ anonymous: v }), label: "Allow anonymous submissions" }), _jsx(FormFieldBuilder, { fields: values.feedbackFields, setFields: (f) => onChange({ feedbackFields: f }) })] }))] })),
431
+ render: ({ values, onChange, errors }) => (_jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Event Settings" }), isEdit && (_jsx(Select, { label: "Status", error: errors.status, value: values.status, options: EVENT_STATUS_OPTIONS, onChange: (e) => onChange({ status: e.target.value }) })), values.createdBy && (_jsxs(Stack, { gap: "none", children: [_jsx(Text, { size: "sm", weight: "medium", className: "mb-1", children: "Created by" }), _jsx(Text, { size: "sm", color: "muted", children: values.createdBy })] })), _jsxs(Stack, { gap: "xs", surface: "muted", rounded: "lg", border: "default", padding: "sm", children: [_jsx(Toggle, { checked: values.allowGuestParticipation, onChange: (v) => onChange({ allowGuestParticipation: v }), label: "Allow guest participation (no login required)" }), _jsx(Text, { size: "xs", color: "muted", children: "When enabled, unauthenticated visitors can participate in this event (poll vote, survey/feedback response, raffle entry, or wheel spin), identified by a hashed IP address instead of an account. Applies uniformly to whatever entry mechanism this event type uses." })] }), _jsxs(Stack, { gap: "xs", surface: "muted", rounded: "lg", border: "default", padding: "sm", children: [_jsx(Toggle, { checked: values.isPinned, onChange: (v) => onChange({ isPinned: v }), label: "Pin to the top of Events" }), _jsx(Text, { size: "xs", color: "muted", children: "Pinned events are listed first on the Events page and in the homepage strip, ahead of the usual soonest-first order. A pinned event still has to be Active to appear at all \u2014 pinning does not publish it." })] }), values.type === "sale" && (_jsxs(Stack, { gap: "xs", surface: "muted", rounded: "lg", border: "default", padding: "sm", children: [_jsx(Text, { size: "sm", weight: "semibold", color: "muted", children: "Sale configuration" }), _jsxs(Grid, { cols: 2, gap: "sm", children: [_jsx(Input, { label: "Discount %", error: errors.discountPercent, type: "number", value: values.discountPercent, onChange: (e) => onChange({ discountPercent: e.target.value }), placeholder: "10" }), _jsx(Input, { label: "Banner text (optional)", value: values.saleBannerText, error: errors.saleBannerText, onChange: (e) => onChange({ saleBannerText: e.target.value }), placeholder: "Limited time \u2014 ends Sunday!" })] })] })), values.type === "offer" && (_jsxs(Stack, { gap: "xs", surface: "muted", rounded: "lg", border: "default", padding: "sm", children: [_jsx(Text, { size: "sm", weight: "semibold", color: "muted", children: "Offer configuration" }), _jsxs(Grid, { cols: 2, gap: "sm", children: [_jsxs(Div, { children: [_jsx(Text, { size: "sm", weight: "medium", className: "mb-1", children: "Coupon" }), _jsx(CouponInlineSelect, { scope: "admin", value: values.couponId, onChange: (id) => onChange({ couponId: id ?? "" }), placeholder: "Search coupons\u2026", allowCreate: false })] }), _jsx(Input, { label: "Display code", error: errors.displayCode, value: values.displayCode, onChange: (e) => onChange({ displayCode: e.target.value }), placeholder: "CHARIZARD25", required: true })] }), _jsx(Input, { label: "Banner text (optional)", value: values.offerBannerText, error: errors.offerBannerText, onChange: (e) => onChange({ offerBannerText: e.target.value }), placeholder: "Use CHARIZARD25 at checkout" })] })), values.type === "poll" && (_jsxs(Stack, { gap: "md", rounded: "xl", border: "default", padding: "md", children: [_jsx(Text, { size: "sm", weight: "semibold", color: "muted", children: "Poll configuration" }), _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "xs", color: "muted", children: "Options (minimum 2)" }), values.pollOptions.map((opt, idx) => (_jsxs(Row, { gap: "xs", children: [_jsx(Div, { className: "flex-1", children: _jsx(Input, { value: opt.label,
432
+ /* Indexed: a failure on option 2 arrives as `pollOptions.1.label`. */
433
+ error: errors[`pollOptions.${idx}.label`], onChange: (e) => onChange({ pollOptions: values.pollOptions.map((o) => o.id === opt.id ? { ...o, label: e.target.value } : o) }), placeholder: `Option ${idx + 1}` }) }), values.pollOptions.length > 2 && (_jsx(Button, { variant: "ghost", type: "button", onClick: () => onChange({ pollOptions: values.pollOptions.filter((o) => o.id !== opt.id) }), className: CLS_REMOVE_BTN_PX2, "aria-label": "Remove option", children: "\u00D7" }))] }, opt.id))), _jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: () => onChange({ pollOptions: [...values.pollOptions, { id: `opt${Date.now()}`, label: "" }] }), children: "+ Add option" })] }), _jsx(Select, { label: "Results visibility", error: errors.resultsVisibility, value: values.resultsVisibility, options: POLL_VISIBILITY_OPTIONS, onChange: (e) => onChange({ resultsVisibility: e.target.value }) }), _jsxs(Stack, { gap: "sm", children: [_jsx(Toggle, { checked: values.allowMultiSelect, onChange: (v) => onChange({ allowMultiSelect: v }), label: "Allow multi-select" }), _jsx(Toggle, { checked: values.allowComment, onChange: (v) => onChange({ allowComment: v }), label: "Allow comment with vote" })] })] })), values.type === "survey" && (_jsxs(Stack, { gap: "md", rounded: "xl", border: "default", padding: "md", children: [_jsx(Text, { size: "sm", weight: "semibold", color: "muted", children: "Survey configuration" }), _jsxs(Grid, { cols: 2, gap: "sm", children: [_jsx(Input, { label: "Max entries per user", error: errors.maxEntriesPerUser, type: "number", value: values.maxEntriesPerUser, onChange: (e) => onChange({ maxEntriesPerUser: e.target.value }), placeholder: "1" }), values.hasPointSystem && (_jsx(Input, { label: "Points label", error: errors.pointsLabel, value: values.pointsLabel, onChange: (e) => onChange({ pointsLabel: e.target.value }), placeholder: "Points" }))] }), _jsxs(Stack, { gap: "xs", children: [_jsx(Toggle, { checked: values.requireLogin, onChange: (v) => onChange({ requireLogin: v }), label: "Require login to participate" }), _jsx(Toggle, { checked: values.hasLeaderboard, onChange: (v) => onChange({ hasLeaderboard: v }), label: "Show leaderboard" }), _jsx(Toggle, { checked: values.hasPointSystem, onChange: (v) => onChange({ hasPointSystem: v }), label: "Enable point system" }), _jsx(Toggle, { checked: values.entryReviewRequired, onChange: (v) => onChange({ entryReviewRequired: v }), label: "Require employee review before entry is approved" })] }), _jsx(FormFieldBuilder, { fields: values.surveyFields, setFields: (f) => onChange({ surveyFields: f }) })] })), values.type === "feedback" && (_jsxs(Stack, { gap: "md", rounded: "xl", border: "default", padding: "md", children: [_jsx(Text, { size: "sm", weight: "semibold", color: "muted", children: "Feedback configuration" }), _jsx(Toggle, { checked: values.anonymous, onChange: (v) => onChange({ anonymous: v }), label: "Allow anonymous submissions" }), _jsx(FormFieldBuilder, { fields: values.feedbackFields, setFields: (f) => onChange({ feedbackFields: f }) })] }))] })),
405
434
  },
406
435
  {
407
436
  id: "raffle",
408
437
  label: "Raffle / Spin",
409
438
  fields: ["hasRaffle", "raffleType", "raffleTopN", "rafflePrize", "rafflePrizeCouponId", "rafflePrizeProductIds", "spinPrizes", "spinMaxPerUser", "spinWindowStart", "spinWindowEnd"],
410
- render: ({ values, onChange }) => {
439
+ render: ({ values, onChange, errors }) => {
411
440
  const isRaffleType = values.type === "raffle" || values.type === "spin_wheel";
412
441
  const showRaffleConfig = isRaffleType || values.hasRaffle;
413
442
  const isSpinMode = values.type === "spin_wheel" || values.raffleType === "spin_wheel";
414
- return (_jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Raffle & Spin Wheel" }), !isRaffleType && (_jsx(Toggle, { checked: values.hasRaffle, onChange: (v) => onChange({ hasRaffle: v }), label: "Attach a raffle to this event" })), !isRaffleType && !values.hasRaffle && (_jsx(Text, { size: "sm", color: "muted", children: "Enable the toggle above to attach a raffle draw to this event. Not applicable for this event type by default." })), showRaffleConfig && (_jsxs(Stack, { gap: "md", rounded: "xl", border: "default", padding: "md", children: [_jsxs(Grid, { cols: 2, gap: "sm", children: [_jsx(Select, { label: "Raffle type", value: values.type === "spin_wheel" ? "spin_wheel" : values.raffleType, options: RAFFLE_TYPE_OPTIONS, onChange: (e) => onChange({ raffleType: e.target.value }), disabled: values.type === "spin_wheel" }), _jsx(Input, { label: "Top N (for top-N raffle types)", type: "number", value: values.raffleTopN, onChange: (e) => onChange({ raffleTopN: e.target.value }), placeholder: "10" })] }), _jsx(Input, { label: "Prize description", value: values.rafflePrize, onChange: (e) => onChange({ rafflePrize: e.target.value }), placeholder: "\u20B92,000 store credit + exclusive Pikachu sticker" }), _jsxs(Div, { children: [_jsx(Text, { size: "sm", weight: "medium", className: "mb-1", children: "Coupon for winner (optional)" }), _jsx(CouponInlineSelect, { scope: "admin", value: values.rafflePrizeCouponId, onChange: (id) => onChange({ rafflePrizeCouponId: id ?? "" }), placeholder: "Search coupons\u2026", allowCreate: false })] }), _jsxs(Div, { children: [_jsx(Text, { size: "sm", weight: "medium", className: "mb-1", children: "Prize products (optional)" }), _jsx(ProductInlineSelect, { scope: "admin", multiple: true, value: values.rafflePrizeProductIds, onChange: (ids) => onChange({ rafflePrizeProductIds: ids }), placeholder: "Search products to feature as prizes\u2026" }), _jsx(Text, { size: "xs", color: "muted", className: "mt-1", children: "Products shown to winners or displayed as the prize showcase." })] }), isSpinMode && _jsx(SpinPrizesEditor, { values: values, onChange: onChange }), isEdit && (_jsxs(Stack, { gap: "xs", rounded: "lg", padding: "sm", className: CLS_RAFFLE_PANEL, children: [_jsxs(Row, { justify: "between", gap: "sm", children: [_jsx(Text, { size: "sm", weight: "medium", className: CLS_RAFFLE_HEADING, children: raffleWinnerName ? `Winner: ${raffleWinnerName}` : "Raffle not yet triggered" }), _jsx(Button, { type: "button", action: ACTIONS.EVENT["draw-raffle-winner"], size: "sm", onClick: () => triggerRaffleMutation.mutate(), disabled: triggerRaffleMutation.isPending || Boolean(raffleWinnerName), children: triggerRaffleMutation.isPending ? "Triggering…" : raffleWinnerName ? "Already triggered" : "Trigger Raffle Now" })] }), raffleEntryCount !== null && _jsxs(Text, { size: "xs", className: CLS_RAFFLE_BODY, children: ["Pool size: ", raffleEntryCount] }), raffleGithubUrl && _jsxs(Text, { size: "xs", className: `break-all ${CLS_RAFFLE_BODY}`, children: ["Fairness proof: ", raffleGithubUrl] }), triggerMessage && _jsx(Text, { size: "xs", className: CLS_RAFFLE_HEADING, children: triggerMessage })] }))] }))] }));
443
+ return (_jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Raffle & Spin Wheel" }), !isRaffleType && (_jsx(Toggle, { checked: values.hasRaffle, onChange: (v) => onChange({ hasRaffle: v }), label: "Attach a raffle to this event" })), !isRaffleType && !values.hasRaffle && (_jsx(Text, { size: "sm", color: "muted", children: "Enable the toggle above to attach a raffle draw to this event. Not applicable for this event type by default." })), showRaffleConfig && (_jsxs(Stack, { gap: "md", rounded: "xl", border: "default", padding: "md", children: [_jsxs(Grid, { cols: 2, gap: "sm", children: [_jsx(Select, { label: "Raffle type", error: errors.raffleType, value: values.type === "spin_wheel" ? "spin_wheel" : values.raffleType, options: RAFFLE_TYPE_OPTIONS, onChange: (e) => onChange({ raffleType: e.target.value }), disabled: values.type === "spin_wheel" }), _jsx(Input, { label: "Top N (for top-N raffle types)", error: errors.raffleTopN, type: "number", value: values.raffleTopN, onChange: (e) => onChange({ raffleTopN: e.target.value }), placeholder: "10" })] }), _jsx(Input, { label: "Prize description", error: errors.rafflePrize, value: values.rafflePrize, onChange: (e) => onChange({ rafflePrize: e.target.value }), placeholder: "\u20B92,000 store credit + exclusive Pikachu sticker" }), _jsxs(Div, { children: [_jsx(Text, { size: "sm", weight: "medium", className: "mb-1", children: "Coupon for winner (optional)" }), _jsx(CouponInlineSelect, { scope: "admin", value: values.rafflePrizeCouponId, onChange: (id) => onChange({ rafflePrizeCouponId: id ?? "" }), placeholder: "Search coupons\u2026", allowCreate: false })] }), _jsxs(Div, { children: [_jsx(Text, { size: "sm", weight: "medium", className: "mb-1", children: "Prize products (optional)" }), _jsx(ProductInlineSelect, { scope: "admin", multiple: true, value: values.rafflePrizeProductIds, onChange: (ids) => onChange({ rafflePrizeProductIds: ids }), placeholder: "Search products to feature as prizes\u2026" }), _jsx(Text, { size: "xs", color: "muted", className: "mt-1", children: "Products shown to winners or displayed as the prize showcase." })] }), isSpinMode && _jsx(SpinPrizesEditor, { values: values, onChange: onChange }), isEdit && (_jsxs(Stack, { gap: "xs", rounded: "lg", padding: "sm", className: CLS_RAFFLE_PANEL, children: [_jsxs(Row, { justify: "between", gap: "sm", children: [_jsx(Text, { size: "sm", weight: "medium", className: CLS_RAFFLE_HEADING, children: raffleWinnerName ? `Winner: ${raffleWinnerName}` : "Raffle not yet triggered" }), _jsx(Button, { type: "button", action: ACTIONS.EVENT["draw-raffle-winner"], size: "sm", onClick: () => triggerRaffleMutation.mutate(), disabled: triggerRaffleMutation.isPending || Boolean(raffleWinnerName), children: triggerRaffleMutation.isPending ? "Triggering…" : raffleWinnerName ? "Already triggered" : "Trigger Raffle Now" })] }), raffleEntryCount !== null && _jsxs(Text, { size: "xs", className: CLS_RAFFLE_BODY, children: ["Pool size: ", raffleEntryCount] }), raffleGithubUrl && _jsxs(Text, { size: "xs", className: `break-all ${CLS_RAFFLE_BODY}`, children: ["Fairness proof: ", raffleGithubUrl] }), triggerMessage && _jsx(Text, { size: "xs", className: CLS_RAFFLE_HEADING, children: triggerMessage })] }))] }))] }));
415
444
  },
416
445
  },
417
446
  ];
@@ -8,6 +8,7 @@ import { sieveFilter, sieveMultiEq, sieveAnd, SIEVE_OP } from "../../../utils/si
8
8
  import { sortBy } from "../../../constants/sort.js";
9
9
  import { safeRead } from "../../../errors/safe-read.js";
10
10
  import { hidePublicTestData } from "../../../_internal/server/features/tester/visibility.js";
11
+ import { sortPinnedFirst } from "../utils/sort-pinned.js";
11
12
  function sp(params, key) {
12
13
  const v = params[key];
13
14
  return Array.isArray(v) ? v[0] ?? "" : v ?? "";
@@ -69,7 +70,7 @@ export async function EventsListPageView({ searchParams = {} }) {
69
70
  * refetch threads the real viewer, so a signed-in tester still sees them.
70
71
  */
71
72
  const publicResult = result
72
- ? { ...result, items: hidePublicTestData(result.items) }
73
+ ? { ...result, items: sortPinnedFirst(hidePublicTestData(result.items)) }
73
74
  : result;
74
75
  return (_jsx(Main, { children: _jsx(Section, { padding: "y-2xl", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 1, className: "mb-8", color: "primary", size: "3xl", weight: "semibold", children: "Events" }), _jsx(AdSlot, { id: "listing-sidebar-top", className: "mb-6" }), _jsx(EventsIndexListing, { initialData: publicResult, facetCounts: facetCounts }), _jsx(AdSlot, { id: "listing-sidebar-bottom", className: "mt-8" })] }) }) }));
75
76
  }
@@ -17,6 +17,22 @@ export interface EventDocument extends BaseDocument {
17
17
  title: string;
18
18
  description: string;
19
19
  status: EventStatus;
20
+ /**
21
+ * Lift this event to the top of `/events` and the homepage rail, ahead of
22
+ * the `startsAt` ASC default.
23
+ *
24
+ * 🛑 DELIBERATELY NOT IN `EVENT_INDEXED_FIELDS` AND NOT SORTABLE IN SIEVE.
25
+ * A Firestore `orderBy` EXCLUDES every document that lacks the ordering
26
+ * field (Root Cause #100), and `isPinned` is absent from all 1 live event
27
+ * and every seeded row that is not pinned — so ordering by it would empty
28
+ * the events page entirely while answering HTTP 200. The ordering is
29
+ * applied in memory by `sortPinnedFirst`; see the bound stated there.
30
+ *
31
+ * Absent means not pinned. Do not default it to `false` on write — a
32
+ * written `false` and an absent field must stay interchangeable, because
33
+ * every event that predates this field has the latter.
34
+ */
35
+ isPinned?: boolean;
20
36
  /** Test-data flag — set on the shared admin-seeded test event; swept by testDataCleanup. */
21
37
  isTestData?: boolean;
22
38
  /** When isTestData, the cutoff after which testDataCleanup deletes this doc. */
@@ -134,6 +150,7 @@ export declare const EVENT_FIELDS: {
134
150
  readonly WINNER_IMAGES: "winnerImages";
135
151
  readonly ADDITIONAL_IMAGES: "additionalImages";
136
152
  readonly TAGS: "tags";
153
+ readonly IS_PINNED: "isPinned";
137
154
  readonly ALLOW_GUEST_PARTICIPATION: "allowGuestParticipation";
138
155
  readonly CREATED_BY: "createdBy";
139
156
  readonly CREATED_AT: "createdAt";
@@ -38,6 +38,7 @@ export const EVENT_FIELDS = {
38
38
  WINNER_IMAGES: "winnerImages",
39
39
  ADDITIONAL_IMAGES: "additionalImages",
40
40
  TAGS: "tags",
41
+ IS_PINNED: "isPinned",
41
42
  ALLOW_GUEST_PARTICIPATION: "allowGuestParticipation",
42
43
  CREATED_BY: "createdBy",
43
44
  CREATED_AT: "createdAt",
@@ -0,0 +1,33 @@
1
+ /** Just the part of an event this ordering reads. */
2
+ export interface PinnableEvent {
3
+ isPinned?: boolean;
4
+ }
5
+ /**
6
+ * Lift pinned events to the front, preserving the relative order of both
7
+ * groups.
8
+ *
9
+ * 🛑 WHY THIS IS IN MEMORY AND NOT AN `orderBy`. Firestore's `orderBy`
10
+ * excludes every document that lacks the ordering field (Root Cause #100), and
11
+ * `isPinned` is absent from every event that is not pinned — which is all of
12
+ * them bar one. Sorting on it in the query would return an events page
13
+ * containing only the pinned row, with no error and a 200, and the failure
14
+ * reads as "the other events were deleted" rather than as a bad sort.
15
+ *
16
+ * A boolean is also a one-bit sort key, so it would need a composite index
17
+ * paired with `startsAt` for every filter combination the page offers, to buy
18
+ * an ordering a single pass over at most `pageSize` rows already gives.
19
+ *
20
+ * 🛑 THE BOUND, STATED RATHER THAN IMPLIED: this reorders WITHIN the page it
21
+ * is handed. A pinned event that falls on page 3 of a paginated result is
22
+ * lifted to the top of page 3, not onto page 1. That is sufficient while the
23
+ * whole catalogue fits one page — live holds 1 event against a pageSize of
24
+ * 24, and the homepage rail asks for 6 — and it is the reason to revisit this
25
+ * if events ever outgrow a single page. The fix then is a second bounded
26
+ * query on `isPinned == true` (an EQUALITY, which is index-friendly and does
27
+ * not have the missing-field problem an `orderBy` has) merged ahead of the
28
+ * main result, not an `orderBy` on this field.
29
+ *
30
+ * `Array.prototype.sort` is specified as stable, so the comparator only needs
31
+ * to express the pinned/unpinned difference to leave everything else alone.
32
+ */
33
+ export declare function sortPinnedFirst<T extends PinnableEvent>(items: readonly T[]): T[];
@@ -0,0 +1,46 @@
1
+ /*
2
+ * WHY: One implementation of "pinned events come first", shared by the two
3
+ * surfaces that render an event list.
4
+ * WHAT: `sortPinnedFirst` — a stable partition on `EventDocument.isPinned`.
5
+ *
6
+ * EXPORTS:
7
+ * sortPinnedFirst — readonly T[] -> T[], pinned first, order otherwise kept
8
+ *
9
+ * @tag domain:events
10
+ * @tag layer:util
11
+ * @tag pattern:none
12
+ * @tag access:isomorphic
13
+ * @tag consumers:features/events/components/EventsListPageView.tsx,features/events/actions/event-actions.ts
14
+ * @tag sideEffects:none
15
+ */
16
+ /**
17
+ * Lift pinned events to the front, preserving the relative order of both
18
+ * groups.
19
+ *
20
+ * 🛑 WHY THIS IS IN MEMORY AND NOT AN `orderBy`. Firestore's `orderBy`
21
+ * excludes every document that lacks the ordering field (Root Cause #100), and
22
+ * `isPinned` is absent from every event that is not pinned — which is all of
23
+ * them bar one. Sorting on it in the query would return an events page
24
+ * containing only the pinned row, with no error and a 200, and the failure
25
+ * reads as "the other events were deleted" rather than as a bad sort.
26
+ *
27
+ * A boolean is also a one-bit sort key, so it would need a composite index
28
+ * paired with `startsAt` for every filter combination the page offers, to buy
29
+ * an ordering a single pass over at most `pageSize` rows already gives.
30
+ *
31
+ * 🛑 THE BOUND, STATED RATHER THAN IMPLIED: this reorders WITHIN the page it
32
+ * is handed. A pinned event that falls on page 3 of a paginated result is
33
+ * lifted to the top of page 3, not onto page 1. That is sufficient while the
34
+ * whole catalogue fits one page — live holds 1 event against a pageSize of
35
+ * 24, and the homepage rail asks for 6 — and it is the reason to revisit this
36
+ * if events ever outgrow a single page. The fix then is a second bounded
37
+ * query on `isPinned == true` (an EQUALITY, which is index-friendly and does
38
+ * not have the missing-field problem an `orderBy` has) merged ahead of the
39
+ * main result, not an `orderBy` on this field.
40
+ *
41
+ * `Array.prototype.sort` is specified as stable, so the comparator only needs
42
+ * to express the pinned/unpinned difference to leave everything else alone.
43
+ */
44
+ export function sortPinnedFirst(items) {
45
+ return [...items].sort((a, b) => Number(b.isPinned === true) - Number(a.isPinned === true));
46
+ }
@@ -1,46 +1,9 @@
1
+ // One shared predicate — this file had its own copy; see firestore-codes.ts.
2
+ import { isAlreadyExistsError } from "../../../errors/firestore-codes.js";
1
3
  import { BaseRepository, applySieveToFirestore, parseSieveDateValue, prepareForFirestore, } from "../../../providers/db-firebase/index.js";
2
4
  import { normalizeError } from "../../../errors/normalize.js";
3
5
  import { GATEWAY_EVENTS_COLLECTION, GATEWAY_EVENT_ERROR_MAX_BYTES, GATEWAY_EVENT_FIELDS, GATEWAY_EVENT_LEASE_SECONDS, GATEWAY_EVENT_RETENTION_DAYS, createGatewayEventId, } from "../schemas/firestore.js";
4
6
  /** gRPC `ALREADY_EXISTS`. The one failure of `.create()` that means "duplicate". */
5
- const GRPC_ALREADY_EXISTS = 6;
6
- /**
7
- * Is this the specific "a document with that id already exists" failure, as
8
- * opposed to any other write failure?
9
- *
10
- * 🛑 THIS DISTINCTION IS THE WHOLE POINT, and it is where the inbox differs
11
- * from every other `.create()` claim in the codebase.
12
- *
13
- * `refunds/actions.ts` catches *every* error from its claim and treats it as a
14
- * duplicate — and that is correct THERE, because the cost of being wrong is
15
- * issuing a second real refund, so it must fail CLOSED.
16
- *
17
- * Here the cost of being wrong is inverted: treating a permissions error or a
18
- * Firestore outage as "already have it" would make the route ack 200 and the
19
- * event would be **silently lost** — which is the exact failure this collection
20
- * exists to prevent. So the inbox fails OPEN: anything that is not
21
- * ALREADY_EXISTS propagates, the route answers 503, and the gateway sends it
22
- * again.
23
- *
24
- * Same primitive, opposite failure direction, because the asymmetry of harm is
25
- * opposite. Do not "unify" these two call sites.
26
- */
27
- function isAlreadyExists(err) {
28
- if (typeof err !== "object" || err === null)
29
- return false;
30
- const code = err.code;
31
- if (code === GRPC_ALREADY_EXISTS)
32
- return true;
33
- /*
34
- * The Admin SDK has also surfaced this as a string code and as a plain
35
- * message depending on version and transport. Checked in addition to, never
36
- * instead of, the numeric code — a message test alone is the kind of
37
- * stringly-typed guard that breaks silently on an SDK bump.
38
- */
39
- if (code === "already-exists" || code === "ALREADY_EXISTS")
40
- return true;
41
- const message = err.message;
42
- return typeof message === "string" && message.includes("ALREADY_EXISTS");
43
- }
44
7
  function truncate(value, maxBytes) {
45
8
  if (!value)
46
9
  return undefined;
@@ -86,7 +49,7 @@ export class GatewayEventsRepository extends BaseRepository {
86
49
  return { claimed: true, id, doc };
87
50
  }
88
51
  catch (err) {
89
- if (isAlreadyExists(err)) {
52
+ if (isAlreadyExistsError(err)) {
90
53
  void normalizeError(err);
91
54
  return { claimed: false, id };
92
55
  }
@@ -34,12 +34,12 @@ export declare const gatewayEventFirestoreSchema: z.ZodObject<{
34
34
  createdAt: Date;
35
35
  updatedAt: Date;
36
36
  expiresAt: Date;
37
+ claimedAt: Date;
37
38
  gateway: string;
38
39
  receivedAt: Date;
39
40
  attempts: number;
40
41
  bodySha256: string;
41
42
  leaseExpiresAt: Date;
42
- claimedAt: Date;
43
43
  rawEvent: string;
44
44
  terminal?: boolean | undefined;
45
45
  amount?: number | undefined;
@@ -59,12 +59,12 @@ export declare const gatewayEventFirestoreSchema: z.ZodObject<{
59
59
  createdAt: Date;
60
60
  updatedAt: Date;
61
61
  expiresAt: Date;
62
+ claimedAt: Date;
62
63
  gateway: string;
63
64
  receivedAt: Date;
64
65
  attempts: number;
65
66
  bodySha256: string;
66
67
  leaseExpiresAt: Date;
67
- claimedAt: Date;
68
68
  rawEvent: string;
69
69
  terminal?: boolean | undefined;
70
70
  amount?: number | undefined;
@@ -16,9 +16,10 @@ import { Button, Div, Form, FieldInput, FieldSelect, Stack, Text } from "../../.
16
16
  import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints.js";
17
17
  import { apiClient } from "../../../http/index.js";
18
18
  import { sortBy } from "../../../constants/sort.js";
19
+ import { normalizeError } from "../../../errors/normalize.js";
19
20
  import { sieveFilter, SIEVE_OP } from "../../../utils/sieve-builder.js";
20
21
  import { operatorMessage } from "./operator-message.js";
21
- import { BoxTypeValues } from "../schemas/firestore.js";
22
+ import { BoxTypeValues, STORAGE_BOX_FIELDS, } from "../schemas/firestore.js";
22
23
  const TYPE_OPTIONS = BoxTypeValues.map((v) => ({ value: v, label: v }));
23
24
  /**
24
25
  * The register-a-box form.
@@ -26,47 +27,58 @@ const TYPE_OPTIONS = BoxTypeValues.map((v) => ({ value: v, label: v }));
26
27
  * Exported because it is a PAGE now, not a drawer — see the note on
27
28
  * `primaryAction` below for why create could not share the row affordance.
28
29
  */
30
+ // audit-form-sectionised-ok: a three-field quick create (code, type, location),
31
+ // not a multi-section editor. <SectionForm> derives its sections from a schema
32
+ // and pins a mobile action bar for a long scroll; neither has anything to do
33
+ // here, and sections of one field each would be chrome around three inputs.
29
34
  export function RegisterBoxForm({ onCreated }) {
30
35
  const [code, setCode] = React.useState("");
31
36
  const [boxType, setBoxType] = React.useState(BoxTypeValues[0]);
32
37
  const [location, setLocation] = React.useState("");
33
38
  const [busy, setBusy] = React.useState(false);
34
- return (_jsx(Form, { onSubmit: (e) => e.preventDefault(), className: "space-y-4", children: ({ setFieldError, clearErrors }) => (_jsxs(Stack, { gap: "md", children: [_jsx(FieldInput, { name: "code", label: "Box code", required: true, value: code, onChange: setCode, placeholder: "AC60-03", hint: "Exactly what is written on the physical label." }), _jsx(FieldSelect, { name: "boxType", label: "Box type", value: boxType, onChange: setBoxType, options: TYPE_OPTIONS }), _jsx(FieldInput, { name: "location", label: "Where it is", value: location, onChange: setLocation, placeholder: "Shelf B, second from top" }), _jsx(Text, { size: "xs", color: "muted", children: "A second box with the same code is the same box \u2014 the registry refuses a duplicate rather than merging two." }), _jsx(Div, { children: _jsx(Button, { type: "submit", variant: "primary", isLoading: busy, onClick: async () => {
35
- clearErrors();
36
- if (!code.trim()) {
37
- setFieldError("code", "A box needs a code");
38
- return;
39
- }
40
- setBusy(true);
41
- try {
42
- await apiClient.post(ADMIN_ENDPOINTS.INVENTORY_BOXES, {
43
- code: code.trim(),
44
- boxType,
45
- ...(location.trim() ? { location: location.trim() } : {}),
46
- });
47
- onCreated();
48
- }
49
- catch (err) {
50
- /*
51
- * 🛑 `operatorMessage`, not a raw fallback. This read
52
- * `toUserMessage(undefined, undefined, { fallback: e.message })`
53
- * under a comment claiming it never showed server text —
54
- * which is false twice over: `undefined` for the code means
55
- * the map never runs, so the fallback is ALWAYS what shows.
56
- * A comment asserting the fix while the code reintroduces the
57
- * bug is how Root Cause #86 would come back, and how the seed
58
- * `listingType` bug survived its own fix one field over.
59
- *
60
- * The split is on status: the 409 here ("Box AC60-03 already
61
- * exists") is authored for this operator and must survive; a
62
- * 5xx terminates in a constant.
63
- */
64
- setFieldError("code", operatorMessage(err));
65
- }
66
- finally {
67
- setBusy(false);
68
- }
69
- }, children: "Register box" }) })] })) }));
39
+ /*
40
+ * Lifted out of the submit button's `onClick`, where it sat five braces deep
41
+ * inside the render-prop's JSX. The two `<Form>` helpers arrive as arguments
42
+ * rather than being captured, which is what lets this live in the component
43
+ * body beside the state it writes.
44
+ */
45
+ const submitBox = async (setFieldError, clearErrors) => {
46
+ clearErrors();
47
+ if (!code.trim()) {
48
+ setFieldError("code", "A box needs a code");
49
+ return;
50
+ }
51
+ setBusy(true);
52
+ try {
53
+ await apiClient.post(ADMIN_ENDPOINTS.INVENTORY_BOXES, {
54
+ code: code.trim(),
55
+ boxType,
56
+ ...(location.trim() ? { location: location.trim() } : {}),
57
+ });
58
+ onCreated();
59
+ }
60
+ catch (err) {
61
+ /*
62
+ * 🛑 `operatorMessage`, not a raw fallback. This read
63
+ * `toUserMessage(undefined, undefined, { fallback: e.message })`
64
+ * under a comment claiming it never showed server text — which is
65
+ * false twice over: `undefined` for the code means the map never
66
+ * runs, so the fallback is ALWAYS what shows. A comment asserting
67
+ * the fix while the code reintroduces the bug is how Root Cause #86
68
+ * would come back, and how the seed `listingType` bug survived its
69
+ * own fix one field over.
70
+ *
71
+ * The split is on status: the 409 here ("Box AC60-03 already
72
+ * exists") is authored for this operator and must survive; a 5xx
73
+ * terminates in a constant.
74
+ */
75
+ setFieldError("code", operatorMessage(normalizeError(err)));
76
+ }
77
+ finally {
78
+ setBusy(false);
79
+ }
80
+ };
81
+ return (_jsx(Form, { onSubmit: (e) => e.preventDefault(), className: "space-y-4", children: ({ setFieldError, clearErrors }) => (_jsxs(Stack, { gap: "md", children: [_jsx(FieldInput, { name: "code", label: "Box code", required: true, value: code, onChange: setCode, placeholder: "AC60-03", hint: "Exactly what is written on the physical label." }), _jsx(FieldSelect, { name: "boxType", label: "Box type", value: boxType, onChange: setBoxType, options: TYPE_OPTIONS }), _jsx(FieldInput, { name: "location", label: "Where it is", value: location, onChange: setLocation, placeholder: "Shelf B, second from top" }), _jsx(Text, { size: "xs", color: "muted", children: "A second box with the same code is the same box \u2014 the registry refuses a duplicate rather than merging two." }), _jsx(Div, { children: _jsx(Button, { type: "submit", variant: "primary", isLoading: busy, onClick: () => submitBox(setFieldError, clearErrors), children: "Register box" }) })] })) }));
70
82
  }
71
83
  export function AdminStorageBoxesView() {
72
84
  const config = React.useMemo(() => ({
@@ -109,9 +121,13 @@ export function AdminStorageBoxesView() {
109
121
  * AC60-2 — the kind of ordering that makes an operator distrust the
110
122
  * screen — which is why `ordinal` is denormalised on write.
111
123
  */
112
- defaultSort: "ordinal",
124
+ defaultSort: sortBy(STORAGE_BOX_FIELDS.ORDINAL, "ASC"),
113
125
  sortOptions: [
114
- { value: "ordinal", label: "Box number" },
126
+ // Same expression as `defaultSort`, not a matching literal beside it —
127
+ // `sortBy(f, "ASC")` is the bare field, so the two are identical by
128
+ // construction. A default that is not one of the options opens the
129
+ // dropdown with nothing selected (Root Cause #63).
130
+ { value: sortBy(STORAGE_BOX_FIELDS.ORDINAL, "ASC"), label: "Box number" },
115
131
  { value: sortBy("createdAt", "DESC"), label: "Newest" },
116
132
  { value: sortBy("code", "ASC"), label: "Code A–Z" },
117
133
  ],