@mohasinac/appkit 4.31.1 → 4.32.1
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/_internal/server/features/products/list-public.d.ts +18 -0
- package/dist/_internal/server/features/products/list-public.js +27 -9
- package/dist/client.d.ts +9 -0
- package/dist/client.js +12 -0
- package/dist/features/account/components/UserBidsView.js +6 -1
- package/dist/features/account/components/UserOrdersView.js +8 -1
- package/dist/features/account/components/UserReturnsView.js +6 -1
- package/dist/features/admin/components/AdminAdsView.js +36 -34
- package/dist/features/admin/components/AdminAuditLogView.js +12 -1
- package/dist/features/admin/components/AdminBundlesView.js +5 -1
- package/dist/features/admin/components/AdminCarouselEditorView.js +78 -10
- package/dist/features/admin/components/AdminFaqsView.js +10 -1
- package/dist/features/admin/components/AdminNotificationsView.js +7 -1
- package/dist/features/admin/components/AdminOrderEditorView.js +55 -24
- package/dist/features/admin/components/AdminPrizeDrawsView.js +11 -1
- package/dist/features/admin/components/AdminProductEditorView.js +78 -18
- package/dist/features/admin/components/AdminSectionsView.js +89 -32
- package/dist/features/admin/components/AdminSiteSettingsView.js +96 -44
- package/dist/features/admin/components/AdminStoresView.js +13 -1
- package/dist/features/admin/components/AdminSupportTicketDetailView.js +58 -43
- package/dist/features/admin/components/AdminSupportTicketsView.js +11 -1
- package/dist/features/admin/components/AdminTeamView.js +11 -1
- package/dist/features/admin/components/AdminTesterChecklistView.js +6 -1
- package/dist/features/admin/components/AdminUserEditorView.js +65 -82
- package/dist/features/admin/components/analytics/AdminPageViewsReportView.js +6 -1
- package/dist/features/admin/constants/admin-page-tabs.d.ts +36 -0
- package/dist/features/admin/constants/admin-page-tabs.js +45 -0
- package/dist/features/admin/schemas/admin-ops-forms.d.ts +81 -0
- package/dist/features/admin/schemas/admin-ops-forms.js +123 -1
- package/dist/features/admin/schemas/admin-user-form.d.ts +78 -3
- package/dist/features/admin/schemas/admin-user-form.js +96 -7
- package/dist/features/admin/schemas/homepage-section-form.d.ts +9 -0
- package/dist/features/layout/PageTabs.d.ts +17 -0
- package/dist/features/layout/PageTabs.js +45 -0
- package/dist/features/products/components/GroupSettingsPanel.js +62 -16
- package/dist/features/search/action-index/tab-entries.d.ts +18 -0
- package/dist/features/search/action-index/tab-entries.js +128 -0
- package/dist/features/seller/components/SellerBundlesView.js +5 -1
- package/dist/features/seller/components/SellerGoogleReviewsView.js +31 -19
- package/dist/features/seller/components/SellerOrdersView.js +57 -19
- package/dist/features/seller/components/SellerProductsView.js +10 -1
- package/dist/features/seller/components/SellerReviewsView.d.ts +2 -1
- package/dist/features/seller/components/SellerReviewsView.js +54 -19
- package/dist/features/seller/constants/store-tabs.d.ts +64 -0
- package/dist/features/seller/constants/store-tabs.js +60 -0
- package/dist/features/seller/schemas/order-forms.d.ts +33 -0
- package/dist/features/seller/schemas/order-forms.js +75 -0
- package/dist/features/seller/schemas/review-forms.d.ts +37 -0
- package/dist/features/seller/schemas/review-forms.js +82 -0
- package/dist/features/shell/index.d.ts +1 -1
- package/dist/features/shell/index.js +5 -1
- package/dist/features/shipments/components/AdminShipmentLotItemsView.js +44 -15
- package/dist/features/shipments/schemas/validation.d.ts +41 -0
- package/dist/features/shipments/schemas/validation.js +49 -0
- package/dist/features/store-extensions/schemas/google-config-form.d.ts +36 -0
- package/dist/features/store-extensions/schemas/google-config-form.js +24 -0
- package/dist/features/stores/actions/store-query-actions.js +5 -3
- package/dist/features/stores/repository/store.repository.d.ts +3 -1
- package/dist/features/stores/repository/store.repository.js +6 -2
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +79 -0
- package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.js +53 -22
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/next/index.d.ts +2 -0
- package/dist/next/index.js +1 -0
- package/dist/react/hooks/useTabParam.d.ts +19 -0
- package/dist/react/hooks/useTabParam.js +65 -0
- package/dist/schemas/registry.d.ts +27 -3
- package/dist/styles.css +1 -1
- package/dist/tailwind-utilities.css +1 -1
- package/package.json +1 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ViewerLike } from "../tester/visibility";
|
|
1
2
|
import type { JsonValue } from "../../../../schemas/types";
|
|
2
3
|
import { type AvailabilityFilter } from "../../../../constants/field-names";
|
|
3
4
|
/** Vercel Hobby Fluid Compute ceiling (CLAUDE.md Rule #6) — never fetch more at once. */
|
|
@@ -156,6 +157,23 @@ export interface PublicProductListOptions {
|
|
|
156
157
|
* so tests can pin it.
|
|
157
158
|
*/
|
|
158
159
|
now?: Date;
|
|
160
|
+
/**
|
|
161
|
+
* Who is asking, for tester-sandbox visibility.
|
|
162
|
+
*
|
|
163
|
+
* 🛑 Omitting it means ANONYMOUS, and anonymous never sees `isTestData` rows.
|
|
164
|
+
* Deliberately fail-closed: an undefined viewer is the default a caller gets
|
|
165
|
+
* by forgetting, and forgetting must not publish the sandbox.
|
|
166
|
+
*
|
|
167
|
+
* On 2026-08-31 the public `/api/products` returned **25 tester fixtures in
|
|
168
|
+
* its first 40 rows** to an unauthenticated caller — `stickers-tester-sandbox-1`,
|
|
169
|
+
* `prizedraw-tester-sandbox-closed`, `live-tester-sandbox-1` — because this
|
|
170
|
+
* function had no viewer concept at all. CLAUDE.md had already recorded that
|
|
171
|
+
* `filterTestDataForViewer` was wired into the STORE read paths only, and that
|
|
172
|
+
* the other listing routes "need the identical one-line treatment when touched
|
|
173
|
+
* next". This is that treatment, applied at the one shared implementation
|
|
174
|
+
* rather than at each of its callers.
|
|
175
|
+
*/
|
|
176
|
+
viewer?: ViewerLike | null;
|
|
159
177
|
}
|
|
160
178
|
/** Every field path a per-type facet can filter on. Read by the wiring audit. */
|
|
161
179
|
export declare const TYPE_FACET_FIELD_PATHS: string[];
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
* @tag sideEffects:firestore-read
|
|
35
35
|
*/
|
|
36
36
|
import { productRepository } from "../../../../repositories";
|
|
37
|
+
import { filterTestDataForViewer } from "../tester/visibility";
|
|
37
38
|
import { normalizeError } from "../../../../errors/normalize";
|
|
38
39
|
import { serverLogger } from "../../../../monitoring/server-logger";
|
|
39
40
|
import { PRODUCT_FIELDS } from "../../../../constants/field-names";
|
|
@@ -464,6 +465,7 @@ export async function listPublicProducts(input, opts) {
|
|
|
464
465
|
filters,
|
|
465
466
|
sorts,
|
|
466
467
|
enabled: opts?.enabledListingTypes,
|
|
468
|
+
viewer: opts?.viewer,
|
|
467
469
|
});
|
|
468
470
|
}
|
|
469
471
|
// ── Path C: unavailable, everything else. One bounded query per distinct
|
|
@@ -528,6 +530,7 @@ export async function listPublicProducts(input, opts) {
|
|
|
528
530
|
filters: clauses.map((c) => sieveAnd(safeFilters, clauseToSieve(c, now))).join(" OR "),
|
|
529
531
|
sorts,
|
|
530
532
|
enabled: opts?.enabledListingTypes,
|
|
533
|
+
viewer: opts?.viewer,
|
|
531
534
|
});
|
|
532
535
|
}
|
|
533
536
|
// ── Path A ("all") and the dense "available" case share one query. The only
|
|
@@ -638,6 +641,7 @@ export async function listPublicProducts(input, opts) {
|
|
|
638
641
|
filters,
|
|
639
642
|
sorts,
|
|
640
643
|
enabled: opts?.enabledListingTypes,
|
|
644
|
+
viewer: opts?.viewer,
|
|
641
645
|
});
|
|
642
646
|
}
|
|
643
647
|
/**
|
|
@@ -661,17 +665,31 @@ async function runQuery(executor, query) {
|
|
|
661
665
|
return null;
|
|
662
666
|
}
|
|
663
667
|
}
|
|
664
|
-
/** Apply the feature-flag post-
|
|
668
|
+
/** Apply the tester-visibility and feature-flag post-filters, then assemble. */
|
|
665
669
|
function finish(draft) {
|
|
666
|
-
const { enabled, ...result } = draft;
|
|
667
|
-
if (!enabled || enabled.size === 0)
|
|
668
|
-
return result;
|
|
669
|
-
// Feature-flagged-off types never reach a public surface.
|
|
670
|
+
const { enabled, viewer, ...result } = draft;
|
|
670
671
|
const before = result.items.length;
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
672
|
+
/*
|
|
673
|
+
* Tester sandbox rows, unless the viewer may see them.
|
|
674
|
+
*
|
|
675
|
+
* 🛑 A post-filter, NOT a query clause. A Firestore inequality on the
|
|
676
|
+
* test-data flag excludes every document that LACKS the field — which is all
|
|
677
|
+
* real content, since the flag is new and optional — so such a query returns
|
|
678
|
+
* ONLY test data, exactly inverting the intent. That trap is written up in
|
|
679
|
+
* CLAUDE.md's tester section and is why `filterTestDataForViewer` exists as
|
|
680
|
+
* an in-memory filter in the first place.
|
|
681
|
+
*
|
|
682
|
+
* Cheap: the flag is sparse (~25 documents), so this drops a handful of rows
|
|
683
|
+
* from a page that was already fetched.
|
|
684
|
+
*/
|
|
685
|
+
let items = filterTestDataForViewer(result.items, viewer);
|
|
686
|
+
// Feature-flagged-off types never reach a public surface.
|
|
687
|
+
if (enabled && enabled.size > 0) {
|
|
688
|
+
items = items.filter((it) => {
|
|
689
|
+
const lt = typeof it.listingType === "string" ? it.listingType : "standard";
|
|
690
|
+
return enabled.has(lt);
|
|
691
|
+
});
|
|
692
|
+
}
|
|
675
693
|
const removed = before - items.length;
|
|
676
694
|
return {
|
|
677
695
|
...result,
|
package/dist/client.d.ts
CHANGED
|
@@ -112,6 +112,8 @@ export type { LotteryConfigWriteInput } from "./features/lottery/schemas/config-
|
|
|
112
112
|
export { PrizeDrawLotteryDetailView } from "./_internal/client/features/lottery/PrizeDrawLotteryDetailView";
|
|
113
113
|
export type { LotteryConfig, LotterySlot, ClientLotterySlot, ClientLotteryConfig, LotteryPricingMode, LotteryEntryStatus } from "./features/lottery/types";
|
|
114
114
|
export { BottomSheet } from "./features/layout/index";
|
|
115
|
+
export { PageTabs } from "./features/layout/PageTabs";
|
|
116
|
+
export type { PageTabDef, PageTabsProps } from "./features/layout/PageTabs";
|
|
115
117
|
export { ImageUpload } from "./features/media/index";
|
|
116
118
|
export type { ImageUploadProps } from "./features/media/index";
|
|
117
119
|
export { AvatarUpload } from "./features/media/index";
|
|
@@ -126,6 +128,8 @@ export { useMediaUpload } from "./features/media/index";
|
|
|
126
128
|
export { SessionProvider, useSession } from "./react/contexts/SessionContext";
|
|
127
129
|
export type { SessionProviderProps } from "./react/contexts/SessionContext";
|
|
128
130
|
export { useUrlTable } from "./react/hooks/useUrlTable";
|
|
131
|
+
export { useTabParam } from "./react/hooks/useTabParam";
|
|
132
|
+
export type { UseTabParamOptions } from "./react/hooks/useTabParam";
|
|
129
133
|
export type { UseUrlTableOptions } from "./react/hooks/useUrlTable";
|
|
130
134
|
export { useBulkSelection } from "./react/hooks/useBulkSelection";
|
|
131
135
|
export type { UseBulkSelectionOptions, UseBulkSelectionReturn } from "./react/hooks/useBulkSelection";
|
|
@@ -218,6 +222,7 @@ export { AdminDashboardView, AdminAnalyticsView, AdminPageViewsReportView, Admin
|
|
|
218
222
|
export type { AdminDashboardViewProps, AdminAnalyticsViewProps, AdminAnalyticsViewLabels, AdminPrizeDrawsViewProps, AdminCarouselViewProps, AdminFulfillmentViewProps, AdminListingScaffoldRow, ListingViewConfig } from "./features/admin/index";
|
|
219
223
|
export type { BulkActionItem } from "./ui/components/BulkActionBar";
|
|
220
224
|
export { PERMISSION_GROUPS, PERMISSION_DOMAINS, getPermissionsForDomain, formatPermLabel } from "./features/auth/permissions/constants";
|
|
225
|
+
export { Details, Summary } from "./ui/components/Details";
|
|
221
226
|
export { ADMIN_ENDPOINTS } from "./constants/index";
|
|
222
227
|
export { SELLER_ENDPOINTS } from "./constants/index";
|
|
223
228
|
export { TourProvider, useTour } from "./_internal/client/features/tour/TourProvider";
|
|
@@ -499,6 +504,10 @@ export { SITE_SETTINGS_ENTRIES, deriveSettingsEntries, } from "./features/search
|
|
|
499
504
|
export type { SettingsIndexSource } from "./features/search/action-index/settings-entries";
|
|
500
505
|
export { SITE_SETTINGS_TABS, SITE_SETTINGS_TAB_IDS, isSiteSettingsTabId, } from "./features/admin/constants/site-settings-tabs";
|
|
501
506
|
export type { SiteSettingsTab, SiteSettingsTabId } from "./features/admin/constants/site-settings-tabs";
|
|
507
|
+
export { PAYOUTS_TABS, isPayoutsTabId, SHIPPING_TABS, isShippingTabId, FULFILLMENT_TABS, isFulfillmentTabId, STOREFRONT_TABS, isStorefrontTabId, } from "./features/seller/constants/store-tabs";
|
|
508
|
+
export type { StoreDashboardTab, PayoutsTabId, ShippingTabId, FulfillmentTabId, StorefrontTabId, } from "./features/seller/constants/store-tabs";
|
|
509
|
+
export { ROLES_TABS, isRolesTabId, ADMIN_SETTINGS_TABS, isAdminSettingsTabId, } from "./features/admin/constants/admin-page-tabs";
|
|
510
|
+
export type { AdminPageTab, RolesTabId, AdminSettingsTabId, } from "./features/admin/constants/admin-page-tabs";
|
|
502
511
|
export { validateActionIndexEntry, normaliseRoute, } from "./features/search/action-index/validate-entry";
|
|
503
512
|
export type { ActionIndexValidationContext, ActionIndexValidationIssue, } from "./features/search/action-index/validate-entry";
|
|
504
513
|
export { AdminActionIndexView } from "./features/admin/components/AdminActionIndexView";
|
package/dist/client.js
CHANGED
|
@@ -168,6 +168,7 @@ export { PrizeDrawLotteryDetailView } from "./_internal/client/features/lottery/
|
|
|
168
168
|
// [CLIENT-ONLY]-Cannot run in SSR mode â€" uses browser-only APIs (window, navigator, localStorage, matchMedia, DOM events) that do not exist in Node.js.
|
|
169
169
|
// BottomSheet - Shared export for bottom sheet.
|
|
170
170
|
export { BottomSheet } from "./features/layout/index";
|
|
171
|
+
export { PageTabs } from "./features/layout/PageTabs";
|
|
171
172
|
export { ImageUpload } from "./features/media/index";
|
|
172
173
|
export { AvatarUpload } from "./features/media/index";
|
|
173
174
|
// [CLIENT-ONLY]-Cannot run in SSR mode â€" uses browser-only APIs (window, navigator, localStorage, matchMedia, DOM events) that do not exist in Node.js.
|
|
@@ -195,6 +196,7 @@ export { useMediaUpload } from "./features/media/index";
|
|
|
195
196
|
export { SessionProvider, useSession } from "./react/contexts/SessionContext";
|
|
196
197
|
// ThemeProvider — exported above alongside the registry-aware variant.
|
|
197
198
|
export { useUrlTable } from "./react/hooks/useUrlTable";
|
|
199
|
+
export { useTabParam } from "./react/hooks/useTabParam";
|
|
198
200
|
export { useBulkSelection } from "./react/hooks/useBulkSelection";
|
|
199
201
|
export { useEntityDelete } from "./react/hooks/useEntityDelete";
|
|
200
202
|
// Client-safe constants, UI primitives, and views
|
|
@@ -264,6 +266,14 @@ export { AdminSidebar } from "./features/admin/components/AdminSidebar";
|
|
|
264
266
|
export { AdminDashboardView, AdminAnalyticsView, AdminPageViewsReportView, AdminPrizeDrawsView, AdminCarouselView, AdminSublistingCategoriesView, AdminFulfillmentView, DataTable, DataListingView, useAdminListingData, toRecordArray, toStringValue, toRelativeDate } from "./features/admin/index";
|
|
265
267
|
// S-ADMIN-7 — permission catalog data, shared by AdminEmployeeEditorView and the read-only /admin/permissions page.
|
|
266
268
|
export { PERMISSION_GROUPS, PERMISSION_DOMAINS, getPermissionsForDomain, formatPermLabel } from "./features/auth/permissions/constants";
|
|
269
|
+
/*
|
|
270
|
+
* The permissions CATALOGUE renders these, and it became a client-graph module
|
|
271
|
+
* in W8 C2 when it moved from its own page into a tab on /admin/roles. Its
|
|
272
|
+
* previous comment — "Details/Summary aren't re-exported from client.ts" — was
|
|
273
|
+
* accurate and is now the reason the bare-entry import would be a Turbopack
|
|
274
|
+
* leak (Root Cause #6).
|
|
275
|
+
*/
|
|
276
|
+
export { Details, Summary } from "./ui/components/Details";
|
|
267
277
|
export { ADMIN_ENDPOINTS } from "./constants/index";
|
|
268
278
|
export { SELLER_ENDPOINTS } from "./constants/index";
|
|
269
279
|
// P-16 — Tour system (driver.js-backed onboarding walkthrough).
|
|
@@ -493,6 +503,8 @@ export { deriveNavEntries, deriveQuickActionEntries, buildActionIndexBase, } fro
|
|
|
493
503
|
export { matchesNavQuery } from "./_internal/shared/features/layout/matchesNavQuery";
|
|
494
504
|
export { SITE_SETTINGS_ENTRIES, deriveSettingsEntries, } from "./features/search/action-index/settings-entries";
|
|
495
505
|
export { SITE_SETTINGS_TABS, SITE_SETTINGS_TAB_IDS, isSiteSettingsTabId, } from "./features/admin/constants/site-settings-tabs";
|
|
506
|
+
export { PAYOUTS_TABS, isPayoutsTabId, SHIPPING_TABS, isShippingTabId, FULFILLMENT_TABS, isFulfillmentTabId, STOREFRONT_TABS, isStorefrontTabId, } from "./features/seller/constants/store-tabs";
|
|
507
|
+
export { ROLES_TABS, isRolesTabId, ADMIN_SETTINGS_TABS, isAdminSettingsTabId, } from "./features/admin/constants/admin-page-tabs";
|
|
496
508
|
export { validateActionIndexEntry, normaliseRoute, } from "./features/search/action-index/validate-entry";
|
|
497
509
|
export { AdminActionIndexView } from "./features/admin/components/AdminActionIndexView";
|
|
498
510
|
export { SellerSupportView } from "./features/seller/components/SellerSupportView";
|
|
@@ -31,7 +31,12 @@ export function UserBidsView() {
|
|
|
31
31
|
const config = {
|
|
32
32
|
portal: "user",
|
|
33
33
|
title: "My Bids",
|
|
34
|
-
|
|
34
|
+
search: {
|
|
35
|
+
placeholder: "Search by auction title…",
|
|
36
|
+
// `/api/user/bids` filters in memory: `(b.productTitle ?? "").includes(q)`.
|
|
37
|
+
// The bid document carries no other text.
|
|
38
|
+
fields: ["productTitle"],
|
|
39
|
+
},
|
|
35
40
|
emptyLabel: "You haven't placed any bids yet.",
|
|
36
41
|
filterKeys: ["status"],
|
|
37
42
|
defaultSort: sortBy("bidDate", "DESC"),
|
|
@@ -48,7 +48,14 @@ export function UserOrdersView({ onOrderClick }) {
|
|
|
48
48
|
const config = {
|
|
49
49
|
portal: "user",
|
|
50
50
|
title: "My Orders",
|
|
51
|
-
|
|
51
|
+
search: {
|
|
52
|
+
placeholder: "Search by order id…",
|
|
53
|
+
// `/api/user/orders` filters in memory on `o.id` and nothing else — not
|
|
54
|
+
// the product titles the rows display, which is what a buyer would try
|
|
55
|
+
// first. Saying "order id" is the honest promise until the route can
|
|
56
|
+
// match more.
|
|
57
|
+
fields: ["id"],
|
|
58
|
+
},
|
|
52
59
|
emptyLabel: "You haven't placed any orders yet.",
|
|
53
60
|
filterKeys: ["status", "orderType"],
|
|
54
61
|
defaultSort: sortBy("createdAt", "DESC"),
|
|
@@ -21,7 +21,12 @@ export function UserReturnsView({ onOrderClick }) {
|
|
|
21
21
|
const config = {
|
|
22
22
|
portal: "user",
|
|
23
23
|
title: "Returns & Refunds",
|
|
24
|
-
|
|
24
|
+
search: {
|
|
25
|
+
placeholder: "Search by order id…",
|
|
26
|
+
// Same route as /user/orders, scoped to returned statuses — so the same
|
|
27
|
+
// single in-memory match on `o.id`.
|
|
28
|
+
fields: ["id"],
|
|
29
|
+
},
|
|
25
30
|
emptyLabel: "You have no return or refund requests.",
|
|
26
31
|
filterKeys: [],
|
|
27
32
|
defaultSort: sortBy("updatedAt", "DESC"),
|
|
@@ -1,24 +1,41 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { normalizeError } from "../../../errors/normalize";
|
|
4
3
|
import { useApiMutation, sieveFilter, SIEVE_OP, sortBy } from "@mohasinac/appkit/client";
|
|
5
4
|
import React from "react";
|
|
6
5
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
7
|
-
import { Alert, Button,
|
|
6
|
+
import { Alert, Button, Div, Input, Label, PaginatedSelect, Row, Select, Stack, Text, TextLink } from "../../../ui";
|
|
8
7
|
import { apiClient } from "../../../http";
|
|
9
8
|
import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints";
|
|
9
|
+
import { adsConfigSchema } from "../schemas/admin-ops-forms";
|
|
10
|
+
import { SectionForm, useSectionFormNav, buildSectionsFromSchema, visibleValues, } from "../../shell";
|
|
11
|
+
import { useFormShellState, FormShellContext } from "../../../ui/forms";
|
|
12
|
+
import { FormErrorSummary } from "../../../ui/forms/FormErrorSummary";
|
|
10
13
|
import { DataListingView } from "./DataListingView";
|
|
11
14
|
const __P = {
|
|
12
15
|
p3: "p-[var(--appkit-space-3)]",
|
|
13
16
|
};
|
|
14
|
-
function AdsSettingsPanel({
|
|
15
|
-
|
|
17
|
+
function AdsSettingsPanel({ draft, onChange, serverCredentialIssues, credentialStatus, providerCredentialsMasked, settingsMutation, settingsMessage, onSave, }) {
|
|
18
|
+
const sections = React.useMemo(() => buildSectionsFromSchema(adsConfigSchema, {
|
|
19
|
+
renderers: {
|
|
20
|
+
adsenseClientId: ({ values, onChange: set, errors }) => (_jsx(Input, { label: "AdSense client id", value: values.adsenseClientId ?? "", onChange: (event) => set({ adsenseClientId: event.target.value }), placeholder: providerCredentialsMasked?.adsenseClientId || "ca-pub-XXXXXXXXXX", helperText: "Leave blank to keep the stored value.", error: errors.adsenseClientId })),
|
|
21
|
+
thirdPartyScriptUrl: ({ values, onChange: set, errors }) => (_jsx(Input, { label: "Third-party script URL", type: "url", value: values.thirdPartyScriptUrl ?? "", onChange: (event) => set({ thirdPartyScriptUrl: event.target.value }), placeholder: providerCredentialsMasked?.thirdPartyScriptUrl || "https://...", helperText: "Must be https. Leave blank to keep the stored value.", error: errors.thirdPartyScriptUrl })),
|
|
22
|
+
},
|
|
23
|
+
}), [providerCredentialsMasked]);
|
|
24
|
+
const nav = useSectionFormNav(sections, draft, { scope: "admin:ads-settings" });
|
|
25
|
+
const form = useFormShellState(adsConfigSchema, {
|
|
26
|
+
sections: nav.sectionMeta,
|
|
27
|
+
onGoToSection: nav.goToSection,
|
|
28
|
+
fieldToSectionIndex: nav.fieldToSectionIndex,
|
|
29
|
+
});
|
|
30
|
+
return (_jsxs(Stack, { border: "default", className: __P.p3, gap: "3", rounded: "lg", children: [_jsx(Text, { size: "sm", weight: "semibold", children: "Provider and publish settings" }), _jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "xs", children: "Save provider credentials here before publishing AdSense or third-party inventory." }), serverCredentialIssues.length > 0 ? (_jsx(Alert, { variant: "warning", title: "Provider credentials need attention", children: serverCredentialIssues.join("; ") })) : null, _jsxs(FormShellContext.Provider, { value: form.shellCtx, children: [_jsx(FormErrorSummary, {}), _jsx(SectionForm, { sections: sections, values: draft, onChange: (partial) => onChange({ ...draft, ...partial }), onSubmit: onSave, onValidationChange: () => form.validate(draft), schema: adsConfigSchema, openIds: nav.openIds, onOpenChange: nav.setOpenIds, submitLabel: "Save settings", isLoading: settingsMutation.isPending })] }), _jsxs(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "xs", children: ["Stored credentials: AdSense ", credentialStatus?.hasAdsenseClientId ? "configured" : "missing", " \u00B7 Third-party ", credentialStatus?.hasThirdPartyScriptUrl ? "configured" : "missing"] }), settingsMessage ? (_jsx(Alert, { variant: settingsMessage.toLowerCase().includes("failed") ? "error" : "success", title: "Settings", children: settingsMessage })) : null] }));
|
|
16
31
|
}
|
|
17
32
|
export function AdminAdsView({ endpoint = ADMIN_ENDPOINTS.ADS, labels = {}, createHref = "/admin/ads/new", renderEditLink, }) {
|
|
18
33
|
const queryClient = useQueryClient();
|
|
19
|
-
const [
|
|
20
|
-
|
|
21
|
-
|
|
34
|
+
const [draft, setDraft] = React.useState({
|
|
35
|
+
consentRequired: false,
|
|
36
|
+
adsenseClientId: "",
|
|
37
|
+
thirdPartyScriptUrl: "",
|
|
38
|
+
});
|
|
22
39
|
const [settingsMessage, setSettingsMessage] = React.useState(null);
|
|
23
40
|
// Lightweight metadata-only fetch (placements, consent, credential status) —
|
|
24
41
|
// decoupled from the paginated listing query DataListingView owns internally.
|
|
@@ -39,7 +56,9 @@ export function AdminAdsView({ endpoint = ADMIN_ENDPOINTS.ADS, labels = {}, crea
|
|
|
39
56
|
React.useEffect(() => {
|
|
40
57
|
if (!metaQuery.data)
|
|
41
58
|
return;
|
|
42
|
-
|
|
59
|
+
// Only the consent switch is seeded from the server: both credentials come
|
|
60
|
+
// back masked, so an input pre-filled with bullets would submit bullets.
|
|
61
|
+
setDraft((d) => ({ ...d, consentRequired: Boolean(metaQuery.data?.consentRequired) }));
|
|
43
62
|
}, [metaQuery.data]);
|
|
44
63
|
const settingsMutation = useApiMutation({
|
|
45
64
|
mutationFn: async (payload) => {
|
|
@@ -47,8 +66,7 @@ export function AdminAdsView({ endpoint = ADMIN_ENDPOINTS.ADS, labels = {}, crea
|
|
|
47
66
|
},
|
|
48
67
|
onSuccess: async () => {
|
|
49
68
|
setSettingsMessage("Ad settings saved.");
|
|
50
|
-
|
|
51
|
-
setThirdPartyScriptUrl("");
|
|
69
|
+
setDraft((d) => ({ ...d, adsenseClientId: "", thirdPartyScriptUrl: "" }));
|
|
52
70
|
await queryClient.invalidateQueries({ queryKey: ["admin-ads-meta"] });
|
|
53
71
|
},
|
|
54
72
|
onError: (error) => {
|
|
@@ -58,33 +76,17 @@ export function AdminAdsView({ endpoint = ADMIN_ENDPOINTS.ADS, labels = {}, crea
|
|
|
58
76
|
const placements = metaQuery.data?.placements ?? [];
|
|
59
77
|
const credentialStatus = metaQuery.data?.providerCredentialStatus;
|
|
60
78
|
const serverCredentialIssues = credentialStatus?.issues ?? [];
|
|
61
|
-
const localCredentialIssues = React.useMemo(() => {
|
|
62
|
-
const issues = [];
|
|
63
|
-
if (adsenseClientId.trim() && !/^ca-pub-[0-9]{10,20}$/.test(adsenseClientId.trim())) {
|
|
64
|
-
issues.push("AdSense client id must match format ca-pub-XXXXXXXXXX");
|
|
65
|
-
}
|
|
66
|
-
if (thirdPartyScriptUrl.trim()) {
|
|
67
|
-
try {
|
|
68
|
-
const parsed = new URL(thirdPartyScriptUrl.trim());
|
|
69
|
-
if (parsed.protocol !== "https:") {
|
|
70
|
-
issues.push("Third-party script URL must be https");
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
catch (_err) {
|
|
74
|
-
void normalizeError(_err);
|
|
75
|
-
issues.push("Third-party script URL must be a valid URL");
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
return issues;
|
|
79
|
-
}, [adsenseClientId, thirdPartyScriptUrl]);
|
|
80
|
-
const hasPendingCredentialInput = adsenseClientId.trim().length > 0 || thirdPartyScriptUrl.trim().length > 0;
|
|
81
79
|
const saveSettings = () => {
|
|
82
80
|
setSettingsMessage(null);
|
|
81
|
+
const v = visibleValues(adsConfigSchema, draft);
|
|
83
82
|
const payload = {
|
|
84
|
-
consentRequired,
|
|
83
|
+
consentRequired: v.consentRequired,
|
|
85
84
|
providerCredentials: {
|
|
86
|
-
|
|
87
|
-
|
|
85
|
+
// `undefined` on a blank field is what leaves the STORED credential
|
|
86
|
+
// alone - the route never sends the real value back, so an empty input
|
|
87
|
+
// means "unchanged", not "clear it".
|
|
88
|
+
adsenseClientId: v.adsenseClientId?.trim() || undefined,
|
|
89
|
+
thirdPartyScriptUrl: v.thirdPartyScriptUrl?.trim() || undefined,
|
|
88
90
|
},
|
|
89
91
|
};
|
|
90
92
|
settingsMutation.mutate(payload);
|
|
@@ -156,7 +158,7 @@ export function AdminAdsView({ endpoint = ADMIN_ENDPOINTS.ADS, labels = {}, crea
|
|
|
156
158
|
{ label: "AdSense", value: "adsense" },
|
|
157
159
|
{ label: "Third Party", value: "thirdParty" },
|
|
158
160
|
], onChange: (e) => setPendingFilters((p) => ({ ...p, provider: e.target.value === "all" ? "" : e.target.value })) }), _jsx(Label, { size: "sm", color: "muted", children: "Placement" }), _jsx(PaginatedSelect, { value: pendingFilters.placement || undefined, onChange: (v) => setPendingFilters((p) => ({ ...p, placement: v ?? "" })), options: placements.map((item) => ({ value: item.id, label: item.label })) })] })),
|
|
159
|
-
renderAboveContent: () => (_jsxs(Stack, { gap: "md", className: __P.p3, children: [metaQuery.error ? (_jsx(Alert, { variant: "error", title: "Could not load ads", children: metaQuery.error instanceof Error ? metaQuery.error.message : "Unknown error" })) : null, _jsx(AdsSettingsPanel, {
|
|
161
|
+
renderAboveContent: () => (_jsxs(Stack, { gap: "md", className: __P.p3, children: [metaQuery.error ? (_jsx(Alert, { variant: "error", title: "Could not load ads", children: metaQuery.error instanceof Error ? metaQuery.error.message : "Unknown error" })) : null, _jsx(AdsSettingsPanel, { draft: draft, onChange: setDraft, serverCredentialIssues: serverCredentialIssues, credentialStatus: credentialStatus, providerCredentialsMasked: metaQuery.data?.providerCredentialsMasked, settingsMutation: settingsMutation, settingsMessage: settingsMessage, onSave: saveSettings })] })),
|
|
160
162
|
};
|
|
161
163
|
return _jsx(DataListingView, { config: config });
|
|
162
164
|
}
|
|
@@ -50,7 +50,18 @@ export function AdminAuditLogView({ children, ...props }) {
|
|
|
50
50
|
const config = {
|
|
51
51
|
portal: "admin",
|
|
52
52
|
title: "Audit Log",
|
|
53
|
-
|
|
53
|
+
search: {
|
|
54
|
+
placeholder: "Search by actor uid (exact)",
|
|
55
|
+
/*
|
|
56
|
+
* `/api/admin/audit-log` builds `actorUid==${q}` — an EQUALITY, stated in
|
|
57
|
+
* that route's own comment because `@=` is prefix-only against Firestore
|
|
58
|
+
* anyway. A partial uid matches nothing, so the box commits on Enter and
|
|
59
|
+
* says "exact" rather than letting someone type half a uid and watch an
|
|
60
|
+
* empty list.
|
|
61
|
+
*/
|
|
62
|
+
mode: "exact",
|
|
63
|
+
fields: ["actorUid"],
|
|
64
|
+
},
|
|
54
65
|
emptyLabel: "No audit log entries found",
|
|
55
66
|
filterKeys: ["action", "actorUid"],
|
|
56
67
|
defaultSort: sortBy("createdAt", "DESC"),
|
|
@@ -62,7 +62,11 @@ export function AdminBundlesView({ editHrefTemplate, newHref }) {
|
|
|
62
62
|
const config = {
|
|
63
63
|
portal: "admin",
|
|
64
64
|
title: "Bundles",
|
|
65
|
-
|
|
65
|
+
search: {
|
|
66
|
+
placeholder: "Search bundles by name or slug…",
|
|
67
|
+
// `/api/admin/bundles` filters in memory on `b.name` and `b.slug`.
|
|
68
|
+
fields: ["name", "slug"],
|
|
69
|
+
},
|
|
66
70
|
emptyLabel: BUNDLE_COPY.adminList.empty,
|
|
67
71
|
filterKeys: ["isActive", "bundleStockStatus"],
|
|
68
72
|
defaultSort: sortBy("name", "ASC"),
|
|
@@ -4,12 +4,14 @@ import { useApiMutation } from "@mohasinac/appkit/client";
|
|
|
4
4
|
import React from "react";
|
|
5
5
|
import { useQuery } from "@tanstack/react-query";
|
|
6
6
|
import { z } from "zod";
|
|
7
|
-
import { Alert, Button, ConfirmDeleteModal, Div,
|
|
7
|
+
import { Alert, Button, ConfirmDeleteModal, Div, Heading, Input, Row, Select, Slider, Stack, StackedViewShell, Text, Toggle } from "../../../ui";
|
|
8
8
|
import { FieldInput, FormErrorSummary } from "../../../ui/forms";
|
|
9
9
|
import { apiClient } from "../../../http";
|
|
10
10
|
import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints";
|
|
11
11
|
import { ROUTES } from "../../../next/routing/route-map";
|
|
12
12
|
import { ACTIONS } from "../../../_internal/shared/actions/action-registry";
|
|
13
|
+
import { SectionForm, useSectionFormNav, } from "../../shell";
|
|
14
|
+
import { useFormShellState, FormShellContext } from "../../../ui/forms";
|
|
13
15
|
const __P = {
|
|
14
16
|
p3: "p-[var(--appkit-space-3)]",
|
|
15
17
|
};
|
|
@@ -21,7 +23,6 @@ const carouselSlideSchema = z.object({
|
|
|
21
23
|
order: z.number().int().min(0),
|
|
22
24
|
active: z.boolean(),
|
|
23
25
|
}).passthrough();
|
|
24
|
-
// ── helpers ─────────────────────────────────────────────────────────────────
|
|
25
26
|
function makeCard(zone = 1) {
|
|
26
27
|
return {
|
|
27
28
|
id: `card-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
@@ -207,16 +208,83 @@ export function AdminCarouselEditorView({ slideId, onSaved, onDeleted, onCancel,
|
|
|
207
208
|
onError: (err) => setErrorMsg(err?.message ?? "Failed to delete."),
|
|
208
209
|
});
|
|
209
210
|
const occupiedZones = cards.map((c) => c.zone);
|
|
211
|
+
/*
|
|
212
|
+
* Hand-written sections around the JSX that was already here, which is the
|
|
213
|
+
* `AdminCategoryEditorView` pattern. Deriving is not available: `background`
|
|
214
|
+
* is a discriminated union with its own editor, `cards[].buttons[]` is two
|
|
215
|
+
* levels of array, and neither has a `FieldKind`.
|
|
216
|
+
*
|
|
217
|
+
* `fields` is what makes the error summary's jump links and the per-section
|
|
218
|
+
* error badge work — it maps a Zod path back to the panel holding that
|
|
219
|
+
* control.
|
|
220
|
+
*/
|
|
221
|
+
const sections = React.useMemo(() => [
|
|
222
|
+
{
|
|
223
|
+
id: "info",
|
|
224
|
+
label: "Slide info",
|
|
225
|
+
required: true,
|
|
226
|
+
fields: ["title", "active", "order", "settings"],
|
|
227
|
+
render: () => (_jsxs(Stack, { gap: "md", children: [_jsx(FieldInput, { name: "title", label: "Title", value: title, onChange: (v) => setTitle(v), required: true, placeholder: "e.g. Hot Wheels RLC Exclusives" }), _jsx(Toggle, { label: "Active (visible on homepage)", checked: active, onChange: setActive }), _jsx(Input, { label: "Display order", type: "number", value: order, onChange: (e) => setOrder(e.target.value), min: 0, placeholder: "1" }), _jsx(Select, { label: "Slide height", value: height, onChange: (e) => setHeight(e.target.value), options: HEIGHT_OPTIONS }), _jsx(Input, { label: "Autoplay delay (ms)", type: "number", value: autoplayDelayMs, onChange: (e) => setAutoplayDelayMs(e.target.value), min: 1000, max: 15000, placeholder: "4000", helperText: "How long this slide shows before auto-advancing. Default: 4000 ms." })] })),
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
id: "background",
|
|
231
|
+
label: "Background",
|
|
232
|
+
fields: ["background"],
|
|
233
|
+
render: () => _jsx(BackgroundEditor, { value: background, onChange: setBackground }),
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
id: "overlay",
|
|
237
|
+
label: "Overlay text",
|
|
238
|
+
fields: ["overlay"],
|
|
239
|
+
render: () => (_jsxs(Stack, { gap: "md", children: [_jsx(Text, { size: "sm", color: "muted", children: "Centred text layered over the background. Leave blank to use cards only." }), _jsx(Input, { label: "Heading", value: overlayTitle, onChange: (e) => setOverlayTitle(e.target.value), placeholder: "India's #1 Collectibles Marketplace" }), _jsx(Input, { label: "Subtitle", value: overlaySubtitle, onChange: (e) => setOverlaySubtitle(e.target.value), placeholder: "Pokemon TCG - Hot Wheels - Beyblade X" }), _jsx(Input, { label: "Description", value: overlayDesc, onChange: (e) => setOverlayDesc(e.target.value), placeholder: "One sentence description..." }), _jsxs(Stack, { className: `${__P.p3}`, gap: "sm", rounded: "lg", border: "default", children: [_jsx(Text, { size: "sm", weight: "medium", children: "CTA button" }), _jsx(Input, { label: "Button text", value: overlayBtnText, onChange: (e) => setOverlayBtnText(e.target.value), placeholder: "Shop Now" }), _jsx(Input, { label: "Button link", value: overlayBtnLink, onChange: (e) => setOverlayBtnLink(e.target.value), placeholder: String(ROUTES.PUBLIC.PRODUCTS) }), _jsx(Select, { label: "Variant", value: overlayBtnVariant, onChange: (e) => setOverlayBtnVariant(e.target.value), options: VARIANT_OPTIONS.filter((v) => ["primary", "secondary", "outline"].includes(v.value)) }), _jsx(Toggle, { label: "Open in new tab", checked: overlayBtnNewTab, onChange: setOverlayBtnNewTab })] })] })),
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
id: "cards",
|
|
243
|
+
label: "Cards (0-2)",
|
|
244
|
+
fields: ["cards"],
|
|
245
|
+
render: () => (_jsxs(Stack, { gap: "md", children: [_jsxs(Row, { className: CLS_ROW_BETWEEN, children: [_jsx(Text, { size: "sm", color: "muted", children: "A slide holds at most two cards." }), cards.length < 2 && (_jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: handleAddCard, children: "+ Add card" }))] }), cards.length === 0 && (_jsx(Text, { size: "sm", color: "faint", children: "No cards - the overlay text covers the whole slide." })), cards.map((card, i) => (_jsx(CardEditor, { card: card, index: i, otherZones: occupiedZones.filter((z) => z !== card.zone), onChange: (updated) => setCards(cards.map((c, ci) => (ci === i ? updated : c))), onRemove: () => setCards(cards.filter((_, ci) => ci !== i)) }, card.id)))] })),
|
|
246
|
+
},
|
|
247
|
+
], [
|
|
248
|
+
title, active, order, height, autoplayDelayMs, background, cards, occupiedZones,
|
|
249
|
+
overlayTitle, overlaySubtitle, overlayDesc, overlayBtnText, overlayBtnLink,
|
|
250
|
+
overlayBtnVariant, overlayBtnNewTab, handleAddCard,
|
|
251
|
+
]);
|
|
252
|
+
/**
|
|
253
|
+
* The values the nav and the error summary key on. Not a draft the form
|
|
254
|
+
* writes through - see the `onChange` note at the SectionForm below.
|
|
255
|
+
*/
|
|
256
|
+
const draftForNav = React.useMemo(() => ({ title, active, order, cards }), [title, active, order, cards]);
|
|
257
|
+
const nav = useSectionFormNav(sections, draftForNav, { scope: "admin:carousel-editor" });
|
|
258
|
+
const form = useFormShellState(carouselSlideSchema, {
|
|
259
|
+
sections: nav.sectionMeta,
|
|
260
|
+
onGoToSection: nav.goToSection,
|
|
261
|
+
fieldToSectionIndex: nav.fieldToSectionIndex,
|
|
262
|
+
});
|
|
210
263
|
return (_jsx(StackedViewShell, { portal: "admin", ...rest, title: isEdit ? "Edit Carousel Slide" : "New Carousel Slide", sections: [
|
|
211
264
|
errorMsg ? _jsx(Alert, { variant: "error", children: errorMsg }, "err") : null,
|
|
212
265
|
successMsg ? _jsx(Alert, { variant: "success", children: successMsg }, "ok") : null,
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
266
|
+
_jsxs(FormShellContext.Provider, { value: form.shellCtx, children: [_jsx(FormErrorSummary, {}), _jsx(SectionForm, { sections: sections, values: draftForNav,
|
|
267
|
+
/*
|
|
268
|
+
* The sections render from this component's own state, so
|
|
269
|
+
* `onChange` has nothing to route: every control already writes
|
|
270
|
+
* through its own setter, and the values object exists to give the
|
|
271
|
+
* error summary and the per-section badge a field map. Rebuilding
|
|
272
|
+
* eighteen scalars into one draft object would be a second source
|
|
273
|
+
* of truth for the same values.
|
|
274
|
+
*/
|
|
275
|
+
onChange: () => undefined, onSubmit: () => {
|
|
276
|
+
form.clearErrors();
|
|
277
|
+
if (!title.trim()) {
|
|
278
|
+
form.setFieldError("title", "Title is required");
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
saveMutation.mutate();
|
|
282
|
+
}, schema: carouselSlideSchema, openIds: nav.openIds, onOpenChange: nav.setOpenIds, submitLabel: isEdit ? "Save changes" : "Create slide", cancelLabel: "Cancel", onCancel: onCancel, isLoading: saveMutation.isPending, destructiveAction: isEdit
|
|
283
|
+
? {
|
|
284
|
+
label: ACTIONS.ADMIN["delete-carousel"].label,
|
|
285
|
+
onClick: () => setDeleteConfirmOpen(true),
|
|
286
|
+
disabled: deleteMutation.isPending,
|
|
287
|
+
}
|
|
288
|
+
: undefined }), isEdit && (_jsx(ConfirmDeleteModal, { isOpen: deleteConfirmOpen, onClose: () => setDeleteConfirmOpen(false), onConfirm: () => { setDeleteConfirmOpen(false); deleteMutation.mutate(); }, title: ACTIONS.ADMIN["delete-carousel"].confirmation.title, message: ACTIONS.ADMIN["delete-carousel"].confirmation.body, isDeleting: deleteMutation.isPending }))] }, "slide-form"),
|
|
221
289
|
] }));
|
|
222
290
|
}
|
|
@@ -33,7 +33,16 @@ export function AdminFaqsView({ children, onBulkArchive, onBulkDelete, ...props
|
|
|
33
33
|
const config = useMemo(() => ({
|
|
34
34
|
portal: "admin",
|
|
35
35
|
title: "FAQs",
|
|
36
|
-
|
|
36
|
+
search: {
|
|
37
|
+
placeholder: "Search questions, answers, categories or tags",
|
|
38
|
+
/*
|
|
39
|
+
* `buildFaqSearchTxt` indexes the question, the category, every tag AND
|
|
40
|
+
* the answer body, all prefix-expanded. The old placeholder said
|
|
41
|
+
* "tokens" — the name of the implementation — and omitted the answer,
|
|
42
|
+
* which is the largest thing it searches.
|
|
43
|
+
*/
|
|
44
|
+
fields: ["question", "answer", "category", "tags"],
|
|
45
|
+
},
|
|
37
46
|
emptyLabel: "No FAQs found",
|
|
38
47
|
filterKeys: ["isActive"],
|
|
39
48
|
defaultSort: sortBy("priority", "ASC"),
|
|
@@ -81,7 +81,13 @@ export function AdminNotificationsView({ children, ...props }) {
|
|
|
81
81
|
// Title search is not expressible here — `title` is not in the repository's
|
|
82
82
|
// SIEVE_FIELDS and Sieve→Firestore cannot OR across two fields. The route
|
|
83
83
|
// treats `q` as an exact userId lookup, so the placeholder says exactly that.
|
|
84
|
-
|
|
84
|
+
search: {
|
|
85
|
+
placeholder: "Search by user ID (exact)",
|
|
86
|
+
// `/api/admin/notifications` builds `userId==${q}`. An equality, so a
|
|
87
|
+
// partial id matches nothing and the box commits on Enter.
|
|
88
|
+
mode: "exact",
|
|
89
|
+
fields: ["userId"],
|
|
90
|
+
},
|
|
85
91
|
emptyLabel: "No notifications found",
|
|
86
92
|
filterKeys: ["type", "readState"],
|
|
87
93
|
defaultSort: sortBy("createdAt", "DESC"),
|