@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.
Files changed (71) hide show
  1. package/dist/_internal/server/features/products/list-public.d.ts +18 -0
  2. package/dist/_internal/server/features/products/list-public.js +27 -9
  3. package/dist/client.d.ts +9 -0
  4. package/dist/client.js +12 -0
  5. package/dist/features/account/components/UserBidsView.js +6 -1
  6. package/dist/features/account/components/UserOrdersView.js +8 -1
  7. package/dist/features/account/components/UserReturnsView.js +6 -1
  8. package/dist/features/admin/components/AdminAdsView.js +36 -34
  9. package/dist/features/admin/components/AdminAuditLogView.js +12 -1
  10. package/dist/features/admin/components/AdminBundlesView.js +5 -1
  11. package/dist/features/admin/components/AdminCarouselEditorView.js +78 -10
  12. package/dist/features/admin/components/AdminFaqsView.js +10 -1
  13. package/dist/features/admin/components/AdminNotificationsView.js +7 -1
  14. package/dist/features/admin/components/AdminOrderEditorView.js +55 -24
  15. package/dist/features/admin/components/AdminPrizeDrawsView.js +11 -1
  16. package/dist/features/admin/components/AdminProductEditorView.js +78 -18
  17. package/dist/features/admin/components/AdminSectionsView.js +89 -32
  18. package/dist/features/admin/components/AdminSiteSettingsView.js +96 -44
  19. package/dist/features/admin/components/AdminStoresView.js +13 -1
  20. package/dist/features/admin/components/AdminSupportTicketDetailView.js +58 -43
  21. package/dist/features/admin/components/AdminSupportTicketsView.js +11 -1
  22. package/dist/features/admin/components/AdminTeamView.js +11 -1
  23. package/dist/features/admin/components/AdminTesterChecklistView.js +6 -1
  24. package/dist/features/admin/components/AdminUserEditorView.js +65 -82
  25. package/dist/features/admin/components/analytics/AdminPageViewsReportView.js +6 -1
  26. package/dist/features/admin/constants/admin-page-tabs.d.ts +36 -0
  27. package/dist/features/admin/constants/admin-page-tabs.js +45 -0
  28. package/dist/features/admin/schemas/admin-ops-forms.d.ts +81 -0
  29. package/dist/features/admin/schemas/admin-ops-forms.js +123 -1
  30. package/dist/features/admin/schemas/admin-user-form.d.ts +78 -3
  31. package/dist/features/admin/schemas/admin-user-form.js +96 -7
  32. package/dist/features/admin/schemas/homepage-section-form.d.ts +9 -0
  33. package/dist/features/layout/PageTabs.d.ts +17 -0
  34. package/dist/features/layout/PageTabs.js +45 -0
  35. package/dist/features/products/components/GroupSettingsPanel.js +62 -16
  36. package/dist/features/search/action-index/tab-entries.d.ts +18 -0
  37. package/dist/features/search/action-index/tab-entries.js +128 -0
  38. package/dist/features/seller/components/SellerBundlesView.js +5 -1
  39. package/dist/features/seller/components/SellerGoogleReviewsView.js +31 -19
  40. package/dist/features/seller/components/SellerOrdersView.js +57 -19
  41. package/dist/features/seller/components/SellerProductsView.js +10 -1
  42. package/dist/features/seller/components/SellerReviewsView.d.ts +2 -1
  43. package/dist/features/seller/components/SellerReviewsView.js +54 -19
  44. package/dist/features/seller/constants/store-tabs.d.ts +64 -0
  45. package/dist/features/seller/constants/store-tabs.js +60 -0
  46. package/dist/features/seller/schemas/order-forms.d.ts +33 -0
  47. package/dist/features/seller/schemas/order-forms.js +75 -0
  48. package/dist/features/seller/schemas/review-forms.d.ts +37 -0
  49. package/dist/features/seller/schemas/review-forms.js +82 -0
  50. package/dist/features/shell/index.d.ts +1 -1
  51. package/dist/features/shell/index.js +5 -1
  52. package/dist/features/shipments/components/AdminShipmentLotItemsView.js +44 -15
  53. package/dist/features/shipments/schemas/validation.d.ts +41 -0
  54. package/dist/features/shipments/schemas/validation.js +49 -0
  55. package/dist/features/store-extensions/schemas/google-config-form.d.ts +36 -0
  56. package/dist/features/store-extensions/schemas/google-config-form.js +24 -0
  57. package/dist/features/stores/actions/store-query-actions.js +5 -3
  58. package/dist/features/stores/repository/store.repository.d.ts +3 -1
  59. package/dist/features/stores/repository/store.repository.js +6 -2
  60. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +79 -0
  61. package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.js +53 -22
  62. package/dist/index.d.ts +4 -0
  63. package/dist/index.js +2 -0
  64. package/dist/next/index.d.ts +2 -0
  65. package/dist/next/index.js +1 -0
  66. package/dist/react/hooks/useTabParam.d.ts +19 -0
  67. package/dist/react/hooks/useTabParam.js +65 -0
  68. package/dist/schemas/registry.d.ts +27 -3
  69. package/dist/styles.css +1 -1
  70. package/dist/tailwind-utilities.css +1 -1
  71. 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-filter and assemble the result. */
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
- const items = result.items.filter((it) => {
672
- const lt = typeof it.listingType === "string" ? it.listingType : "standard";
673
- return enabled.has(lt);
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
- searchPlaceholder: "Search by auction title…",
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
- searchPlaceholder: "Search by order id…",
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
- searchPlaceholder: "Search by order id…",
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, Checkbox, Div, Input, Label, PaginatedSelect, Row, Select, Stack, Text, TextLink } from "../../../ui";
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({ adsenseClientId, setAdsenseClientId, thirdPartyScriptUrl, setThirdPartyScriptUrl, consentRequired, setConsentRequired, serverCredentialIssues, localCredentialIssues, credentialStatus, providerCredentialsMasked, settingsMutation, hasPendingCredentialInput, currentConsentRequired, settingsMessage, onSave, }) {
15
- 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(Div, { layout: "grid", gap: "3", className: "grid-cols-1 md:grid-cols-2", children: [_jsx(Input, { label: "AdSense client id", value: adsenseClientId, onChange: (event) => setAdsenseClientId(event.target.value), placeholder: providerCredentialsMasked?.adsenseClientId || "ca-pub-XXXXXXXXXX" }), _jsx(Input, { label: "Third-party script URL", value: thirdPartyScriptUrl, onChange: (event) => setThirdPartyScriptUrl(event.target.value), placeholder: providerCredentialsMasked?.thirdPartyScriptUrl || "https://..." })] }), _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"] }), _jsxs(Row, { align: "center", justify: "between", gap: "3", children: [_jsxs(Label, { layout: "flex", gap: "md", size: "sm", children: [_jsx(Checkbox, { bare: true, checked: consentRequired, onChange: (event) => setConsentRequired(event.target.checked) }), "Require consent globally for ad rendering"] }), _jsx(Button, { size: "sm", variant: "outline", disabled: settingsMutation.isPending || localCredentialIssues.length > 0 || (!hasPendingCredentialInput && consentRequired === Boolean(currentConsentRequired)), onClick: onSave, children: settingsMutation.isPending ? "Saving..." : "Save settings" })] }), localCredentialIssues.length > 0 ? (_jsx(Alert, { variant: "error", title: "Fix settings before saving", children: localCredentialIssues.join("; ") })) : null, settingsMessage ? (_jsx(Alert, { variant: settingsMessage.toLowerCase().includes("failed") ? "error" : "success", title: "Settings", children: settingsMessage })) : null] }));
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 [consentRequired, setConsentRequired] = React.useState(false);
20
- const [adsenseClientId, setAdsenseClientId] = React.useState("");
21
- const [thirdPartyScriptUrl, setThirdPartyScriptUrl] = React.useState("");
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
- setConsentRequired(Boolean(metaQuery.data.consentRequired));
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
- setAdsenseClientId("");
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
- adsenseClientId: adsenseClientId.trim() || undefined,
87
- thirdPartyScriptUrl: thirdPartyScriptUrl.trim() || undefined,
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, { adsenseClientId: adsenseClientId, setAdsenseClientId: setAdsenseClientId, thirdPartyScriptUrl: thirdPartyScriptUrl, setThirdPartyScriptUrl: setThirdPartyScriptUrl, consentRequired: consentRequired, setConsentRequired: setConsentRequired, serverCredentialIssues: serverCredentialIssues, localCredentialIssues: localCredentialIssues, credentialStatus: credentialStatus, providerCredentialsMasked: metaQuery.data?.providerCredentialsMasked, settingsMutation: settingsMutation, hasPendingCredentialInput: hasPendingCredentialInput, currentConsentRequired: Boolean(metaQuery.data?.consentRequired), settingsMessage: settingsMessage, onSave: saveSettings })] })),
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
- searchPlaceholder: "Search by actor uid",
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
- searchPlaceholder: "Search bundles by name or slug…",
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, Form, FormActions, Heading, Input, Row, Select, Slider, Stack, StackedViewShell, Text, Toggle } from "../../../ui";
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
- _jsx(Form, { schema: carouselSlideSchema, onSubmit: (e) => e.preventDefault(), spacing: "lg", children: ({ setFieldError, clearErrors }) => (_jsxs(_Fragment, { children: [_jsxs(Div, { className: CLS_PANEL, children: [_jsx(Heading, { level: 3, className: CLS_SECTION_HEADING, children: "Slide info" }), _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." })] }), _jsxs(Div, { className: CLS_PANEL, children: [_jsx(Heading, { level: 3, className: CLS_SECTION_HEADING, children: "Background" }), _jsx(BackgroundEditor, { value: background, onChange: setBackground })] }), _jsxs(Div, { className: CLS_PANEL, children: [_jsx(Heading, { level: 3, className: CLS_SECTION_HEADING, children: "Overlay text (optional)" }), _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: "Pok\u00E9mon TCG \u00B7 Hot Wheels \u00B7 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 })] })] }), _jsxs(Div, { className: CLS_PANEL, children: [_jsxs(Row, { className: CLS_ROW_BETWEEN, children: [_jsx(Heading, { level: 3, className: CLS_SECTION_HEADING, children: "Cards (0\u20132)" }), 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 \u2014 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)))] }), _jsx(FormErrorSummary, {}), _jsxs(FormActions, { children: [_jsx(Button, { type: "submit", isLoading: saveMutation.isPending, disabled: !title || saveMutation.isPending, onClick: () => {
214
- clearErrors();
215
- if (!title.trim()) {
216
- setFieldError("title", "Title is required");
217
- return;
218
- }
219
- saveMutation.mutate();
220
- }, children: isEdit ? "Save changes" : "Create slide" }), onCancel && (_jsx(Button, { type: "button", variant: "outline", onClick: onCancel, children: "Cancel" })), isEdit && (_jsxs(_Fragment, { children: [_jsx(Button, { type: "button", variant: "danger", isLoading: deleteMutation.isPending, onClick: () => setDeleteConfirmOpen(true), children: ACTIONS.ADMIN["delete-carousel"].label }), _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"),
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
- searchPlaceholder: "Search questions, categories, or tokens",
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
- searchPlaceholder: "Search by user ID",
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"),