@mohasinac/appkit 4.61.0 → 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 (96) 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/jobs/core/paymentReconcile.js +87 -97
  6. package/dist/_internal/shared/actions/action-registry.js +149 -9
  7. package/dist/_internal/shared/media/limits.d.ts +10 -0
  8. package/dist/_internal/shared/media/limits.js +10 -0
  9. package/dist/_internal/shared/types/action-result.js +15 -1
  10. package/dist/client/api/surface-error.js +16 -2
  11. package/dist/client/api/useApiMutation.js +17 -1
  12. package/dist/client.d.ts +1 -1
  13. package/dist/client.js +1 -1
  14. package/dist/constants/api-endpoints.d.ts +3 -0
  15. package/dist/constants/api-endpoints.js +1 -0
  16. package/dist/constants/field-names.d.ts +17 -0
  17. package/dist/constants/field-names.js +23 -0
  18. package/dist/core/server-action.js +19 -3
  19. package/dist/errors/error-mapping.js +26 -1
  20. package/dist/features/account/components/UserOffersPanel.js +5 -3
  21. package/dist/features/admin/components/AdminActionIndexView.js +100 -5
  22. package/dist/features/admin/components/AdminAddressClustersView.js +21 -2
  23. package/dist/features/admin/components/AdminBlogEditorView.js +8 -8
  24. package/dist/features/admin/components/AdminCarouselEditorView.js +1 -1
  25. package/dist/features/admin/components/AdminCarouselView.js +96 -4
  26. package/dist/features/admin/components/AdminFulfillmentView.js +3 -2
  27. package/dist/features/admin/components/AdminMediaView.js +36 -3
  28. package/dist/features/admin/components/AdminNavigationView.js +4 -1
  29. package/dist/features/admin/components/AdminOffersView.js +2 -1
  30. package/dist/features/admin/components/AdminPaymentClustersView.js +19 -2
  31. package/dist/features/admin/components/AdminProductEditorView.js +2 -2
  32. package/dist/features/admin/components/AdminSectionsView.d.ts +10 -0
  33. package/dist/features/admin/components/AdminSectionsView.js +486 -224
  34. package/dist/features/admin/components/ReferenceRecordEditor.js +1 -1
  35. package/dist/features/admin/hooks/useOptimisticRowFlag.d.ts +33 -0
  36. package/dist/features/admin/hooks/useOptimisticRowFlag.js +87 -0
  37. package/dist/features/admin/repository/notification.repository.d.ts +15 -0
  38. package/dist/features/admin/repository/notification.repository.js +32 -0
  39. package/dist/features/admin/schemas/action-index-form.d.ts +34 -0
  40. package/dist/features/admin/schemas/action-index-form.js +115 -0
  41. package/dist/features/auth/components/RegisterForm.js +2 -1
  42. package/dist/features/catalogue/components/CatalogueItemEditorView.js +3 -3
  43. package/dist/features/events/components/AdminEventEditorView.js +8 -6
  44. package/dist/features/layout/hooks/useFormBottomActions.js +20 -1
  45. package/dist/features/orders/components/EmiInstallmentsPanel.js +25 -7
  46. package/dist/features/orders/components/RefundRequestView.js +4 -4
  47. package/dist/features/orders/schemas/index.d.ts +6 -6
  48. package/dist/features/products/components/GroupMemberPicker.js +4 -1
  49. package/dist/features/products/components/PrizeDrawEntryActions.js +2 -1
  50. package/dist/features/products/components/ProductDetailActions.js +2 -1
  51. package/dist/features/seller/components/FulfillmentView.js +3 -2
  52. package/dist/features/seller/components/SellerAddressesView.js +32 -4
  53. package/dist/features/seller/components/SellerAnalyticsAlertsView.js +62 -3
  54. package/dist/features/seller/components/SellerBundlesView.js +53 -14
  55. package/dist/features/seller/components/SellerGroupedListingsView.js +23 -3
  56. package/dist/features/seller/components/SellerListingTypeView.js +56 -10
  57. package/dist/features/seller/components/SellerPayoutMethodsView.js +33 -1
  58. package/dist/features/seller/components/SellerPayoutSettingsView.js +48 -9
  59. package/dist/features/seller/components/SellerProductsView.js +5 -1
  60. package/dist/features/seller/components/SellerShippingConfigsView.js +33 -1
  61. package/dist/features/seller/components/SellerShippingView.js +2 -1
  62. package/dist/features/seller/components/SellerStoreCategoriesView.js +33 -1
  63. package/dist/features/seller/components/SellerSupportView.js +69 -2
  64. package/dist/features/shell/SectionForm.js +20 -16
  65. package/dist/features/shell/build-sections.js +14 -1
  66. package/dist/features/shipments/components/AdminShipmentLotItemsView.js +54 -1
  67. package/dist/features/shipments/components/AdminShipmentProjectionsView.js +3 -2
  68. package/dist/features/sticker-trace/components/StickerCommitPanel.js +2 -1
  69. package/dist/features/sticker-trace/components/StickerPrintPanel.js +2 -1
  70. package/dist/features/sticker-trace/components/StickerRetryPanel.js +24 -4
  71. package/dist/features/sticker-trace/components/StickerTraceStudio.js +2 -1
  72. package/dist/features/tester/components/AdminTesterFeedbackIssuesView.js +3 -2
  73. package/dist/index.d.ts +5 -1
  74. package/dist/index.js +3 -1
  75. package/dist/next/routing/route-map.d.ts +4 -0
  76. package/dist/next/routing/route-map.js +9 -0
  77. package/dist/schemas/registry.d.ts +4 -4
  78. package/dist/schemas/sieve.d.ts +3 -3
  79. package/dist/server.d.ts +1 -1
  80. package/dist/server.js +1 -1
  81. package/dist/ui/components/Checkbox.js +10 -2
  82. package/dist/ui/components/DateInput.js +8 -1
  83. package/dist/ui/components/Input.js +22 -2
  84. package/dist/ui/components/PaginatedSelect.d.ts +8 -19
  85. package/dist/ui/components/PaginatedSelect.js +43 -9
  86. package/dist/ui/components/Radio.js +8 -1
  87. package/dist/ui/components/Select.js +10 -2
  88. package/dist/ui/components/TagInput.d.ts +8 -1
  89. package/dist/ui/components/TagInput.js +4 -3
  90. package/dist/ui/components/Textarea.js +10 -2
  91. package/dist/ui/forms/FieldCheckbox.js +1 -1
  92. package/dist/ui/forms/FormErrorList.d.ts +0 -16
  93. package/dist/ui/forms/FormErrorList.js +34 -1
  94. package/dist/ui/forms/apply-zod-issues.d.ts +19 -1
  95. package/dist/ui/forms/apply-zod-issues.js +23 -1
  96. package/package.json +1 -1
