@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.
- 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/jobs/core/paymentReconcile.js +87 -97
- package/dist/_internal/shared/actions/action-registry.js +149 -9
- package/dist/_internal/shared/media/limits.d.ts +10 -0
- package/dist/_internal/shared/media/limits.js +10 -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/errors/error-mapping.js +26 -1
- 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/AdminFulfillmentView.js +3 -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/ReferenceRecordEditor.js +1 -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/schemas/action-index-form.d.ts +34 -0
- package/dist/features/admin/schemas/action-index-form.js +115 -0
- package/dist/features/auth/components/RegisterForm.js +2 -1
- package/dist/features/catalogue/components/CatalogueItemEditorView.js +3 -3
- package/dist/features/events/components/AdminEventEditorView.js +8 -6
- package/dist/features/layout/hooks/useFormBottomActions.js +20 -1
- package/dist/features/orders/components/EmiInstallmentsPanel.js +25 -7
- package/dist/features/orders/components/RefundRequestView.js +4 -4
- package/dist/features/orders/schemas/index.d.ts +6 -6
- package/dist/features/products/components/GroupMemberPicker.js +4 -1
- package/dist/features/products/components/PrizeDrawEntryActions.js +2 -1
- package/dist/features/products/components/ProductDetailActions.js +2 -1
- 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/shell/SectionForm.js +20 -16
- 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/tester/components/AdminTesterFeedbackIssuesView.js +3 -2
- package/dist/index.d.ts +5 -1
- package/dist/index.js +3 -1
- package/dist/next/routing/route-map.d.ts +4 -0
- package/dist/next/routing/route-map.js +9 -0
- package/dist/schemas/registry.d.ts +4 -4
- package/dist/schemas/sieve.d.ts +3 -3
- package/dist/server.d.ts +1 -1
- package/dist/server.js +1 -1
- 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 +43 -9
- 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
|
@@ -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.
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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:
|
|
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 }) => (
|
|
85
|
-
|
|
86
|
-
|
|
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);
|