@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.
- package/dist/.build-stamp.json +3 -3
- package/dist/_internal/client/features/lottery/LotteryAdminEditView.js +19 -3
- package/dist/_internal/client/features/maintenance/views/AnalysisRunnerView.js +1 -1
- package/dist/_internal/client/features/maintenance/views/CloudLogsListView.js +1 -1
- package/dist/_internal/server/features/auctions/actions.d.ts +1 -1
- package/dist/_internal/server/features/auctions/actions.js +12 -1
- package/dist/_internal/server/features/blog/actions.d.ts +1 -1
- package/dist/_internal/server/features/blog/actions.js +12 -1
- package/dist/_internal/server/features/brands/actions.d.ts +1 -1
- package/dist/_internal/server/features/brands/actions.js +12 -1
- package/dist/_internal/server/features/cart/actions.d.ts +1 -1
- package/dist/_internal/server/features/cart/actions.js +12 -1
- package/dist/_internal/server/features/checkout/actions.js +0 -1
- package/dist/_internal/server/features/events/actions.d.ts +1 -1
- package/dist/_internal/server/features/events/actions.js +12 -1
- package/dist/_internal/server/features/gateway-events/redact.js +14 -2
- package/dist/_internal/server/features/history/actions.d.ts +1 -1
- package/dist/_internal/server/features/history/actions.js +12 -1
- package/dist/_internal/server/features/lottery/actions.d.ts +1 -1
- package/dist/_internal/server/features/lottery/actions.js +12 -1
- package/dist/_internal/server/features/orders/actions.d.ts +1 -1
- package/dist/_internal/server/features/orders/actions.js +12 -1
- package/dist/_internal/server/features/orders/cancellation-refund.js +0 -1
- package/dist/_internal/server/features/orders/service.js +31 -1
- package/dist/_internal/server/features/payouts/actions.d.ts +1 -1
- package/dist/_internal/server/features/payouts/actions.js +12 -1
- package/dist/_internal/server/features/points/award.js +9 -1
- package/dist/_internal/server/features/pre-orders/actions.d.ts +1 -1
- package/dist/_internal/server/features/pre-orders/actions.js +12 -1
- package/dist/_internal/server/features/prize-draws/actions.d.ts +1 -1
- package/dist/_internal/server/features/prize-draws/actions.js +12 -1
- package/dist/_internal/server/features/products/actions.d.ts +1 -1
- package/dist/_internal/server/features/products/actions.js +12 -1
- package/dist/_internal/server/features/promotions/actions.d.ts +1 -1
- package/dist/_internal/server/features/promotions/actions.js +12 -1
- package/dist/_internal/server/features/protection-claims/actions.d.ts +2 -2
- package/dist/_internal/server/features/protection-claims/actions.js +21 -12
- package/dist/_internal/server/features/raffle/actions.d.ts +1 -1
- package/dist/_internal/server/features/raffle/actions.js +12 -1
- package/dist/_internal/server/features/refunds/actions.d.ts +1 -3
- package/dist/_internal/server/features/refunds/actions.js +14 -2
- package/dist/_internal/server/features/reviews/actions.d.ts +1 -1
- package/dist/_internal/server/features/reviews/actions.js +12 -1
- package/dist/_internal/server/features/wishlist/actions.d.ts +1 -1
- package/dist/_internal/server/features/wishlist/actions.js +12 -1
- package/dist/_internal/server/jobs/core/obligationSweep.js +0 -1
- package/dist/_internal/server/jobs/core/paymentReconcile.js +93 -76
- package/dist/_internal/server/jobs/core/testDataCleanup.d.ts +1 -1
- package/dist/_internal/server/jobs/core/testDataCleanup.js +22 -1
- package/dist/_internal/server/jobs/handlers/onShipmentAllocationSync.js +16 -4
- package/dist/_internal/shared/actions/action-registry.js +149 -9
- package/dist/_internal/shared/features/promotions/schema.d.ts +66 -0
- package/dist/_internal/shared/features/promotions/schema.js +7 -0
- package/dist/_internal/shared/media/limits.d.ts +10 -0
- package/dist/_internal/shared/media/limits.js +10 -0
- package/dist/_internal/shared/obligations/deadlines.js +11 -1
- package/dist/_internal/shared/tester/window.d.ts +25 -0
- package/dist/_internal/shared/tester/window.js +66 -0
- package/dist/_internal/shared/types/action-result.js +15 -1
- package/dist/client/api/surface-error.js +16 -2
- package/dist/client/api/useApiMutation.js +17 -1
- package/dist/client.d.ts +1 -1
- package/dist/client.js +1 -1
- package/dist/constants/api-endpoints.d.ts +3 -0
- package/dist/constants/api-endpoints.js +1 -0
- package/dist/constants/field-names.d.ts +17 -0
- package/dist/constants/field-names.js +23 -0
- package/dist/core/server-action.js +19 -3
- package/dist/core/unit-of-work.js +15 -1
- package/dist/errors/error-mapping.js +26 -1
- package/dist/errors/firestore-codes.d.ts +37 -0
- package/dist/errors/firestore-codes.js +52 -0
- package/dist/errors/index.d.ts +0 -1
- package/dist/errors/index.js +12 -2
- package/dist/features/account/components/UserOffersPanel.js +5 -3
- package/dist/features/admin/components/AdminActionIndexView.js +100 -5
- package/dist/features/admin/components/AdminAddressClustersView.js +21 -2
- package/dist/features/admin/components/AdminBlogEditorView.js +8 -8
- package/dist/features/admin/components/AdminCarouselEditorView.js +1 -1
- package/dist/features/admin/components/AdminCarouselView.js +96 -4
- package/dist/features/admin/components/AdminCouponEditorView.js +8 -0
- package/dist/features/admin/components/AdminFulfillmentView.js +3 -2
- package/dist/features/admin/components/AdminGatewayEventsView.js +2 -2
- package/dist/features/admin/components/AdminMediaView.js +36 -3
- package/dist/features/admin/components/AdminNavigationView.js +4 -1
- package/dist/features/admin/components/AdminOffersView.js +2 -1
- package/dist/features/admin/components/AdminPaymentClustersView.js +19 -2
- package/dist/features/admin/components/AdminProductEditorView.js +2 -2
- package/dist/features/admin/components/AdminSectionsView.d.ts +10 -0
- package/dist/features/admin/components/AdminSectionsView.js +486 -224
- package/dist/features/admin/components/DataListingView.js +2 -0
- package/dist/features/admin/components/ReferenceRecordEditor.js +1 -1
- package/dist/features/admin/components/analytics/AdminStatCard.js +4 -1
- package/dist/features/admin/hooks/useOptimisticRowFlag.d.ts +33 -0
- package/dist/features/admin/hooks/useOptimisticRowFlag.js +87 -0
- package/dist/features/admin/repository/notification.repository.d.ts +15 -0
- package/dist/features/admin/repository/notification.repository.js +32 -0
- package/dist/features/admin/repository/site-settings.repository.d.ts +2 -1
- package/dist/features/admin/repository/site-settings.repository.js +7 -0
- package/dist/features/admin/schemas/action-index-form.d.ts +34 -0
- package/dist/features/admin/schemas/action-index-form.js +115 -0
- package/dist/features/auctions/components/MarketplaceAuctionCard.js +1 -1
- package/dist/features/auth/components/RegisterForm.js +2 -1
- package/dist/features/catalogue/actions/approve-catalogue-listing.d.ts +1 -1
- package/dist/features/catalogue/actions/approve-catalogue-listing.js +12 -1
- package/dist/features/catalogue/actions/list-from-catalogue.d.ts +1 -1
- package/dist/features/catalogue/actions/list-from-catalogue.js +12 -1
- package/dist/features/catalogue/actions/submit-for-approval.d.ts +1 -1
- package/dist/features/catalogue/actions/submit-for-approval.js +12 -1
- package/dist/features/catalogue/components/CatalogueItemEditorView.js +3 -3
- package/dist/features/categories/repository/price-observation.repository.d.ts +19 -0
- package/dist/features/categories/repository/price-observation.repository.js +21 -2
- package/dist/features/events/actions/event-actions.js +4 -1
- package/dist/features/events/components/AdminEventEditorView.js +79 -50
- package/dist/features/events/components/EventsListPageView.js +2 -1
- package/dist/features/events/schemas/firestore.d.ts +17 -0
- package/dist/features/events/schemas/firestore.js +1 -0
- package/dist/features/events/utils/sort-pinned.d.ts +33 -0
- package/dist/features/events/utils/sort-pinned.js +46 -0
- package/dist/features/gateway-events/repository/gateway-events.repository.js +3 -40
- package/dist/features/gateway-events/schemas/index.d.ts +2 -2
- package/dist/features/inventory/components/AdminStorageBoxesView.js +55 -39
- package/dist/features/inventory/components/ReceiveStationView.js +61 -17
- package/dist/features/inventory/repository/storage-boxes.repository.d.ts +3 -1
- package/dist/features/inventory/repository/storage-boxes.repository.js +36 -3
- package/dist/features/layout/hooks/useFormBottomActions.js +20 -1
- package/dist/features/obligations/repository/obligations.repository.js +10 -1
- package/dist/features/orders/components/EmiInstallmentsPanel.js +25 -7
- package/dist/features/orders/components/RefundRequestView.js +4 -4
- package/dist/features/orders/schemas/firestore.d.ts +19 -0
- package/dist/features/orders/schemas/firestore.js +19 -0
- package/dist/features/orders/schemas/index.d.ts +6 -6
- package/dist/features/payment-cases/repository/payment-cases.repository.d.ts +1 -0
- package/dist/features/payment-cases/repository/payment-cases.repository.js +3 -14
- package/dist/features/pre-orders/components/MarketplacePreorderCard.js +1 -1
- package/dist/features/products/components/GroupMemberPicker.js +4 -1
- package/dist/features/products/components/MarketplaceBundleCard.js +1 -1
- package/dist/features/products/components/MarketplacePrizeDrawCard.js +1 -1
- package/dist/features/products/components/PrizeDrawEntryActions.js +2 -1
- package/dist/features/products/components/ProductDetailActions.js +2 -1
- package/dist/features/products/components/ProductForm.js +2 -2
- package/dist/features/products/components/ProductGrid.js +1 -1
- package/dist/features/promotions/repository/claimed-coupons.repository.js +31 -1
- package/dist/features/promotions/schemas/firestore.d.ts +22 -0
- package/dist/features/promotions/schemas/firestore.js +22 -0
- package/dist/features/promotions/schemas/index.d.ts +479 -6
- package/dist/features/promotions/schemas/index.js +153 -0
- package/dist/features/protection-claims/repository/protection-claims.repository.js +22 -15
- package/dist/features/seller/components/FulfillmentView.js +3 -2
- package/dist/features/seller/components/SellerAddressesView.js +32 -4
- package/dist/features/seller/components/SellerAnalyticsAlertsView.js +62 -3
- package/dist/features/seller/components/SellerBundlesView.js +53 -14
- package/dist/features/seller/components/SellerGroupedListingsView.js +23 -3
- package/dist/features/seller/components/SellerListingTypeView.js +56 -10
- package/dist/features/seller/components/SellerPayoutMethodsView.js +33 -1
- package/dist/features/seller/components/SellerPayoutSettingsView.js +48 -9
- package/dist/features/seller/components/SellerProductsView.js +5 -1
- package/dist/features/seller/components/SellerShippingConfigsView.js +33 -1
- package/dist/features/seller/components/SellerShippingView.js +2 -1
- package/dist/features/seller/components/SellerStoreCategoriesView.js +33 -1
- package/dist/features/seller/components/SellerSupportView.js +69 -2
- package/dist/features/seller/schemas/coupon-form.d.ts +5 -0
- package/dist/features/seller/schemas/coupon-form.js +18 -0
- package/dist/features/shell/SectionForm.js +35 -9
- package/dist/features/shell/build-sections.js +14 -1
- package/dist/features/shipments/components/AdminShipmentLotItemsView.js +54 -1
- package/dist/features/shipments/components/AdminShipmentProjectionsView.js +3 -2
- package/dist/features/sticker-trace/components/StickerCommitPanel.js +2 -1
- package/dist/features/sticker-trace/components/StickerPrintPanel.js +2 -1
- package/dist/features/sticker-trace/components/StickerRetryPanel.js +24 -4
- package/dist/features/sticker-trace/components/StickerTraceStudio.js +2 -1
- package/dist/features/sticker-trace/server.d.ts +2 -0
- package/dist/features/tester/components/AdminTesterFeedbackIssuesView.js +3 -2
- package/dist/http/api-handler.js +4 -1
- package/dist/index.d.ts +8 -4
- package/dist/index.js +6 -4
- package/dist/next/routing/route-map.d.ts +4 -2
- package/dist/next/routing/route-map.js +19 -1
- package/dist/schemas/registry.d.ts +15 -10
- package/dist/schemas/sieve.d.ts +3 -3
- package/dist/server.d.ts +1 -2
- package/dist/server.js +6 -4
- package/dist/styles.css +51 -0
- package/dist/ui/components/Checkbox.js +10 -2
- package/dist/ui/components/DateInput.js +8 -1
- package/dist/ui/components/Input.js +22 -2
- package/dist/ui/components/PaginatedSelect.d.ts +8 -19
- package/dist/ui/components/PaginatedSelect.js +0 -0
- package/dist/ui/components/Radio.js +8 -1
- package/dist/ui/components/Select.js +10 -2
- package/dist/ui/components/TagInput.d.ts +8 -1
- package/dist/ui/components/TagInput.js +4 -3
- package/dist/ui/components/Textarea.js +10 -2
- package/dist/ui/forms/FieldCheckbox.js +1 -1
- package/dist/ui/forms/FormErrorList.d.ts +0 -16
- package/dist/ui/forms/FormErrorList.js +34 -1
- package/dist/ui/forms/apply-zod-issues.d.ts +19 -1
- package/dist/ui/forms/apply-zod-issues.js +23 -1
- package/package.json +1 -1
- package/scripts/seed-cli.mjs +1822 -1812
- package/dist/features/tester/utils/phases.d.ts +0 -48
- 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
|
-
|
|
247
|
-
|
|
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." })] }),
|
|
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";
|
|
@@ -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 (
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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: "
|
|
124
|
+
defaultSort: sortBy(STORAGE_BOX_FIELDS.ORDINAL, "ASC"),
|
|
113
125
|
sortOptions: [
|
|
114
|
-
|
|
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
|
],
|