@@ -64,7 +64,7 @@ export function ReferenceRecordEditor({ endpoint, noun, fields, publicHref, }) {
64
64
  catch (err) {
65
65
  const e = normalizeError(err);
66
66
  if (!cancelled)
67
- setLoadError(e.message);
67
+ setLoadError(toUserMessage(e?.code, undefined, { fallback: "Could not load that record." }));
68
68
  }
69
69
  finally {
70
70
  if (!cancelled)
@@ -0,0 +1,33 @@
1
+ import { type QueryKey } from "@tanstack/react-query";
2
+ export interface UseOptimisticRowFlagOptions<T> {
3
+ /**
4
+ * Performs the write. Receives the row id and the value being set.
5
+ *
6
+ * 🛑 Send ONLY the field being changed. Several of this hook's consumers sit
7
+ * over repositories that merge a `config`/nested object by READING the
8
+ * document first; omitting that field short-circuits the read, so a toggle
9
+ * costs one write and no read.
10
+ */
11
+ mutate: (id: string, next: T) => Promise<unknown>;
12
+ /**
13
+ * Query keys to invalidate. Pass every key that renders this field —
14
+ * several of these records are read by a PUBLIC surface under a key of its
15
+ * own, and forgetting it leaves the storefront stale until its own TTL.
16
+ */
17
+ invalidate: readonly QueryKey[];
18
+ /** Toast copy. Receives the value that was set. */
19
+ successMessage: (next: T) => string;
20
+ /** Fallback when the thrown error carries no message. */
21
+ errorMessage?: string;
22
+ }
23
+ export interface UseOptimisticRowFlagReturn<T> {
24
+ /** `overrides[id] ?? row.value` — the value to render. */
25
+ overrides: Record<string, T>;
26
+ /** Resolve a row's current value against any pending override. */
27
+ resolve: (id: string, serverValue: T) => T;
28
+ /** The id currently being written, for a per-row disabled state. */
29
+ pendingId: string | null;
30
+ /** Fire the change. Safe to call from an `onChange` without awaiting. */
31
+ set: (id: string, next: T) => Promise<void>;
32
+ }
33
+ export declare function useOptimisticRowFlag<T>({ mutate, invalidate, successMessage, errorMessage, }: UseOptimisticRowFlagOptions<T>): UseOptimisticRowFlagReturn<T>;
@@ -0,0 +1,87 @@
1
+ "use client";
2
+ /*
3
+ * WHY: Eight listing views grew the same per-row "flip one field" control in
4
+ * one change — homepage sections (`enabled`), carousel slides (`active`),
5
+ * the six seller listing-type pages (`status`), seller bundles, grouped
6
+ * listings, store categories, shipping configs and payout methods
7
+ * (`isActive`). Every one needs an optimistic override so the switch
8
+ * moves under the finger, a PATCH, an invalidate, and a rollback.
9
+ *
10
+ * That is well past the Duplication Framework's Rule of Three, and the
11
+ * bug-fix multiplier is the real argument: the rollback is subtly wrong
12
+ * in the shape everyone writes first.
13
+ *
14
+ * 🛑 THE ROLLBACK BUG THIS EXISTS TO MAKE UNREPEATABLE
15
+ *
16
+ * The obvious implementation captures the previous override and writes it
17
+ * back on failure:
18
+ *
19
+ * const prev = overrides[id]; // `undefined` on a row's
20
+ * setOverrides((o) => ({...o, [id]: v})); // FIRST toggle
21
+ * catch { setOverrides((o) => ({...o, [id]: prev})); }
22
+ *
23
+ * `prev` is `undefined` the first time any given row is toggled, and writing
24
+ * `undefined` back is not the same as removing the key — a reader doing
25
+ * `overrides[id] ?? row.value` gets `undefined`, which for a boolean renders
26
+ * as `false`. So a FAILED toggle of a row the server holds as `true` leaves
27
+ * the UI asserting the opposite of the truth, which is worse than not having
28
+ * rolled back at all. `AdminProductsView.tsx` has this shape.
29
+ *
30
+ * The fix is to DELETE the key, which is what `clear` below does.
31
+ *
32
+ * 🛑 The key is cleared on SUCCESS too, and only AFTER the invalidation has
33
+ * settled. `invalidateQueries` resolves once active refetches finish, so by
34
+ * then `mapRows` is already reading the fresh server value and there is no
35
+ * visible flip-back-then-forward. An override that is never cleared pins the
36
+ * row forever and masks every later server-side change to it.
37
+ *
38
+ * EXPORTS: useOptimisticRowFlag, UseOptimisticRowFlagOptions
39
+ *
40
+ * @tag domain:admin,seller
41
+ * @tag layer:hook
42
+ * @tag pattern:none
43
+ * @tag access:client
44
+ * @tag consumers:AdminSectionsView,AdminCarouselView,SellerListingTypeView,SellerBundlesView,SellerGroupedListingsView,SellerStoreCategoriesView,SellerShippingConfigsView,SellerPayoutMethodsView
45
+ * @tag sideEffects:network
46
+ */
47
+ import React from "react";
48
+ import { useQueryClient } from "@tanstack/react-query";
49
+ import { useToast } from "../../../ui/index.js";
50
+ import { normalizeError } from "../../../errors/normalize.js";
51
+ import { toUserMessage } from "../../../errors/error-display-map.js";
52
+ export function useOptimisticRowFlag({ mutate, invalidate, successMessage, errorMessage = "Could not save that change.", }) {
53
+ const queryClient = useQueryClient();
54
+ const { showToast } = useToast();
55
+ const [overrides, setOverrides] = React.useState({});
56
+ const [pendingId, setPendingId] = React.useState(null);
57
+ const clear = React.useCallback((id) => {
58
+ setOverrides((prev) => {
59
+ if (!(id in prev))
60
+ return prev;
61
+ const next = { ...prev };
62
+ // DELETE, never write back a captured value — see the header.
63
+ delete next[id];
64
+ return next;
65
+ });
66
+ }, []);
67
+ const set = React.useCallback(async (id, next) => {
68
+ setOverrides((prev) => ({ ...prev, [id]: next }));
69
+ setPendingId(id);
70
+ try {
71
+ await mutate(id, next);
72
+ await Promise.all(invalidate.map((queryKey) => queryClient.invalidateQueries({ queryKey })));
73
+ clear(id);
74
+ showToast(successMessage(next), "success");
75
+ }
76
+ catch (err) {
77
+ const normalized = normalizeError(err);
78
+ clear(id);
79
+ showToast(toUserMessage(normalized.code, undefined, { fallback: errorMessage }), "error");
80
+ }
81
+ finally {
82
+ setPendingId(null);
83
+ }
84
+ }, [mutate, invalidate, queryClient, clear, showToast, successMessage, errorMessage]);
85
+ const resolve = React.useCallback((id, serverValue) => (id in overrides ? overrides[id] : serverValue), [overrides]);
86
+ return { overrides, resolve, pendingId, set };
87
+ }
@@ -30,6 +30,21 @@ export declare class NotificationRepository extends BaseRepository<NotificationD
30
30
  * Get unread notification count for a user.
31
31
  */
32
32
  getUnreadCount(userId: string): Promise<number>;
33
+ /**
34
+ * Mark a single notification UNREAD again.
35
+ *
36
+ * 🛑 `readAt` is cleared, not left behind. It is the timestamp of a read
37
+ * that the caller is now saying did not happen, and a row that is
38
+ * `isRead: false` with a `readAt` set is a contradiction the next reader has
39
+ * to resolve — the digest and the unread badge key on `isRead`, so they
40
+ * would disagree with anything that looked at `readAt`.
41
+ *
42
+ * There was no way to do this at all: the buyer's notifications page offered
43
+ * only Mark-read, and the PATCH route hard-coded `markAsRead(id)` without
44
+ * reading a body — so a notification clicked by accident was gone from the
45
+ * unread list for good.
46
+ */
47
+ markAsUnread(id: string): Promise<NotificationDocument | null>;
33
48
  /**
34
49
  * Mark a single notification as read.
35
50
  */
@@ -59,6 +59,38 @@ export class NotificationRepository extends BaseRepository {
59
59
  return 0;
60
60
  }
61
61
  }
62
+ /**
63
+ * Mark a single notification UNREAD again.
64
+ *
65
+ * 🛑 `readAt` is cleared, not left behind. It is the timestamp of a read
66
+ * that the caller is now saying did not happen, and a row that is
67
+ * `isRead: false` with a `readAt` set is a contradiction the next reader has
68
+ * to resolve — the digest and the unread badge key on `isRead`, so they
69
+ * would disagree with anything that looked at `readAt`.
70
+ *
71
+ * There was no way to do this at all: the buyer's notifications page offered
72
+ * only Mark-read, and the PATCH route hard-coded `markAsRead(id)` without
73
+ * reading a body — so a notification clicked by accident was gone from the
74
+ * unread list for good.
75
+ */
76
+ async markAsUnread(id) {
77
+ try {
78
+ const now = new Date();
79
+ await this.getCollection()
80
+ .doc(id)
81
+ .update(prepareForFirestore({
82
+ [NOTIFICATION_FIELDS.IS_READ]: false,
83
+ [NOTIFICATION_FIELDS.READ_AT]: null,
84
+ [NOTIFICATION_FIELDS.UPDATED_AT]: now,
85
+ }));
86
+ return this.findById(id);
87
+ }
88
+ catch (error) {
89
+ void normalizeError(error);
90
+ serverLogger.error("Failed to mark notification unread", { id, error });
91
+ throw error;
92
+ }
93
+ }
62
94
  /**
63
95
  * Mark a single notification as read.
64
96
  */
@@ -0,0 +1,34 @@
1
+ import { z } from "zod";
2
+ import type { QuickFieldDef } from "../../shell/QuickFormDrawer";
3
+ /**
4
+ * 🛑 `keywords` is a COMMA-SEPARATED STRING here, not an array.
5
+ *
6
+ * `QuickFormDrawer` has no array control — its field types are text / number
7
+ * / select / toggle / date / textarea / email / url. Declaring `z.string()`
8
+ * and splitting at the call site keeps the schema honest about what the form
9
+ * actually produces, rather than describing a shape no control can emit.
10
+ * `splitKeywords` below is the one place that conversion happens.
11
+ */
12
+ export declare const actionIndexOverrideSchema: z.ZodObject<{
13
+ label: z.ZodString;
14
+ description: z.ZodOptional<z.ZodString>;
15
+ keywords: z.ZodOptional<z.ZodString>;
16
+ weight: z.ZodOptional<z.ZodNumber>;
17
+ }, "strip", z.ZodTypeAny, {
18
+ label: string;
19
+ weight?: number | undefined;
20
+ description?: string | undefined;
21
+ keywords?: string | undefined;
22
+ }, {
23
+ label: string;
24
+ weight?: number | undefined;
25
+ description?: string | undefined;
26
+ keywords?: string | undefined;
27
+ }>;
28
+ export type ActionIndexOverrideFormInput = z.infer<typeof actionIndexOverrideSchema>;
29
+ /** The drawer's fields, in the order an admin reads them. */
30
+ export declare const ACTION_INDEX_OVERRIDE_FIELDS: QuickFieldDef[];
31
+ /** The single place the comma-separated field becomes the stored array. */
32
+ export declare function splitKeywords(raw: string | undefined): string[] | undefined;
33
+ /** The reverse, for seeding the form from a stored override. */
34
+ export declare function joinKeywords(keywords: readonly string[] | undefined): string;
@@ -0,0 +1,115 @@
1
+ /*
2
+ * WHY: `/admin/action-index` told admins "Labels, descriptions and keywords
3
+ * can be edited here" while offering only a Shown toggle. The server has
4
+ * always accepted those fields — `ActionIndexOverride` carries `label`,
5
+ * `description`, `keywords` and `weight`, and `PATCH
6
+ * /api/admin/action-index` writes them through `setOverride`. The UI had
7
+ * simply never grown the control, so the sentence was a promise nothing
8
+ * kept.
9
+ *
10
+ * WHAT: The write contract for ONE entry override, as edited from the admin
11
+ * row action.
12
+ *
13
+ * ## What is deliberately absent
14
+ *
15
+ * `href`, `kind`, `portal` and every permission field. The view's own
16
+ * docstring draws this line and it is the right one: repointing a built-in
17
+ * route is routing, and relaxing a permission is access control. Neither
18
+ * belongs in a quick-edit drawer beside a label.
19
+ *
20
+ * `enabled` is also absent — it has its own control in the Shown column, and
21
+ * a second place to set it is a second source of truth for one boolean.
22
+ *
23
+ * EXPORTS:
24
+ * actionIndexOverrideSchema, ActionIndexOverrideFormInput,
25
+ * ACTION_INDEX_OVERRIDE_FIELDS
26
+ *
27
+ * @tag domain:admin,search
28
+ * @tag layer:schema
29
+ * @tag pattern:none
30
+ * @tag access:isomorphic
31
+ * @tag consumers:AdminActionIndexView
32
+ * @tag sideEffects:none
33
+ */
34
+ import { z } from "zod";
35
+ /**
36
+ * 🛑 `keywords` is a COMMA-SEPARATED STRING here, not an array.
37
+ *
38
+ * `QuickFormDrawer` has no array control — its field types are text / number
39
+ * / select / toggle / date / textarea / email / url. Declaring `z.string()`
40
+ * and splitting at the call site keeps the schema honest about what the form
41
+ * actually produces, rather than describing a shape no control can emit.
42
+ * `splitKeywords` below is the one place that conversion happens.
43
+ */
44
+ export const actionIndexOverrideSchema = z.object({
45
+ label: z
46
+ .string()
47
+ .trim()
48
+ .min(1, "A label is required — it is what the sidebar and search show.")
49
+ .max(80, "Keep labels under 80 characters so they fit the sidebar."),
50
+ description: z
51
+ .string()
52
+ .trim()
53
+ .max(200, "Keep descriptions under 200 characters.")
54
+ .optional(),
55
+ keywords: z
56
+ .string()
57
+ .trim()
58
+ .max(300, "Keep the keyword list under 300 characters.")
59
+ .optional(),
60
+ /*
61
+ * Ranking, not ordering. Higher sorts first in the search and the command
62
+ * palette. Bounded because an unbounded weight is how one entry ends up
63
+ * permanently above every other and nobody remembers why.
64
+ */
65
+ weight: z.coerce
66
+ .number()
67
+ .int("Weight must be a whole number.")
68
+ .min(0, "Weight cannot be negative.")
69
+ .max(100, "Weight is capped at 100.")
70
+ .optional(),
71
+ });
72
+ /** The drawer's fields, in the order an admin reads them. */
73
+ export const ACTION_INDEX_OVERRIDE_FIELDS = [
74
+ {
75
+ name: "label",
76
+ label: "Label",
77
+ type: "text",
78
+ required: true,
79
+ helperText: "What the sidebar, header search and command palette show.",
80
+ },
81
+ {
82
+ name: "description",
83
+ label: "Description",
84
+ type: "textarea",
85
+ placeholder: "One line explaining what this entry is for",
86
+ },
87
+ {
88
+ name: "keywords",
89
+ label: "Keywords",
90
+ type: "text",
91
+ placeholder: "refunds, returns, money back",
92
+ helperText: "Comma-separated. Extra words that should find this entry in search.",
93
+ },
94
+ {
95
+ name: "weight",
96
+ label: "Weight",
97
+ type: "number",
98
+ helperText: "Higher sorts first in search results. 0–100.",
99
+ },
100
+ ];
101
+ /** The single place the comma-separated field becomes the stored array. */
102
+ export function splitKeywords(raw) {
103
+ if (raw === undefined)
104
+ return undefined;
105
+ const parts = raw
106
+ .split(",")
107
+ .map((part) => part.trim())
108
+ .filter(Boolean);
109
+ // An empty box means "no keywords", which is `[]` — not "leave them alone".
110
+ return parts;
111
+ }
112
+ /** The reverse, for seeding the form from a stored override. */
113
+ export function joinKeywords(keywords) {
114
+ return (keywords ?? []).join(", ");
115
+ }
@@ -11,6 +11,7 @@ import { FormShellContext, useFormShellState } from "../../../ui/forms/FormShell
11
11
  import { registerSchema, registerPasswordSchema } from "../schemas/index.js";
12
12
  import { FormErrorSummary } from "../../../ui/forms/FormErrorSummary.js";
13
13
  import { SectionForm, useSectionFormNav } from "../../shell/SectionForm.js";
14
+ import { toUserMessage } from "../../../errors/error-display-map.js";
14
15
  const registerClientSchema = registerSchema.extend({
15
16
  displayName: z.string().min(1, "Enter your name"),
16
17
  confirmPassword: registerPasswordSchema,
@@ -81,7 +82,7 @@ export function RegisterForm({ onSubmit, isLoading = false, error, success, labe
81
82
  * email, a rejected password — which is the one thing they can act on.
82
83
  */
83
84
  const normalized = normalizeError(err);
84
- setFieldError("email", normalized.message || "Registration failed");
85
+ setFieldError("email", toUserMessage(normalized.code, undefined, { fallback: "We couldn't create your account. Please check your details and try again." }));
85
86
  }
86
87
  };
87
88
  return (_jsx(Row, { className: `min-h-[60vh] ${className}`, align: "center", justify: "center", padding: "x-md", children: _jsxs(Stack, { className: "max-w-md w-full", gap: "lg", children: [_jsxs(Div, { className: "text-center", children: [_jsx(Row, { justify: "center", className: "mb-3", children: _jsx(SiteMark, { size: "lg" }) }), _jsx(Heading, { level: 1, className: "font-extrabold", size: "3xl", children: labels.title ?? "Create Account" }), (labels.subtitle || renderLoginLink) && (_jsxs(Text, { className: "mt-2 text-[var(--appkit-color-text-muted)]", size: "sm", children: [labels.subtitle ?? "Already have an account?", " ", renderLoginLink?.()] }))] }), success && (_jsx(Alert, { variant: "success", compact: true, children: success })), error && (_jsx(Alert, { variant: "error", compact: true, children: error })), _jsxs(FormShellContext.Provider, { value: shellCtx, children: [_jsx(FormErrorSummary, {}), _jsx(SectionForm, { sections: sections, values: values, onChange: (partial) => setValues((prev) => Object.assign({}, prev, partial)), onSubmit: handleSubmit, schema: registerClientSchema, openIds: nav.openIds, onOpenChange: nav.setOpenIds, isLoading: isLoading, submitLabel: isLoading
@@ -95,11 +95,11 @@ export function CatalogueItemEditorView({ item, onSaved }) {
95
95
  // than pointing at a photo the user has since removed.
96
96
  onChange({ images: urls, mainImage: urls[0] ?? "" });
97
97
  }, onUpload: handleUpload, accept: IMAGE_ACCEPT_ATTR, maxItems: 8, helperText: "Photos older than 30 days must be refreshed before this item can be listed." })),
98
- categorySlugs: ({ values, onChange }) => (
98
+ categorySlugs: ({ values, onChange, errors }) => (
99
99
  // Explicit `<string>`: with `multiple`, `value` is `V[]`, so an
100
100
  // inferred V reads the array itself as the option type.
101
- _jsx(PaginatedSelect, { multiple: true, value: values.categorySlugs, onChange: (v) => onChange({ categorySlugs: v ?? [] }), loadOptions: loadCategoryOptions, placeholder: "Search categories\u2026", searchPlaceholder: "Type a category name\u2026", noResultsText: "No categories found", ariaLabel: "Categories" })),
102
- brandSlug: ({ values, onChange }) => (_jsx(PaginatedSelect, { value: values.brandSlug || null, onChange: (v) => onChange({ brandSlug: v ?? "" }), loadOptions: loadBrandOptions, placeholder: "Search brands\u2026", searchPlaceholder: "Type a brand name\u2026", noResultsText: "No brands found", ariaLabel: "Brand" })),
101
+ _jsx(PaginatedSelect, { multiple: true, name: "categorySlugs", error: errors.categorySlugs, value: values.categorySlugs, onChange: (v) => onChange({ categorySlugs: v ?? [] }), loadOptions: loadCategoryOptions, placeholder: "Search categories\u2026", searchPlaceholder: "Type a category name\u2026", noResultsText: "No categories found", ariaLabel: "Categories" })),
102
+ brandSlug: ({ values, onChange, errors }) => (_jsx(PaginatedSelect, { name: "brandSlug", error: errors.brandSlug, value: values.brandSlug || null, onChange: (v) => onChange({ brandSlug: v ?? "" }), loadOptions: loadBrandOptions, placeholder: "Search brands\u2026", searchPlaceholder: "Type a brand name\u2026", noResultsText: "No brands found", ariaLabel: "Brand" })),
103
103
  },
104
104
  }), [handleUpload]);
105
105
  const nav = useSectionFormNav(sections, form, { scope: "user:catalogue-item" });
@@ -407,10 +407,10 @@ export function AdminEventEditorView({ eventId, onSaved, embedded, allowedTypes,
407
407
  // The per-step `validate` is gone: every rule it stated is already in
408
408
  // eventDraftSchema (title/startsAt/endsAt are .min(1)), and it only ever
409
409
  // acted as a gate to the next step.
410
- 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) => {
411
411
  const v = e.target.value;
412
412
  onChange({ title: v, ...(!slugManual && { slug: toEventSlug(v) }) });
413
- }, 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" })] })),
414
414
  },
415
415
  {
416
416
  id: "media",
@@ -419,7 +419,7 @@ export function AdminEventEditorView({ eventId, onSaved, embedded, allowedTypes,
419
419
  // section mid-upload discards the transfer.
420
420
  keepMounted: true,
421
421
  fields: ["coverImageUrl"],
422
- 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." })] })),
423
423
  },
424
424
  {
425
425
  id: "settings",
@@ -428,17 +428,19 @@ export function AdminEventEditorView({ eventId, onSaved, embedded, allowedTypes,
428
428
  // Same: the offer-coupon, offer-display-code and poll-minimum-options
429
429
  // rules are all stated in eventDraftSchema's superRefine, which now
430
430
  // surfaces them inline on the field instead of as a step-blocking banner.
431
- 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." })] }), _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 %", 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 }) })] }))] })),
432
434
  },
