@mohasinac/appkit 4.31.0 โ†’ 4.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) 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/admin/components/AdminAdsView.js +36 -34
  6. package/dist/features/admin/components/AdminCarouselEditorView.js +78 -10
  7. package/dist/features/admin/components/AdminOrderEditorView.js +55 -24
  8. package/dist/features/admin/components/AdminProductEditorView.js +78 -18
  9. package/dist/features/admin/components/AdminSectionsView.js +89 -32
  10. package/dist/features/admin/components/AdminSiteSettingsView.js +96 -44
  11. package/dist/features/admin/components/AdminSupportTicketDetailView.js +58 -43
  12. package/dist/features/admin/components/AdminUserEditorView.js +65 -82
  13. package/dist/features/admin/constants/admin-page-tabs.d.ts +36 -0
  14. package/dist/features/admin/constants/admin-page-tabs.js +45 -0
  15. package/dist/features/admin/schemas/admin-ops-forms.d.ts +81 -0
  16. package/dist/features/admin/schemas/admin-ops-forms.js +123 -1
  17. package/dist/features/admin/schemas/admin-user-form.d.ts +78 -3
  18. package/dist/features/admin/schemas/admin-user-form.js +96 -7
  19. package/dist/features/admin/schemas/homepage-section-form.d.ts +9 -0
  20. package/dist/features/layout/PageTabs.d.ts +17 -0
  21. package/dist/features/layout/PageTabs.js +45 -0
  22. package/dist/features/products/components/GroupSettingsPanel.js +62 -16
  23. package/dist/features/search/action-index/tab-entries.d.ts +18 -0
  24. package/dist/features/search/action-index/tab-entries.js +128 -0
  25. package/dist/features/seller/components/SellerGoogleReviewsView.js +31 -19
  26. package/dist/features/seller/components/SellerOrdersView.js +57 -19
  27. package/dist/features/seller/components/SellerReviewsView.d.ts +2 -1
  28. package/dist/features/seller/components/SellerReviewsView.js +54 -19
  29. package/dist/features/seller/constants/store-tabs.d.ts +64 -0
  30. package/dist/features/seller/constants/store-tabs.js +60 -0
  31. package/dist/features/seller/schemas/order-forms.d.ts +33 -0
  32. package/dist/features/seller/schemas/order-forms.js +75 -0
  33. package/dist/features/seller/schemas/review-forms.d.ts +37 -0
  34. package/dist/features/seller/schemas/review-forms.js +82 -0
  35. package/dist/features/shell/index.d.ts +1 -1
  36. package/dist/features/shell/index.js +5 -1
  37. package/dist/features/shipments/components/AdminShipmentLotItemsView.js +44 -15
  38. package/dist/features/shipments/schemas/validation.d.ts +41 -0
  39. package/dist/features/shipments/schemas/validation.js +49 -0
  40. package/dist/features/store-extensions/schemas/google-config-form.d.ts +36 -0
  41. package/dist/features/store-extensions/schemas/google-config-form.js +24 -0
  42. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +67 -0
  43. package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.js +53 -22
  44. package/dist/index.d.ts +4 -0
  45. package/dist/index.js +2 -0
  46. package/dist/next/index.d.ts +8 -0
  47. package/dist/next/index.js +37 -0
  48. package/dist/react/hooks/useTabParam.d.ts +19 -0
  49. package/dist/react/hooks/useTabParam.js +65 -0
  50. package/dist/schemas/registry.d.ts +27 -3
  51. package/dist/styles.css +1 -1
  52. package/dist/tailwind-utilities.css +1 -1
  53. 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";
@@ -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
  }
@@ -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
  }
@@ -6,13 +6,16 @@ import { useApiMutation } from "@mohasinac/appkit/client";
6
6
  import React, { useState } from "react";
7
7
  import { normalizeError } from "../../../errors/normalize";
8
8
  import { useQueryClient } from "@tanstack/react-query";