433
435
  {
434
436
  id: "raffle",
435
437
  label: "Raffle / Spin",
436
438
  fields: ["hasRaffle", "raffleType", "raffleTopN", "rafflePrize", "rafflePrizeCouponId", "rafflePrizeProductIds", "spinPrizes", "spinMaxPerUser", "spinWindowStart", "spinWindowEnd"],
437
- render: ({ values, onChange }) => {
439
+ render: ({ values, onChange, errors }) => {
438
440
  const isRaffleType = values.type === "raffle" || values.type === "spin_wheel";
439
441
  const showRaffleConfig = isRaffleType || values.hasRaffle;
440
442
  const isSpinMode = values.type === "spin_wheel" || values.raffleType === "spin_wheel";
441
- 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 })] }))] }))] }));
442
444
  },
443
445
  },
444
446
  ];
@@ -3,6 +3,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { useContext, useMemo } from "react";
4
4
  import { FormShellContext } from "../../../ui/forms/FormShell.js";
5
5
  import { FormErrorList } from "../../../ui/forms/FormErrorList.js";
6
+ import { Div } from "../../../ui/components/Div.js";
6
7
  import { useIsInsideOverlay } from "../../../ui/components/overlay-context.js";
7
8
  import { useBottomActions } from "./useBottomActions.js";
8
9
  /**
@@ -98,7 +99,25 @@ export function useFormBottomActions({ onSubmit, onCancel, submitLabel = "Save",
98
99
  infoLabel: showErrors
99
100
  ? `Fix ${errorCount} ${errorCount === 1 ? "issue" : "issues"}`
100
101
  : undefined,
101
- infoPanel: showErrors && ctx ? _jsx(FormErrorList, { ctx: ctx }) : undefined,
102
+ /*
103
+ * 🛑 The alert region lives HERE, not in `FormErrorList`.
104
+ *
105
+ * Without it a failed submit on a phone announced nothing at all: the
106
+ * list has no role, `InfoPanel` has no role, and the only signal was the
107
+ * disclosure button's text changing — which a screen reader does not read
108
+ * out unprompted. This is the whole error surface on mobile, since
109
+ * `useBottomActions` defaults to `desktop: "hidden"`.
110
+ *
111
+ * It cannot go on `FormErrorList` itself: the list's other consumer is
112
+ * `<FormErrorSummary>`, whose wrapper is already `role="alert"`, and
113
+ * nesting one alert region inside another makes the announcement
114
+ * ambiguous and duplicated.
115
+ *
116
+ * `aria-live="polite"` rather than `role="alert"`'s implicit assertive —
117
+ * the user has just pressed Save and is expecting a response, so there is
118
+ * nothing to interrupt. Same choice `FormErrorSummary` makes.
119
+ */
120
+ infoPanel: showErrors && ctx ? (_jsx(Div, { role: "alert", "aria-live": "polite", children: _jsx(FormErrorList, { ctx: ctx }) })) : undefined,
102
121
  // Bumped by every submit attempt, so a second failed Save re-opens a