9
- import { Button, Div, Form, FormActions, Input, Label, Select, SideDrawer, Stack, Text, Textarea, useToast } from "../../../ui";
9
+ import { Button, Div, Label, SideDrawer, Stack, Text, Textarea, useToast } from "../../../ui";
10
10
  import { MediaImage } from "../../media";
11
11
  import { apiClient } from "../../../http";
12
12
  import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints";
13
13
  import { ACTIONS } from "../../../_internal/shared/actions/action-registry";
14
14
  import { toCurrency } from "../hooks/useAdminListingData";
15
- import { adminOrderUpdateSchema } from "../schemas/admin-ops-forms";
15
+ import { adminOrderUpdateSchema, } from "../schemas/admin-ops-forms";
16
+ import { SectionForm, useSectionFormNav, buildSectionsFromSchema, visibleValues, } from "../../shell";
17
+ import { useFormShellState, FormShellContext } from "../../../ui/forms";
18
+ import { applyZodIssues } from "../../../ui/forms/apply-zod-issues";
16
19
  import { FormErrorSummary } from "../../../ui/forms/FormErrorSummary";
17
20
  import { ValidationError } from "../../../errors/validation-error";
18
21
  const STATUS_OPTIONS = [
@@ -37,11 +40,28 @@ const CARRIER_OPTIONS = [
37
40
  export function AdminOrderEditorView({ open, onClose, orderId, orderLabel, currentStatus, items, paymentProofUrl, paymentTransactionId, paymentMethod, paymentStatus, displayedUpiId, buyerReportedUpiId, paymentUpiMismatch, buyerMarkedPaid, buyerFraudAgreementAccepted, paymentReviewOutcome, addons, }) {
38
41
  const queryClient = useQueryClient();
39
42
  const { showToast } = useToast();
40
- const [status, setStatus] = React.useState(currentStatus ?? "pending");
41
- const [trackingNumber, setTrackingNumber] = React.useState("");
42
- const [carrier, setCarrier] = React.useState("");
43
- const [notes, setNotes] = React.useState("");
44
- const [refundAmount, setRefundAmount] = React.useState("");
43
+ const [draft, setDraft] = React.useState({
44
+ status: (currentStatus ?? "pending"),
45
+ trackingNumber: "",
46
+ carrier: "",
47
+ notes: "",
48
+ });
49
+ /*
50
+ * `refundAmount` carries a `when` in the schema โ€” it is only meaningful on a
51
+ * refunding status โ€” so the control appears and disappears with the status
52
+ * select, and `visibleValues()` drops the value when it disappears. The page
53
+ * used to gate the input with the same condition written a second time in
54
+ * JSX; now the schema is the single place that rule lives.
55
+ */
56
+ const sections = React.useMemo(() => buildSectionsFromSchema(adminOrderUpdateSchema, {
57
+ options: { status: STATUS_OPTIONS, carrier: CARRIER_OPTIONS },
58
+ }), []);
59
+ const nav = useSectionFormNav(sections, draft, { scope: "admin:order-editor" });
60
+ const form = useFormShellState(adminOrderUpdateSchema, {
61
+ sections: nav.sectionMeta,
62
+ onGoToSection: nav.goToSection,
63
+ fieldToSectionIndex: nav.fieldToSectionIndex,
64
+ });
45
65
  const [isVerifyingPayment, setIsVerifyingPayment] = useState(false);
46
66
  /*
47
67
  * ๐Ÿ›‘ `reviewNote` belongs to the PAYMENT REVIEW actions, not to "Save
@@ -69,26 +89,30 @@ export function AdminOrderEditorView({ open, onClose, orderId, orderLabel, curre
69
89
  const needsVerification = isManualPayment && !isVerified && !isRejected && Boolean(paymentProofUrl);
70
90
  React.useEffect(() => {
71
91
  if (open) {
72
- setStatus(currentStatus ?? "pending");
73
- setTrackingNumber("");
74
- setCarrier("");
75
- setNotes("");
76
- setRefundAmount("");
92
+ setDraft({
93
+ status: (currentStatus ?? "pending"),
94
+ trackingNumber: "",
95
+ carrier: "",
96
+ notes: "",
97
+ });
77
98
  setReviewNote("");
78
99
  }
79
100
  }, [open, currentStatus]);
80
101
  const saveMutation = useApiMutation({
81
102
  errorMessage: "Failed to update order.",
82
103
  mutationFn: async () => {
104
+ // Built from `visibleValues`, so a refund amount typed and then hidden by
105
+ // a status change cannot travel with the request.
106
+ const v = visibleValues(adminOrderUpdateSchema, draft);
83
107
  const payload = {
84
- status,
85
- notes: notes || undefined,
108
+ status: v.status,
109
+ notes: v.notes || undefined,
86
110
  };
87
- if (trackingNumber)
88
- payload.trackingNumber = trackingNumber;
89
- if (carrier)
90
- payload.carrier = carrier;
91
- if (refundAmount.trim()) {
111
+ if (v.trackingNumber)
112
+ payload.trackingNumber = v.trackingNumber;
113
+ if (v.carrier)
114
+ payload.carrier = v.carrier;
115
+ if (v.refundAmount !== undefined && String(v.refundAmount).trim() !== "") {
92
116
  /*
93
117
  * Parsed through the schema, which coerces, requires > 0 and caps the
94
118
  * magnitude. It used to be `parseFloat` guarded by `!isNaN && > 0` with
@@ -96,7 +120,7 @@ export function AdminOrderEditorView({ open, onClose, orderId, orderLabel, curre
96
120
  * save reported success with nothing recorded. A refund that does not
97
121
  * parse must stop the save, not disappear from it.
98
122
  */
99
- const parsed = adminOrderUpdateSchema.shape.refundAmount.safeParse(refundAmount);
123
+ const parsed = adminOrderUpdateSchema.shape.refundAmount.safeParse(v.refundAmount);
100
124
  if (!parsed.success) {
101
125
  throw new ValidationError(parsed.error.issues[0]?.message ?? "Enter the refund amount as a number.");
102
126
  }
@@ -183,8 +207,15 @@ export function AdminOrderEditorView({ open, onClose, orderId, orderLabel, curre
183
207
  setIsRejectingFraud(false);
184
208
  }
185
209
  };
186
- return (_jsx(SideDrawer, { isOpen: open, onClose: onClose, title: orderLabel ? `Order: ${orderLabel}` : "Update Order", children: _jsxs(Form, { schema: adminOrderUpdateSchema, onSubmit: (e) => {
187
- e.preventDefault();
188
- saveMutation.mutate();
189
- }, spacing: "md", padding: "md", children: [_jsx(FormErrorSummary, {}), addons && _jsx(OrderAddonBadges, { order: addons, variant: "detail" }), items && items.length > 0 && (_jsxs(Stack, { gap: "xs", children: [_jsxs(Label, { size: "sm", weight: "medium", color: "primary", children: ["Items (", items.length, ")"] }), _jsx(Div, { className: "divide-y divide-[var(--appkit-color-border)] border border-[var(--appkit-color-border)]", rounded: "lg", children: items.map((item, i) => (_jsxs(Div, { layout: "flex", align: "center", gap: "3", padding: "sm", children: [_jsx(Div, { className: "h-10 w-10 shrink-0", rounded: "md", overflow: "hidden", children: _jsx(MediaImage, { src: item.image, alt: item.title, size: "thumbnail" }) }), _jsxs(Div, { className: "min-w-0 flex-1", children: [_jsx(Text, { size: "sm", className: "truncate", weight: "medium", children: item.title }), _jsxs(Text, { size: "xs", color: "muted", children: ["Qty: ", item.quantity, " \u00D7 ", toCurrency(item.unitPrice)] })] }), _jsx(Text, { size: "sm", className: "shrink-0", weight: "semibold", children: toCurrency(item.totalPrice) })] }, item.productId || i))) })] })), _jsx(Select, { label: "Order status", options: STATUS_OPTIONS, value: status, onValueChange: setStatus }), _jsx(Input, { label: "Tracking number (optional)", value: trackingNumber, onChange: (e) => setTrackingNumber(e.target.value), placeholder: "e.g. DEL1234567890IN" }), _jsx(Select, { label: "Carrier (optional)", options: CARRIER_OPTIONS, value: carrier, onValueChange: setCarrier }), _jsxs(Stack, { gap: "xs", children: [_jsx(Label, { size: "sm", weight: "medium", color: "primary", children: "Internal note (optional)" }), _jsx(Textarea, { value: notes, onChange: (e) => setNotes(e.target.value), rows: 3, placeholder: "Reason for status change, escalation notes\u2026" })] }), (status === "refunded" || status === "return_requested") && (_jsx(Input, { label: "Refund amount \u20B9 (optional)", type: "number", min: "0", step: "0.01", value: refundAmount, onChange: (e) => setRefundAmount(e.target.value), placeholder: "e.g. 499.00" })), isManualPayment && (_jsxs(Stack, { gap: "xs", children: [_jsx(Label, { size: "sm", weight: "medium", color: "primary", children: "Payment Proof" }), isRejected && (_jsx(Text, { size: "xs", className: "text-error", weight: "medium", children: "Payment rejected as fraud \u2014 no further action available here." })), isReuploadRequested && !isVerified && (_jsx(Text, { size: "xs", className: "text-warning", weight: "medium", children: "Re-upload requested \u2014 waiting for the buyer to submit a new proof." })), paymentProofUrl ? (_jsxs(Stack, { gap: "xs", children: [_jsx(Div, { border: "default", rounded: "lg", overflow: "hidden", children: _jsx(MediaImage, { src: paymentProofUrl, alt: "Payment screenshot", size: "card" }) }), paymentTransactionId && (_jsxs(Text, { size: "xs", color: "muted", children: ["UTR: ", _jsx(Text, { as: "span", size: "xs", weight: "medium", children: paymentTransactionId })] })), needsVerification && (_jsxs(Stack, { gap: "xs", children: [(displayedUpiId || buyerReportedUpiId) && (_jsxs(Stack, { gap: "xs", children: [_jsxs(Text, { size: "xs", color: "muted", children: ["Expected UPI: ", _jsx(Text, { as: "span", size: "xs", weight: "medium", children: displayedUpiId || "โ€”" }), " ยท ", "Buyer reported: ", _jsx(Text, { as: "span", size: "xs", weight: "medium", children: buyerReportedUpiId || "โ€”" })] }), paymentUpiMismatch && (_jsx(Div, { rounded: "lg", padding: "inlineSm", className: "border border-error/20", surface: "danger-surface", children: _jsx(Text, { size: "xs", className: "text-error", weight: "semibold", children: "UPI mismatch \u2014 buyer-reported ID doesn't match the one shown for this order." }) }))] })), _jsxs(Text, { size: "xs", color: "muted", children: ["Buyer marked as paid: ", _jsx(Text, { as: "span", size: "xs", weight: "medium", children: buyerMarkedPaid ? "Yes" : "No" }), " ยท ", "Fraud agreement accepted: ", _jsx(Text, { as: "span", size: "xs", weight: "medium", children: buyerFraudAgreementAccepted ? "Yes" : "No" })] }), _jsxs(Stack, { gap: "xs", children: [_jsx(Label, { size: "sm", weight: "medium", color: "primary", children: "Review note" }), _jsxs(Text, { size: "xs", color: "muted", children: ["Sent with ", _jsx(Text, { as: "span", size: "xs", weight: "medium", children: "Request re-upload" }), " or", " ", _jsx(Text, { as: "span", size: "xs", weight: "medium", children: "Reject as fraud" }), " below. Not saved by \u201CSave changes\u201D."] }), _jsx(Textarea, { value: reviewNote, onChange: (e) => handleReviewNoteChange(e.target.value), rows: 2, "aria-invalid": reviewNoteError ? true : undefined, placeholder: "e.g. Screenshot is blurry \u2014 amount not readable / UPI ID doesn't match order" }), reviewNoteError && (_jsx(Text, { size: "xs", color: "error", role: "alert", children: reviewNoteError }))] }), _jsx(Button, { type: "button", action: ACTIONS.ADMIN["verify-payment"], onClick: handleVerifyPayment, isLoading: isVerifyingPayment, disabled: isVerifyingPayment, variant: "primary", className: "w-full" }), _jsx(Button, { type: "button", action: ACTIONS.ADMIN["request-payment-reupload"], onClick: handleRequestReupload, isLoading: isRequestingReupload, disabled: isRequestingReupload, variant: "secondary", className: "w-full" }), _jsx(Button, { type: "button", action: ACTIONS.ADMIN["reject-payment-fraud"], onClick: handleRejectFraud, isLoading: isRejectingFraud, disabled: isRejectingFraud, variant: "danger", className: "w-full" })] })), isVerified && (_jsx(Div, { rounded: "lg", padding: "inlineSm", className: "border border-success/20", surface: "success-surface", children: _jsx(Text, { size: "xs", className: "text-success", weight: "medium", children: "Payment verified" }) }))] })) : (_jsx(Text, { size: "xs", color: "faint", children: "No proof uploaded yet." }))] })), _jsxs(FormActions, { align: "right", children: [_jsx(Button, { type: "button", variant: "secondary", onClick: onClose, children: "Cancel" }), _jsx(Button, { type: "submit", isLoading: saveMutation.isPending, disabled: !orderId || saveMutation.isPending, children: "Save changes" })] })] }) }));
210
+ return (_jsx(SideDrawer, { isOpen: open, onClose: onClose, title: orderLabel ? `Order: ${orderLabel}` : "Update Order", children: _jsx(FormShellContext.Provider, { value: form.shellCtx, children: _jsxs(Stack, { gap: "md", padding: "md", children: [_jsx(FormErrorSummary, {}), addons && _jsx(OrderAddonBadges, { order: addons, variant: "detail" }), items && items.length > 0 && (_jsxs(Stack, { gap: "xs", children: [_jsxs(Label, { size: "sm", weight: "medium", color: "primary", children: ["Items (", items.length, ")"] }), _jsx(Div, { className: "divide-y divide-[var(--appkit-color-border)] border border-[var(--appkit-color-border)]", rounded: "lg", children: items.map((item, i) => (_jsxs(Div, { layout: "flex", align: "center", gap: "3", padding: "sm", children: [_jsx(Div, { className: "h-10 w-10 shrink-0", rounded: "md", overflow: "hidden", children: _jsx(MediaImage, { src: item.image, alt: item.title, size: "thumbnail" }) }), _jsxs(Div, { className: "min-w-0 flex-1", children: [_jsx(Text, { size: "sm", className: "truncate", weight: "medium", children: item.title }), _jsxs(Text, { size: "xs", color: "muted", children: ["Qty: ", item.quantity, " \u00D7 ", toCurrency(item.unitPrice)] })] }), _jsx(Text, { size: "sm", className: "shrink-0", weight: "semibold", children: toCurrency(item.totalPrice) })] }, item.productId || i))) })] })), _jsx(SectionForm, { sections: sections, values: draft, onChange: (partial) => setDraft((d) => ({ ...d, ...partial })), onSubmit: () => {
211
+ // SectionForm surfaces errors and then submits regardless, so the
212
+ // guard is here.
213
+ form.clearErrors();
214
+ const parsed = adminOrderUpdateSchema.safeParse(visibleValues(adminOrderUpdateSchema, draft));
215
+ if (!parsed.success) {
216
+ applyZodIssues(parsed.error.issues, form.setFieldError);
217
+ return;
218
+ }
219
+ saveMutation.mutate();
220
+ }, onValidationChange: () => form.validate(draft), schema: adminOrderUpdateSchema, openIds: nav.openIds, onOpenChange: nav.setOpenIds, submitLabel: "Save changes", cancelLabel: "Cancel", onCancel: onClose, isLoading: saveMutation.isPending }), isManualPayment && (_jsxs(Stack, { gap: "xs", children: [_jsx(Label, { size: "sm", weight: "medium", color: "primary", children: "Payment Proof" }), isRejected && (_jsx(Text, { size: "xs", className: "text-error", weight: "medium", children: "Payment rejected as fraud \u2014 no further action available here." })), isReuploadRequested && !isVerified && (_jsx(Text, { size: "xs", className: "text-warning", weight: "medium", children: "Re-upload requested \u2014 waiting for the buyer to submit a new proof." })), paymentProofUrl ? (_jsxs(Stack, { gap: "xs", children: [_jsx(Div, { border: "default", rounded: "lg", overflow: "hidden", children: _jsx(MediaImage, { src: paymentProofUrl, alt: "Payment screenshot", size: "card" }) }), paymentTransactionId && (_jsxs(Text, { size: "xs", color: "muted", children: ["UTR: ", _jsx(Text, { as: "span", size: "xs", weight: "medium", children: paymentTransactionId })] })), needsVerification && (_jsxs(Stack, { gap: "xs", children: [(displayedUpiId || buyerReportedUpiId) && (_jsxs(Stack, { gap: "xs", children: [_jsxs(Text, { size: "xs", color: "muted", children: ["Expected UPI: ", _jsx(Text, { as: "span", size: "xs", weight: "medium", children: displayedUpiId || "โ€”" }), " ยท ", "Buyer reported: ", _jsx(Text, { as: "span", size: "xs", weight: "medium", children: buyerReportedUpiId || "โ€”" })] }), paymentUpiMismatch && (_jsx(Div, { rounded: "lg", padding: "inlineSm", className: "border border-error/20", surface: "danger-surface", children: _jsx(Text, { size: "xs", className: "text-error", weight: "semibold", children: "UPI mismatch \u2014 buyer-reported ID doesn't match the one shown for this order." }) }))] })), _jsxs(Text, { size: "xs", color: "muted", children: ["Buyer marked as paid: ", _jsx(Text, { as: "span", size: "xs", weight: "medium", children: buyerMarkedPaid ? "Yes" : "No" }), " ยท ", "Fraud agreement accepted: ", _jsx(Text, { as: "span", size: "xs", weight: "medium", children: buyerFraudAgreementAccepted ? "Yes" : "No" })] }), _jsxs(Stack, { gap: "xs", children: [_jsx(Label, { size: "sm", weight: "medium", color: "primary", children: "Review note" }), _jsxs(Text, { size: "xs", color: "muted", children: ["Sent with ", _jsx(Text, { as: "span", size: "xs", weight: "medium", children: "Request re-upload" }), " or", " ", _jsx(Text, { as: "span", size: "xs", weight: "medium", children: "Reject as fraud" }), " below. Not saved by \u201CSave changes\u201D."] }), _jsx(Textarea, { value: reviewNote, onChange: (e) => handleReviewNoteChange(e.target.value), rows: 2, "aria-invalid": reviewNoteError ? true : undefined, placeholder: "e.g. Screenshot is blurry \u2014 amount not readable / UPI ID doesn't match order" }), reviewNoteError && (_jsx(Text, { size: "xs", color: "error", role: "alert", children: reviewNoteError }))] }), _jsx(Button, { type: "button", action: ACTIONS.ADMIN["verify-payment"], onClick: handleVerifyPayment, isLoading: isVerifyingPayment, disabled: isVerifyingPayment, variant: "primary", className: "w-full" }), _jsx(Button, { type: "button", action: ACTIONS.ADMIN["request-payment-reupload"], onClick: handleRequestReupload, isLoading: isRequestingReupload, disabled: isRequestingReupload, variant: "secondary", className: "w-full" }), _jsx(Button, { type: "button", action: ACTIONS.ADMIN["reject-payment-fraud"], onClick: handleRejectFraud, isLoading: isRejectingFraud, disabled: isRejectingFraud, variant: "danger", className: "w-full" })] })), isVerified && (_jsx(Div, { rounded: "lg", padding: "inlineSm", className: "border border-success/20", surface: "success-surface", children: _jsx(Text, { size: "xs", className: "text-success", weight: "medium", children: "Payment verified" }) }))] })) : (_jsx(Text, { size: "xs", color: "faint", children: "No proof uploaded yet." }))] }))] }) }) }));
190
221
  }