103
122
  // sheet the user collapsed — while an ordinary re-render does not.
104
123
  infoOpenSignal: showErrors ? ctx?.submitAttemptCount : undefined,
@@ -4,6 +4,7 @@ import { useState } from "react";
4
4
  import { Badge, Button, Div, Form, Row, Stack, Text, useToast } from "../../../ui/index.js";
5
5
  import { FieldInput } from "../../../ui/forms/index.js";
6
6
  import { formatCurrency } from "../../../utils/number.formatter.js";
7
+ import { toUserMessage } from "../../../errors/error-display-map.js";
7
8
  import { normalizeError } from "../../../errors/normalize.js";
8
9
  function stateOf(i) {
9
10
  if (i.status === "paid")
@@ -28,19 +29,34 @@ export function EmiInstallmentsPanel({ orderId, installments, remainingBalance,
28
29
  const money = (value) => formatCurrency(value, currency);
29
30
  const ordered = [...installments].sort((a, b) => a.index - b.index);
30
31
  const outstanding = ordered.filter((i) => i.status !== "paid").length;
31
- const submit = async (index) => {
32
+ /*
33
+ * Takes `setFieldError` from the <Form> render prop rather than closing
34
+ * over state, because the failure belongs ON the reference field.
35
+ *
36
+ * This panel used to be toast-only AND gate submission by disabling the
37
+ * button on an empty reference — so an incomplete form could not be sent
38
+ * and said nothing about why, and a rejected reference produced a toast
39
+ * that was gone before the buyer looked back at the input. Both are money
40
+ * surfaces on somebody's order.
41
+ */
42
+ const submit = async (index, fieldName, setFieldError) => {
43
+ const trimmed = reference.trim();
44
+ if (trimmed.length === 0) {
45
+ setFieldError(fieldName, "Enter the UTR or reference number from your transfer.");
46
+ return;
47
+ }
32
48
  setIsSubmitting(true);
33
49
  try {
34
- await onSubmit({ installmentIndex: index, transactionId: reference.trim() });
50
+ await onSubmit({ installmentIndex: index, transactionId: trimmed });
35
51
  showToast("Payment reference submitted — the seller will confirm it.", "success");
36
52
  setOpenIndex(null);
37
53
  setReference("");
38
54
  }
39
55
  catch (err) {
40
- void normalizeError(err);
41
- // The action's own message is surfaced by the caller's error mapping;
42
- // this only has to stop the panel pretending the submission landed.
43
- showToast("Couldn't submit that payment. Please try again.", "error");
56
+ const normalized = normalizeError(err);
57
+ setFieldError(fieldName, toUserMessage(normalized.code, undefined, {
58
+ fallback: "Couldn't submit that payment reference. Please try again.",
59
+ }));
44
60
  }
45
61
  finally {
46
62
  setIsSubmitting(false);
@@ -49,9 +65,11 @@ export function EmiInstallmentsPanel({ orderId, installments, remainingBalance,
49
65
  return (_jsx(Div, { surface: "card", border: "default", rounded: "lg", padding: "md", children: _jsxs(Stack, { gap: "sm", children: [_jsxs(Row, { align: "center", justify: "between", gap: "sm", children: [_jsx(Text, { weight: "semibold", children: "Your EMI plan" }), remainingBalance !== undefined && remainingBalance > 0 && (_jsxs(Text, { size: "sm", color: "muted", children: [money(remainingBalance), " outstanding across ", outstanding, " instalment", outstanding === 1 ? "" : "s"] }))] }), _jsx(Text, { size: "xs", color: "muted", children: "You can pay any instalment whenever you like, including ahead of its due date \u2014 paying early settles the plan sooner and does not cost more." }), _jsx(Stack, { gap: "xs", children: ordered.map((inst) => {
50
66
  const state = stateOf(inst);
51
67
  const due = inst.dueDate ? new Date(inst.dueDate) : null;
68
+ // Per-installment, so each open row's error is keyed to its own field.
69
+ const fieldName = `emi-reference-${orderId}-${inst.index}`;
52
70
  return (_jsxs(Div, { border: "default", rounded: "md", padding: "sm", children: [_jsxs(Row, { align: "center", justify: "between", gap: "sm", className: "min-w-0", children: [_jsxs(Stack, { gap: "xs", className: "min-w-0", children: [_jsxs(Text, { size: "sm", weight: "medium", children: ["Instalment ", inst.index, " \u00B7 ", money(inst.amount)] }), _jsx(Text, { size: "xs", color: "muted", children: due ? `Due ${due.toLocaleDateString()}` : "No due date recorded" })] }), _jsxs(Row, { align: "center", gap: "xs", className: "flex-shrink-0", children: [_jsx(Badge, { variant: state === "paid" ? "success" : state === "awaiting-review" ? "warning" : "default", children: STATE_LABEL[state] }), state === "payable" && (_jsx(Button, { type: "button", size: "sm", variant: "secondary", onClick: () => {
53
71
  setOpenIndex(openIndex === inst.index ? null : inst.index);
54
72
  setReference("");
55
- }, children: openIndex === inst.index ? "Cancel" : "Pay now" }))] })] }), openIndex === inst.index && (_jsx(Div, { className: "mt-3", children: _jsx(Form, { onSubmit: (e) => e.preventDefault(), children: () => (_jsxs(Stack, { gap: "sm", children: [_jsxs(Text, { size: "xs", color: "muted", children: ["Transfer ", money(inst.amount), " to the seller, then enter the UTR or reference number so they can confirm it."] }), _jsx(FieldInput, { name: `emi-reference-${orderId}-${inst.index}`, label: "UTR / reference number", required: true, value: reference, onChange: setReference }), _jsx(Button, { type: "submit", size: "sm", isLoading: isSubmitting, disabled: reference.trim().length === 0 || isSubmitting, onClick: () => { void submit(inst.index); }, children: "Submit payment reference" })] })) }) }))] }, inst.index));
73
+ }, children: openIndex === inst.index ? "Cancel" : "Pay now" }))] })] }), openIndex === inst.index && (_jsx(Div, { className: "mt-3", children: _jsx(Form, { onSubmit: (e) => e.preventDefault(), children: ({ setFieldError, clearErrors }) => (_jsxs(Stack, { gap: "sm", children: [_jsxs(Text, { size: "xs", color: "muted", children: ["Transfer ", money(inst.amount), " to the seller, then enter the UTR or reference number so they can confirm it."] }), _jsx(FieldInput, { name: fieldName, label: "UTR / reference number", required: true, value: reference, onChange: (v) => { clearErrors(); setReference(v); } }), _jsx(Button, { type: "submit", size: "sm", isLoading: isSubmitting, disabled: isSubmitting, onClick: () => { void submit(inst.index, fieldName, setFieldError); }, children: "Submit payment reference" })] })) }) }))] }, inst.index));
56
74
  }) })] }) }));
57
75
  }
@@ -71,7 +71,7 @@ export function RefundRequestView({ order, onSubmitRequest, isLoading = false, c
71
71
  label: "Why are you returning this?",
72
72
  required: true,
73
73
  fields: ["reasonCode", "reasonNote"],
74
- render: ({ values, onChange }) => (_jsxs(Stack, { gap: "md", children: [_jsxs(Text, { size: "sm", color: "muted", children: [REFUND_COPY.request.orderTotalLabel, " ", _jsx(Span, { weight: "bold", children: formatCurrency(order.total, order.currency ?? "INR") })] }), hasFinalSaleLine && (_jsx(Alert, { variant: "warning", title: REFUND_COPY.request.finalSaleNoticeHeading, children: "You can\u2019t return it for a change of mind, but you can still claim if it never arrived, arrived damaged, was the wrong item, wasn\u2019t as described, or is counterfeit. Only those reasons are listed below." })), _jsxs(Stack, { gap: "xs", children: [_jsxs(Text, { size: "sm", weight: "semibold", children: [REFUND_COPY.request.reasonSelectLabel, " ", _jsx(Span, { "aria-hidden": true, children: "*" })] }), _jsx(PaginatedSelect, { value: values.reasonCode === "" ? null : values.reasonCode, onChange: (v) => onChange({ reasonCode: v ?? "" }), options: allowedReasons.map((r) => ({
74
+ render: ({ values, onChange, errors }) => (_jsxs(Stack, { gap: "md", children: [_jsxs(Text, { size: "sm", color: "muted", children: [REFUND_COPY.request.orderTotalLabel, " ", _jsx(Span, { weight: "bold", children: formatCurrency(order.total, order.currency ?? "INR") })] }), hasFinalSaleLine && (_jsx(Alert, { variant: "warning", title: REFUND_COPY.request.finalSaleNoticeHeading, children: "You can\u2019t return it for a change of mind, but you can still claim if it never arrived, arrived damaged, was the wrong item, wasn\u2019t as described, or is counterfeit. Only those reasons are listed below." })), _jsxs(Stack, { gap: "xs", children: [_jsxs(Text, { size: "sm", weight: "semibold", children: [REFUND_COPY.request.reasonSelectLabel, " ", _jsx(Span, { "aria-hidden": true, children: "*" })] }), _jsx(PaginatedSelect, { name: "reasonCode", error: errors.reasonCode, value: values.reasonCode === "" ? null : values.reasonCode, onChange: (v) => onChange({ reasonCode: v ?? "" }), options: allowedReasons.map((r) => ({
75
75
  value: r,
76
76
  label: RETURN_REASON_LABEL[r],
77
77
  })), placeholder: REFUND_COPY.request.reasonSelectPlaceholder, ariaLabel: REFUND_COPY.request.reasonSelectLabel })] }), _jsx(FieldTextarea, { name: "reasonNote", label: REFUND_COPY.request.reasonNoteLabel, value: values.reasonNote, onChange: (v) => onChange({ reasonNote: v }), rows: 3, maxLength: 500, showCharCount: true })] })),
@@ -81,9 +81,9 @@ export function RefundRequestView({ order, onSubmitRequest, isLoading = false, c
81
81
  label: REFUND_COPY.request.acknowledgeHeading,
82
82
  required: true,
83
83
  fields: ["acknowledged"],
84
- render: ({ values, onChange }) => (_jsx(Stack, { gap: "sm", children: REFUND_COPY.request.acknowledgments.map((text, i) => (_jsx(Checkbox, { checked: values.acknowledged[i] ?? false, onChange: () => onChange({
85
- acknowledged: values.acknowledged.map((v, idx) => idx === i ? !v : v),
86
- }), label: _jsx(Text, { size: "sm", className: "leading-snug", children: text }), "aria-label": `Acknowledgment ${i + 1}` }, i))) })),
84
+ render: ({ values, onChange, errors }) => (_jsxs(Stack, { gap: "sm", children: [REFUND_COPY.request.acknowledgments.map((text, i) => (_jsx(Checkbox, { checked: values.acknowledged[i] ?? false, onChange: () => onChange({
85
+ acknowledged: values.acknowledged.map((v, idx) => idx === i ? !v : v),
86
+ }), label: _jsx(Text, { size: "sm", className: "leading-snug", children: text }), "aria-label": `Acknowledgment ${i + 1}`, "aria-invalid": errors.acknowledged ? "true" : undefined }, i))), errors.acknowledged && (_jsx(Text, { size: "sm", variant: "error", role: "alert", children: errors.acknowledged }))] })),
87
87
  },
88
88
  ], [order.total, order.currency, hasFinalSaleLine, allowedReasons]);
89
89
  const nav = useSectionFormNav(sections, draft);