@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
@@ -3,9 +3,14 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
3
3
  import { Code, useApiMutation } from "@mohasinac/appkit/client";
4
4
  import React from "react";
5
5
  import { useQuery, useQueryClient } from "@tanstack/react-query";
6
- import { Button, Div, FormActions, HorizontalRule, Input, Label, Row, Select, SideDrawer, Span, Stack, Text, Textarea, Toggle, useToast } from "../../../ui";
6
+ import { Button, Div, HorizontalRule, Input, Label, Row, Select, SideDrawer, Span, Stack, Text, Textarea, Toggle, useToast } from "../../../ui";
7
7
  import { apiClient } from "../../../http";
8
8
  import { ADMIN_ENDPOINTS, SUPPORT_ENDPOINTS } from "../../../constants/api-endpoints";
9
+ import { supportTicketUpdateSchema, toRelatedPartiesPayload, } from "../schemas/admin-ops-forms";
10
+ import { SectionForm, useSectionFormNav, buildSectionsFromSchema, visibleValues, } from "../../shell";
11
+ import { useFormShellState, FormShellContext } from "../../../ui/forms";
12
+ import { applyZodIssues } from "../../../ui/forms/apply-zod-issues";
13
+ import { FormErrorSummary } from "../../../ui/forms/FormErrorSummary";
9
14
  import { RecordStatusTimeline } from "../../status-history/components/RecordStatusTimeline";
10
15
  const __P = {
11
16
  p3: "p-[var(--appkit-space-3)]",
@@ -14,6 +19,23 @@ const __P = {
14
19
  const __O = {
15
20
  yAuto: "overflow-y-auto",
16
21
  };
22
+ /**
23
+ * The drawer reopens for a different ticket without unmounting, so the draft is
24
+ * seeded in two places — initial state and the `open` effect. One function, so
25
+ * the two cannot disagree about what an absent field defaults to.
26
+ */
27
+ function seedTicketDraft(status, priority, notes, parties) {
28
+ return {
29
+ status: (status ?? "open"),
30
+ priority: (priority ?? "normal"),
31
+ internalNotes: notes ?? "",
32
+ userId: parties?.userId ?? "",
33
+ storeId: parties?.storeId ?? "",
34
+ orderId: parties?.orderId ?? "",
35
+ productId: parties?.productId ?? "",
36
+ bidId: parties?.bidId ?? "",
37
+ };
38
+ }
17
39
  const STATUS_OPTIONS = [
18
40
  { label: "Open", value: "open" },
19
41
  { label: "In Progress", value: "in_progress" },
@@ -42,44 +64,33 @@ const ROLE_LABEL = {
42
64
  export function AdminSupportTicketDetailView({ open, onClose, ticketId, subject, userDisplayName, category, currentStatus, currentPriority, description, messages = [], internalNotes, orderId, relatedParties, statusHistory, statusHistoryTruncated, }) {
43
65
  const queryClient = useQueryClient();
44
66
  const { showToast } = useToast();
45
- const [status, setStatus] = React.useState(currentStatus ?? "open");
46
- const [priority, setPriority] = React.useState(currentPriority ?? "normal");
47
- const [notes, setNotes] = React.useState(internalNotes ?? "");
48
67
  const [replyBody, setReplyBody] = React.useState("");
49
- // ST-6 — local edits for the linked-parties panel
50
- const [parties, setParties] = React.useState(relatedParties ?? {});
68
+ const [draft, setDraft] = React.useState(() => seedTicketDraft(currentStatus, currentPriority, internalNotes, relatedParties));
51
69
  React.useEffect(() => {
52
70
  if (open) {
53
- setStatus(currentStatus ?? "open");
54
- setPriority(currentPriority ?? "normal");
55
- setNotes(internalNotes ?? "");
56
71
  setReplyBody("");
57
- setParties(relatedParties ?? {});
72
+ setDraft(seedTicketDraft(currentStatus, currentPriority, internalNotes, relatedParties));
58
73
  }
59
74
  }, [open, currentStatus, currentPriority, internalNotes, relatedParties]);
75
+ const sections = React.useMemo(() => buildSectionsFromSchema(supportTicketUpdateSchema, {
76
+ options: { status: STATUS_OPTIONS, priority: PRIORITY_OPTIONS },
77
+ }), []);
78
+ const nav = useSectionFormNav(sections, draft, { scope: "admin:support-ticket" });
79
+ const form = useFormShellState(supportTicketUpdateSchema, {
80
+ sections: nav.sectionMeta,
81
+ onGoToSection: nav.goToSection,
82
+ fieldToSectionIndex: nav.fieldToSectionIndex,
83
+ });
60
84
  const invalidate = () => queryClient.invalidateQueries({ queryKey: ["admin", "support-tickets"] });
61
- const cleanedParties = React.useMemo(() => {
62
- const out = {};
63
- if (parties.userId?.trim())
64
- out.userId = parties.userId.trim();
65
- if (parties.storeId?.trim())
66
- out.storeId = parties.storeId.trim();
67
- if (parties.orderId?.trim())
68
- out.orderId = parties.orderId.trim();
69
- if (parties.productId?.trim())
70
- out.productId = parties.productId.trim();
71
- if (parties.bidId?.trim())
72
- out.bidId = parties.bidId.trim();
73
- return out;
74
- }, [parties]);
75
85
  const updateMutation = useApiMutation({
76
86
  errorMessage: "Failed to update ticket.",
77
87
  mutationFn: async () => {
88
+ const v = visibleValues(supportTicketUpdateSchema, draft);
78
89
  await apiClient.patch(ADMIN_ENDPOINTS.SUPPORT_TICKET_BY_ID(ticketId), {
79
- status,
80
- priority,
81
- internalNotes: notes || undefined,
82
- relatedParties: Object.keys(cleanedParties).length > 0 ? cleanedParties : undefined,
90
+ status: v.status,
91
+ priority: v.priority,
92
+ internalNotes: v.internalNotes || undefined,
93
+ relatedParties: toRelatedPartiesPayload(v),
83
94
  });
84
95
  },
85
96
  onSuccess: () => {
@@ -91,7 +102,7 @@ export function AdminSupportTicketDetailView({ open, onClose, ticketId, subject,
91
102
  const replyMutation = useApiMutation({
92
103
  errorMessage: "Failed to send reply.",
93
104
  mutationFn: async () => {
94
- await apiClient.post(SUPPORT_ENDPOINTS.TICKET_MESSAGES(ticketId), { body: replyBody, newStatus: status });
105
+ await apiClient.post(SUPPORT_ENDPOINTS.TICKET_MESSAGES(ticketId), { body: replyBody, newStatus: draft.status });
95
106
  },
96
107
  onSuccess: () => {
97
108
  showToast("Reply sent.", "success");
@@ -104,7 +115,7 @@ export function AdminSupportTicketDetailView({ open, onClose, ticketId, subject,
104
115
  const [storeStatus, setStoreStatus] = React.useState("active");
105
116
  const [storeIsVerified, setStoreIsVerified] = React.useState(false);
106
117
  const [storeIsFeatured, setStoreIsFeatured] = React.useState(false);
107
- const linkedStoreId = parties.storeId?.trim();
118
+ const linkedStoreId = draft.storeId?.trim();
108
119
  const isStoreChangeRequest = category === "store_change_request";
109
120
  // Seed the panel from the store's REAL current values, not hardcoded
110
121
  // defaults — applyStoreChange below sends all three fields unconditionally,
@@ -140,7 +151,7 @@ export function AdminSupportTicketDetailView({ open, onClose, ticketId, subject,
140
151
  // Append an internal note marking the action; non-fatal if it fails.
141
152
  apiClient
142
153
  .patch(ADMIN_ENDPOINTS.SUPPORT_TICKET_BY_ID(ticketId), {
143
- internalNotes: (notes ? notes + "\n" : "") +
154
+ internalNotes: (draft.internalNotes ? draft.internalNotes + "\n" : "") +
144
155
  `[${new Date().toISOString()}] Applied store change to ${linkedStoreId}: status=${storeStatus}, verified=${storeIsVerified}, featured=${storeIsFeatured}`,
145
156
  })
146
157
  .catch(console.error);
@@ -151,7 +162,7 @@ export function AdminSupportTicketDetailView({ open, onClose, ticketId, subject,
151
162
  // admin edit quantity per line + remove lines, then PATCHes the order).
152
163
  const [orderItemsOpen, setOrderItemsOpen] = React.useState(false);
153
164
  const [orderItems, setOrderItems] = React.useState([]);
154
- const linkedOrderId = parties.orderId?.trim() || orderId;
165
+ const linkedOrderId = draft.orderId?.trim() || orderId;
155
166
  const isOrderModificationRequest = category === "order_modification_request" || category === "order_issue";
156
167
  const loadOrderItems = useApiMutation({
157
168
  errorMessage: "Failed to load order.",
@@ -171,7 +182,7 @@ export function AdminSupportTicketDetailView({ open, onClose, ticketId, subject,
171
182
  },
172
183
  });
173
184
  // ST-5 — Lift Ban actions for unban_request tickets
174
- const linkedUserId = parties.userId?.trim();
185
+ const linkedUserId = draft.userId?.trim();
175
186
  const isUnbanRequest = category === "unban_request";
176
187
  const liftHardBan = useApiMutation({
177
188
  errorMessage: "Failed to lift hard ban.",
@@ -184,7 +195,7 @@ export function AdminSupportTicketDetailView({ open, onClose, ticketId, subject,
184
195
  showToast("Hard ban lifted.", "success");
185
196
  apiClient
186
197
  .patch(ADMIN_ENDPOINTS.SUPPORT_TICKET_BY_ID(ticketId), {
187
- internalNotes: (notes ? notes + "\n" : "") +
198
+ internalNotes: (draft.internalNotes ? draft.internalNotes + "\n" : "") +
188
199
  `[${new Date().toISOString()}] Lifted hard ban for ${linkedUserId}`,
189
200
  status: "resolved",
190
201
  })
@@ -203,7 +214,7 @@ export function AdminSupportTicketDetailView({ open, onClose, ticketId, subject,
203
214
  showToast("Ticket-creation soft ban lifted.", "success");
204
215
  apiClient
205
216
  .patch(ADMIN_ENDPOINTS.SUPPORT_TICKET_BY_ID(ticketId), {
206
- internalNotes: (notes ? notes + "\n" : "") +
217
+ internalNotes: (draft.internalNotes ? draft.internalNotes + "\n" : "") +
207
218
  `[${new Date().toISOString()}] Lifted create_support_tickets soft ban for ${linkedUserId}`,
208
219
  status: "resolved",
209
220
  })
@@ -235,7 +246,7 @@ export function AdminSupportTicketDetailView({ open, onClose, ticketId, subject,
235
246
  showToast("Order items updated.", "success");
236
247
  apiClient
237
248
  .patch(ADMIN_ENDPOINTS.SUPPORT_TICKET_BY_ID(ticketId), {
238
- internalNotes: (notes ? notes + "\n" : "") +
249
+ internalNotes: (draft.internalNotes ? draft.internalNotes + "\n" : "") +
239
250
  `[${new Date().toISOString()}] Modified order ${linkedOrderId} items: ${orderItems
240
251
  .filter((it) => it.quantity > 0)
241
252
  .map((it) => `${it.productId}x${it.quantity}`)
@@ -246,13 +257,17 @@ export function AdminSupportTicketDetailView({ open, onClose, ticketId, subject,
246
257
  setOrderItemsOpen(false);
247
258
  },
248
259
  });
249
- return (_jsx(SideDrawer, { isOpen: open, onClose: onClose, title: subject ?? "Support Ticket", children: _jsxs(Stack, { className: `${__P.p4}`, gap: "md", children: [_jsxs(Row, { align: "center", gap: "sm", wrap: true, children: [_jsx(Span, { size: "xs", weight: "medium", className: `inline-flex ${STATUS_COLOR[currentStatus ?? "open"] ?? STATUS_COLOR.open}`, rounded: "full", padding: "pill-sm-tall", children: (currentStatus ?? "open").replace(/_/g, " ") }), _jsx(Span, { size: "xs", rounded: "full", padding: "pill-sm-tall", surface: "subtle", color: "muted", children: category }), orderId && (_jsxs(Span, { size: "xs", rounded: "full", padding: "pill-sm-tall", surface: "subtle", color: "muted", children: ["Order: ", orderId] }))] }), _jsxs(Div, { textSize: "sm", color: "muted", children: ["From: ", _jsx(Span, { weight: "medium", color: "primary", children: userDisplayName })] }), description && (_jsxs(Div, { className: `${__P.p3}`, rounded: "lg", surface: "muted", border: "default", children: [_jsx(Text, { className: "mb-1 tracking-wide", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "Description" }), _jsx(Text, { className: "whitespace-pre-wrap", color: "primary", size: "sm", children: description })] })), messages.length > 0 && (_jsxs(Stack, { gap: "sm", children: [_jsxs(Text, { className: "tracking-wide", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: ["Messages (", messages.length, ")"] }), _jsx(Stack, { className: `max-h-64 ${__O.yAuto}`, gap: "sm", children: messages.map((msg, i) => (_jsxs(Div, { textSize: "sm", surface: msg.authorRole === "user" ? "default" : "info-surface", className: msg.authorRole === "user" ? "border border-[var(--appkit-color-border)]" : "border border-[var(--appkit-color-info)]", rounded: "lg", padding: "sm", children: [_jsxs(Row, { color: "muted", textSize: "xs", className: "mb-1", align: "center", gap: "sm", children: [_jsx(Span, { weight: "medium", color: "muted", children: ROLE_LABEL[msg.authorRole ?? "user"] ?? msg.authorRole }), msg.createdAt && (_jsx(Span, { children: new Date(msg.createdAt).toLocaleString() }))] }), _jsx(Text, { className: "whitespace-pre-wrap", color: "primary", children: msg.body })] }, msg.id ?? i))) })] })), _jsxs(Stack, { gap: "xs", children: [_jsx(Label, { className: "uppercase tracking-wide", color: "muted", size: "xs", weight: "semibold", children: "Reply to user" }), _jsx(Textarea, { value: replyBody, onChange: (e) => setReplyBody(e.target.value), rows: 3, placeholder: "Type a reply\u2026" }), _jsx(Button, { type: "button", variant: "primary", size: "sm", isLoading: replyMutation.isPending, disabled: !replyBody.trim() || !ticketId || replyMutation.isPending, onClick: () => replyMutation.mutate(), children: "Send reply" })] }), _jsx(HorizontalRule, { tone: "subtle", spacing: "comfortable" }), _jsx(RecordStatusTimeline, { entries: statusHistory, truncatedCount: statusHistoryTruncated }), _jsx(HorizontalRule, { tone: "subtle", spacing: "comfortable" }), _jsx(Select, { label: "Status", options: STATUS_OPTIONS, value: status, onValueChange: setStatus }), _jsx(Select, { label: "Priority", options: PRIORITY_OPTIONS, value: priority, onValueChange: setPriority }), _jsxs(Stack, { gap: "xs", children: [_jsx(Label, { className: "uppercase tracking-wide", color: "muted", size: "xs", weight: "semibold", children: "Internal notes (staff only)" }), _jsx(Textarea, { value: notes, onChange: (e) => setNotes(e.target.value), rows: 3, placeholder: "Notes visible only to admins and employees\u2026" })] }), _jsxs(Stack, { padding: "sm", gap: "sm", rounded: "lg", border: "default", children: [_jsx(Text, { className: "tracking-wide", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "Linked parties" }), _jsx(Text, { size: "xs", color: "muted", children: "Tag the buyer / store / order / product / bid this ticket concerns." }), [
250
- ["userId", "User slug"],
251
- ["storeId", "Store slug"],
252
- ["orderId", "Order ID"],
253
- ["productId", "Product slug"],
254
- ["bidId", "Bid ID"],
255
- ].map(([key, label]) => (_jsx(Input, { label: label, type: "text", value: parties[key] ?? "", onChange: (e) => setParties((p) => ({ ...p, [key]: e.target.value })), placeholder: label }, key)))] }), isOrderModificationRequest && (_jsx(OrderItemsPanel, { linkedOrderId: linkedOrderId, orderItemsOpen: orderItemsOpen, setOrderItemsOpen: setOrderItemsOpen, orderItems: orderItems, setOrderItems: setOrderItems, loadOrderItems: loadOrderItems, applyOrderItems: applyOrderItems })), isStoreChangeRequest && (_jsx(StoreChangePanel, { linkedStoreId: linkedStoreId, storeStatus: storeStatus, setStoreStatus: setStoreStatus, storeIsVerified: storeIsVerified, setStoreIsVerified: setStoreIsVerified, storeIsFeatured: storeIsFeatured, setStoreIsFeatured: setStoreIsFeatured, applyStoreChange: applyStoreChange, isLoadingStore: storeQuery.isLoading })), isUnbanRequest && (_jsx(UnbanRequestPanel, { linkedUserId: linkedUserId, liftHardBan: liftHardBan, liftSoftBanTickets: liftSoftBanTickets })), _jsxs(FormActions, { align: "right", children: [_jsx(Button, { type: "button", variant: "secondary", onClick: onClose, children: "Cancel" }), _jsx(Button, { type: "button", isLoading: updateMutation.isPending, disabled: !ticketId || updateMutation.isPending, onClick: () => updateMutation.mutate(), children: "Save changes" })] })] }) }));
260
+ return (_jsx(SideDrawer, { isOpen: open, onClose: onClose, title: subject ?? "Support Ticket", children: _jsxs(Stack, { className: `${__P.p4}`, gap: "md", children: [_jsxs(Row, { align: "center", gap: "sm", wrap: true, children: [_jsx(Span, { size: "xs", weight: "medium", className: `inline-flex ${STATUS_COLOR[currentStatus ?? "open"] ?? STATUS_COLOR.open}`, rounded: "full", padding: "pill-sm-tall", children: (currentStatus ?? "open").replace(/_/g, " ") }), _jsx(Span, { size: "xs", rounded: "full", padding: "pill-sm-tall", surface: "subtle", color: "muted", children: category }), orderId && (_jsxs(Span, { size: "xs", rounded: "full", padding: "pill-sm-tall", surface: "subtle", color: "muted", children: ["Order: ", orderId] }))] }), _jsxs(Div, { textSize: "sm", color: "muted", children: ["From: ", _jsx(Span, { weight: "medium", color: "primary", children: userDisplayName })] }), description && (_jsxs(Div, { className: `${__P.p3}`, rounded: "lg", surface: "muted", border: "default", children: [_jsx(Text, { className: "mb-1 tracking-wide", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "Description" }), _jsx(Text, { className: "whitespace-pre-wrap", color: "primary", size: "sm", children: description })] })), messages.length > 0 && (_jsxs(Stack, { gap: "sm", children: [_jsxs(Text, { className: "tracking-wide", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: ["Messages (", messages.length, ")"] }), _jsx(Stack, { className: `max-h-64 ${__O.yAuto}`, gap: "sm", children: messages.map((msg, i) => (_jsxs(Div, { textSize: "sm", surface: msg.authorRole === "user" ? "default" : "info-surface", className: msg.authorRole === "user" ? "border border-[var(--appkit-color-border)]" : "border border-[var(--appkit-color-info)]", rounded: "lg", padding: "sm", children: [_jsxs(Row, { color: "muted", textSize: "xs", className: "mb-1", align: "center", gap: "sm", children: [_jsx(Span, { weight: "medium", color: "muted", children: ROLE_LABEL[msg.authorRole ?? "user"] ?? msg.authorRole }), msg.createdAt && (_jsx(Span, { children: new Date(msg.createdAt).toLocaleString() }))] }), _jsx(Text, { className: "whitespace-pre-wrap", color: "primary", children: msg.body })] }, msg.id ?? i))) })] })), _jsxs(Stack, { gap: "xs", children: [_jsx(Label, { className: "uppercase tracking-wide", color: "muted", size: "xs", weight: "semibold", children: "Reply to user" }), _jsx(Textarea, { value: replyBody, onChange: (e) => setReplyBody(e.target.value), rows: 3, placeholder: "Type a reply\u2026" }), _jsx(Button, { type: "button", variant: "primary", size: "sm", isLoading: replyMutation.isPending, disabled: !replyBody.trim() || !ticketId || replyMutation.isPending, onClick: () => replyMutation.mutate(), children: "Send reply" })] }), _jsx(HorizontalRule, { tone: "subtle", spacing: "comfortable" }), _jsx(RecordStatusTimeline, { entries: statusHistory, truncatedCount: statusHistoryTruncated }), _jsx(HorizontalRule, { tone: "subtle", spacing: "comfortable" }), _jsxs(FormShellContext.Provider, { value: form.shellCtx, children: [_jsx(FormErrorSummary, {}), _jsx(SectionForm, { sections: sections, values: draft, onChange: (partial) => setDraft((d) => ({ ...d, ...partial })), onSubmit: () => {
261
+ // SectionForm scrolls to the first erroring section and then
262
+ // submits regardless, so the guard lives here.
263
+ form.clearErrors();
264
+ const parsed = supportTicketUpdateSchema.safeParse(visibleValues(supportTicketUpdateSchema, draft));
265
+ if (!parsed.success) {
266
+ applyZodIssues(parsed.error.issues, form.setFieldError);
267
+ return;
268
+ }
269
+ updateMutation.mutate();
270
+ }, onValidationChange: () => form.validate(draft), schema: supportTicketUpdateSchema, openIds: nav.openIds, onOpenChange: nav.setOpenIds, submitLabel: "Save changes", cancelLabel: "Cancel", onCancel: onClose, isLoading: updateMutation.isPending })] }), isOrderModificationRequest && (_jsx(OrderItemsPanel, { linkedOrderId: linkedOrderId, orderItemsOpen: orderItemsOpen, setOrderItemsOpen: setOrderItemsOpen, orderItems: orderItems, setOrderItems: setOrderItems, loadOrderItems: loadOrderItems, applyOrderItems: applyOrderItems })), isStoreChangeRequest && (_jsx(StoreChangePanel, { linkedStoreId: linkedStoreId, storeStatus: storeStatus, setStoreStatus: setStoreStatus, storeIsVerified: storeIsVerified, setStoreIsVerified: setStoreIsVerified, storeIsFeatured: storeIsFeatured, setStoreIsFeatured: setStoreIsFeatured, applyStoreChange: applyStoreChange, isLoadingStore: storeQuery.isLoading })), isUnbanRequest && (_jsx(UnbanRequestPanel, { linkedUserId: linkedUserId, liftHardBan: liftHardBan, liftSoftBanTickets: liftSoftBanTickets }))] }) }));
256
271
  }
257
272
  function OrderItemsPanel(props) {
258
273
  const { linkedOrderId, orderItemsOpen, setOrderItemsOpen, orderItems, setOrderItems, loadOrderItems, applyOrderItems, } = props;
@@ -57,7 +57,17 @@ export function AdminSupportTicketsView({ children, ...props }) {
57
57
  const config = {
58
58
  portal: "admin",
59
59
  title: "Support Tickets",
60
- searchPlaceholder: "Search by subject",
60
+ search: {
61
+ placeholder: "Search by subject (starts with)",
62
+ /*
63
+ * `/api/admin/support-tickets` builds `subject@=${q}`, and `@=` is
64
+ * PREFIX-only against Firestore. `searchTxt` on this collection is built
65
+ * from the subject alone on purpose — the description and every message
66
+ * body are PII-encrypted, and indexing them in plaintext would hand back
67
+ * exactly what the encryption withholds.
68
+ */
69
+ fields: ["subject"],
70
+ },
61
71
  emptyLabel: "No support tickets found",
62
72
  filterKeys: ["status", "priority", "showResolved"],
63
73
  defaultSort: sortBy("createdAt", "DESC"),
@@ -57,7 +57,17 @@ export function AdminTeamView({ children, onBulkRemove, ...props }) {
57
57
  const config = {
58
58
  portal: "admin",
59
59
  title: "Team",
60
- searchPlaceholder: "Search by name or email",
60
+ search: {
61
+ placeholder: "Search by name or email",
62
+ /*
63
+ * Genuinely partial, and that is worth stating: `/api/admin/team`
64
+ * filters AFTER `mapDoc` has decrypted, with
65
+ * `(u.email ?? "").toLowerCase().includes(q)`. Most PII search in this
66
+ * codebase resolves an HMAC blind index and matches exactly; this one
67
+ * does not, which is why `mode` stays partial here.
68
+ */
69
+ fields: ["displayName", "email"],
70
+ },
61
71
  emptyLabel: "No employees found",
62
72
  filterKeys: ["group"],
63
73
  defaultSort: sortBy("createdAt", "DESC"),
@@ -54,7 +54,12 @@ export function AdminTesterChecklistView({ children, onBulkDelete, ...props }) {
54
54
  const config = useMemo(() => ({
55
55
  portal: "admin",
56
56
  title: "Tester Checklist",
57
- searchPlaceholder: "Search test cases, groups, or pages",
57
+ search: {
58
+ placeholder: "Search test cases, descriptions, groups or pages",
59
+ // `TesterChecklistItemRepository` builds `searchTxt` from label,
60
+ // description, groupLabel and pageLabel.
61
+ fields: ["label", "description", "groupLabel", "pageLabel"],
62
+ },
58
63
  emptyLabel: "No checklist items found",
59
64
  filterKeys: ["isActive", "bugConfirmed"],
60
65
  // Bug-confirmed (and reopened-away) cases are always isActive:false —
@@ -3,10 +3,13 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
3
3
  import { Code, useApiMutation } from "@mohasinac/appkit/client";
4
4
  import React from "react";
5
5
  import { useQueryClient } from "@tanstack/react-query";
6
- import { Avatar, Button, ConfirmDeleteModal, Div, Form, FormActions, Heading, Input, Row, Select, SideDrawer, Span, Stack, StackedViewShell, Text, Textarea, Toggle, useToast } from "../../../ui";
6
+ import { Avatar, Button, ConfirmDeleteModal, Div, Heading, Input, Row, Select, SideDrawer, Span, Stack, StackedViewShell, Text, Textarea, useToast } from "../../../ui";
7
7
  import { apiClient } from "../../../http";
8
8
  import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints";
9
- import { adminUserUpdateSchema } from "../schemas/admin-user-form";
9
+ import { adminUserUpdateSchema, toPublicProfilePayload, } from "../schemas/admin-user-form";
10
+ import { SectionForm, useSectionFormNav, buildSectionsFromSchema, visibleValues, } from "../../shell";
11
+ import { useFormShellState, FormShellContext } from "../../../ui/forms";
12
+ import { applyZodIssues } from "../../../ui/forms/apply-zod-issues";
10
13
  import { FormErrorSummary } from "../../../ui/forms/FormErrorSummary";
11
14
  const ROLE_OPTIONS = [
12
15
  { label: "User (buyer)", value: "user" },
@@ -47,22 +50,9 @@ export function AdminUserEditorView({ open, onClose, userId, displayName, photoU
47
50
  const queryClient = useQueryClient();
48
51
  const { showToast } = useToast();
49
52
  // --- General fields -------------------------------------------------------
50
- const [role, setRole] = React.useState(currentRole ?? "user");
51
- const [emailVerified, setEmailVerified] = React.useState(currentEmailVerified ?? false);
52
- const [isTester, setIsTester] = React.useState(currentIsTester ?? false);
53
- const [canTestAdmin, setCanTestAdmin] = React.useState(currentCanTestAdmin ?? false);
54
- const [adminNotes, setAdminNotes] = React.useState("");
53
+ const [draft, setDraft] = React.useState({});
55
54
  const [deleteOpen, setDeleteOpen] = React.useState(false);
56
55
  // --- ST-2 profile fields --------------------------------------------------
57
- const [editDisplayName, setEditDisplayName] = React.useState(displayName ?? "");
58
- const [phoneNumber, setPhoneNumber] = React.useState(currentPhoneNumber ?? "");
59
- const [bio, setBio] = React.useState(currentBio ?? "");
60
- const [location, setLocation] = React.useState(currentLocation ?? "");
61
- const [website, setWebsite] = React.useState(currentWebsite ?? "");
62
- const [twitter, setTwitter] = React.useState(currentSocialLinks?.twitter ?? "");
63
- const [instagram, setInstagram] = React.useState(currentSocialLinks?.instagram ?? "");
64
- const [facebook, setFacebook] = React.useState(currentSocialLinks?.facebook ?? "");
65
- const [linkedin, setLinkedin] = React.useState(currentSocialLinks?.linkedin ?? "");
66
56
  // --- Hard ban form --------------------------------------------------------
67
57
  const [showHardBanForm, setShowHardBanForm] = React.useState(false);
68
58
  const [hardBanReasonInput, setHardBanReasonInput] = React.useState("");
@@ -73,20 +63,22 @@ export function AdminUserEditorView({ open, onClose, userId, displayName, photoU
73
63
  const [softBanExpiry, setSoftBanExpiry] = React.useState("");
74
64
  React.useEffect(() => {
75
65
  if (open) {
76
- setRole(currentRole ?? "user");
77
- setEmailVerified(currentEmailVerified ?? false);
78
- setIsTester(currentIsTester ?? false);
79
- setCanTestAdmin(currentCanTestAdmin ?? false);
80
- setAdminNotes("");
81
- setEditDisplayName(displayName ?? "");
82
- setPhoneNumber(currentPhoneNumber ?? "");
83
- setBio(currentBio ?? "");
84
- setLocation(currentLocation ?? "");
85
- setWebsite(currentWebsite ?? "");
86
- setTwitter(currentSocialLinks?.twitter ?? "");
87
- setInstagram(currentSocialLinks?.instagram ?? "");
88
- setFacebook(currentSocialLinks?.facebook ?? "");
89
- setLinkedin(currentSocialLinks?.linkedin ?? "");
66
+ setDraft({
67
+ role: (currentRole ?? "user"),
68
+ emailVerified: currentEmailVerified ?? false,
69
+ isTester: currentIsTester ?? false,
70
+ canTestAdmin: currentCanTestAdmin ?? false,
71
+ adminNotes: "",
72
+ displayName: displayName ?? "",
73
+ phoneNumber: currentPhoneNumber ?? "",
74
+ bio: currentBio ?? "",
75
+ location: currentLocation ?? "",
76
+ website: currentWebsite ?? "",
77
+ twitter: currentSocialLinks?.twitter ?? "",
78
+ instagram: currentSocialLinks?.instagram ?? "",
79
+ facebook: currentSocialLinks?.facebook ?? "",
80
+ linkedin: currentSocialLinks?.linkedin ?? "",
81
+ });
90
82
  setShowHardBanForm(false);
91
83
  setHardBanReasonInput("");
92
84
  setShowAddSoftBan(false);
@@ -110,40 +102,34 @@ export function AdminUserEditorView({ open, onClose, userId, displayName, photoU
110
102
  currentSocialLinks?.facebook,
111
103
  currentSocialLinks?.linkedin,
112
104
  ]);
105
+ const sections = React.useMemo(() => buildSectionsFromSchema(adminUserUpdateSchema, {
106
+ options: { role: ROLE_OPTIONS },
107
+ }), []);
108
+ const nav = useSectionFormNav(sections, draft, { scope: "admin:user-editor" });
109
+ const form = useFormShellState(adminUserUpdateSchema, {
110
+ sections: nav.sectionMeta,
111
+ onGoToSection: nav.goToSection,
112
+ fieldToSectionIndex: nav.fieldToSectionIndex,
113
+ });
113
114
  const invalidate = () => queryClient.invalidateQueries({ queryKey: ["admin", "users"] });
114
115
  // --- Mutations ------------------------------------------------------------
115
116
  const saveMutation = useApiMutation({
116
117
  errorMessage: "Failed to update user.",
117
118
  mutationFn: async () => {
118
- // ST-2 — build publicProfile partial only when something changed; keeps
119
- // the PATCH payload minimal and avoids overwriting unrelated subkeys.
120
- const socialLinks = {};
121
- if (twitter.trim())
122
- socialLinks.twitter = twitter.trim();
123
- if (instagram.trim())
124
- socialLinks.instagram = instagram.trim();
125
- if (facebook.trim())
126
- socialLinks.facebook = facebook.trim();
127
- if (linkedin.trim())
128
- socialLinks.linkedin = linkedin.trim();
129
- const publicProfile = {};
130
- if (bio.trim())
131
- publicProfile.bio = bio.trim();
132
- if (location.trim())
133
- publicProfile.location = location.trim();
134
- if (website.trim())
135
- publicProfile.website = website.trim();
136
- if (Object.keys(socialLinks).length > 0)
137
- publicProfile.socialLinks = socialLinks;
119
+ const v = visibleValues(adminUserUpdateSchema, draft);
138
120
  await apiClient.patch(ADMIN_ENDPOINTS.USER_BY_ID(userId), {
139
- role,
140
- emailVerified,
141
- isTester,
142
- canTestAdmin: isTester ? canTestAdmin : false,
143
- adminNotes: adminNotes || undefined,
144
- displayName: editDisplayName.trim() || undefined,
145
- phoneNumber: phoneNumber.trim() || undefined,
146
- publicProfile: Object.keys(publicProfile).length > 0 ? publicProfile : undefined,
121
+ role: v.role,
122
+ emailVerified: v.emailVerified,
123
+ isTester: v.isTester,
124
+ // `when` already drops this once `isTester` is off; the `?? false` is
125
+ // what CLEARS a previously-granted flag rather than omitting the key.
126
+ canTestAdmin: v.canTestAdmin ?? false,
127
+ adminNotes: v.adminNotes || undefined,
128
+ displayName: v.displayName?.trim() || undefined,
129
+ phoneNumber: v.phoneNumber?.trim() || undefined,
130
+ // ST-2 — a partial: only the subkeys that were filled in, so an
131
+ // untouched field never overwrites what is stored.
132
+ publicProfile: toPublicProfilePayload(v),
147
133
  });
148
134
  },
149
135
  onSuccess: () => {
@@ -216,29 +202,26 @@ export function AdminUserEditorView({ open, onClose, userId, displayName, photoU
216
202
  const softBans = currentSoftBans ?? [];
217
203
  const renderAvatarHeader = () => userId ? (_jsxs(Row, { gap: "sm", align: "center", children: [_jsx(Avatar, { src: photoURL ?? null, name: displayName ?? userId, size: "md" }), _jsx(Text, { size: "sm", weight: "semibold", children: displayName ?? userId })] })) : null;
218
204
  const renderInfoCard = () => userId ? (_jsx(Div, { textSize: "xs", surface: "muted", rounded: "lg", border: "default", padding: "inlineSm", children: _jsxs(Stack, { color: "primary", gap: "xs", children: [_jsxs(Text, { size: "xs", children: [_jsx(Span, { weight: "semibold", children: "Owner ID (Firebase UID):" }), " ", _jsx(Code, { className: "select-all font-mono", children: userId })] }), ownedStoreId && (_jsxs(Text, { size: "xs", children: [_jsx(Span, { weight: "semibold", children: "Owns store:" }), " ", _jsx(Code, { className: "select-all font-mono", children: ownedStoreId }), ownedStoreName ? ` — ${ownedStoreName}` : ""] }))] }) })) : null;
219
- const renderRoleSection = () => (_jsx(Select, { label: "Role", options: ROLE_OPTIONS, value: role, onValueChange: setRole }));
220
- const renderEmailVerifiedSection = () => (_jsx(Toggle, { label: "Email verified", checked: emailVerified, onChange: setEmailVerified }));
221
- const renderIsTesterSection = () => (_jsxs(Stack, { gap: "sm", children: [_jsx(Toggle, { label: "Is Tester", checked: isTester, onChange: (next) => {
222
- setIsTester(next);
223
- if (!next)
224
- setCanTestAdmin(false);
225
- } }), isTester && (_jsx(Toggle, { label: "Can Test Admin Areas", checked: canTestAdmin, onChange: setCanTestAdmin }))] }));
226
- const renderProfileSection = () => (_jsxs(Stack, { border: "default", as: "section", gap: "sm", className: "border-t", padding: "t-sm", children: [_jsx(Heading, { level: 3, className: "mb-1", color: "muted", size: "sm", weight: "semibold", children: "Profile details" }), _jsx(Input, { label: "Display name", value: editDisplayName, onChange: (e) => setEditDisplayName(e.target.value), placeholder: "Full name shown on profile" }), _jsx(Input, { label: "Phone number", type: "tel", value: phoneNumber, onChange: (e) => setPhoneNumber(e.target.value), placeholder: "+91 90000 00000" }), _jsx(Textarea, { label: "Bio", value: bio, onChange: (e) => setBio(e.target.value), rows: 3, placeholder: "Short bio shown on the public profile\u2026" }), _jsx(Input, { label: "Location", value: location, onChange: (e) => setLocation(e.target.value), placeholder: "City, Country" }), _jsx(Input, { label: "Website", type: "url", value: website, onChange: (e) => setWebsite(e.target.value), placeholder: "https://" }), _jsx(Text, { size: "xs", color: "muted", children: "Social links" }), _jsx(Input, { label: "Twitter / X", value: twitter, onChange: (e) => setTwitter(e.target.value), placeholder: "@handle or full URL" }), _jsx(Input, { label: "Instagram", value: instagram, onChange: (e) => setInstagram(e.target.value), placeholder: "@handle or full URL" }), _jsx(Input, { label: "Facebook", value: facebook, onChange: (e) => setFacebook(e.target.value), placeholder: "https://facebook.com/\u2026" }), _jsx(Input, { label: "LinkedIn", value: linkedin, onChange: (e) => setLinkedin(e.target.value), placeholder: "https://linkedin.com/in/\u2026" })] }));
227
- const renderAdminNotesSection = () => (_jsx(Textarea, { label: "Admin notes (optional)", value: adminNotes, onChange: (e) => setAdminNotes(e.target.value), rows: 3, placeholder: "Internal notes about this user\u2026" }));
228
- const renderActionsSection = () => (_jsxs(FormActions, { align: "right", children: [_jsx(Button, { type: "button", variant: "danger", onClick: () => setDeleteOpen(true), disabled: !userId, children: "Delete user" }), _jsx(Button, { type: "button", variant: "secondary", onClick: onClose, children: "Cancel" }), _jsx(Button, { type: "submit", isLoading: saveMutation.isPending, disabled: !userId || saveMutation.isPending, children: "Save changes" })] }));
205
+ const renderFieldsSection = () => (_jsx(SectionForm, { sections: sections, values: draft, onChange: (partial) => setDraft((d) => ({ ...d, ...partial })), onSubmit: () => {
206
+ // SectionForm scrolls to the first erroring section and then submits
207
+ // regardless, so the guard lives here.
208
+ form.clearErrors();
209
+ const parsed = adminUserUpdateSchema.safeParse(visibleValues(adminUserUpdateSchema, draft));
210
+ if (!parsed.success) {
211
+ applyZodIssues(parsed.error.issues, form.setFieldError);
212
+ return;
213
+ }
214
+ saveMutation.mutate();
215
+ }, onValidationChange: () => form.validate(draft), schema: adminUserUpdateSchema, openIds: nav.openIds, onOpenChange: nav.setOpenIds, submitLabel: "Save changes", cancelLabel: "Cancel", onCancel: onClose, isLoading: saveMutation.isPending, destructiveAction: {
216
+ label: "Delete user",
217
+ onClick: () => setDeleteOpen(true),
218
+ disabled: !userId,
219
+ } }));
229
220
  const renderModerationSection = () => (_jsxs(Stack, { border: "default", as: "section", gap: "sm", className: "border-t", padding: "t-md", children: [_jsx(Heading, { level: 3, className: "mb-3", color: "muted", size: "sm", weight: "semibold", children: "Moderation" }), _jsx(HardBanPanel, { userId: userId, isHardBanned: isHardBanned, currentHardBanReason: currentHardBanReason, showHardBanForm: showHardBanForm, setShowHardBanForm: setShowHardBanForm, hardBanReasonInput: hardBanReasonInput, setHardBanReasonInput: setHardBanReasonInput, hardBanPending: hardBanMutation.isPending, unbanPending: unbanMutation.isPending, onHardBan: (reason) => hardBanMutation.mutate(reason), onUnban: () => unbanMutation.mutate() }), _jsx(SoftBanPanel, { userId: userId, softBans: softBans, showAddSoftBan: showAddSoftBan, setShowAddSoftBan: setShowAddSoftBan, softBanAction: softBanAction, setSoftBanAction: setSoftBanAction, softBanReason: softBanReason, setSoftBanReason: setSoftBanReason, softBanExpiry: softBanExpiry, setSoftBanExpiry: setSoftBanExpiry, softBanPending: softBanMutation.isPending, liftPending: liftSoftBanMutation.isPending, onAddSoftBan: (payload) => softBanMutation.mutate(payload), onLiftSoftBan: (action) => liftSoftBanMutation.mutate(action) })] }));
230
- return (_jsxs(_Fragment, { children: [_jsx(SideDrawer, { isOpen: open, onClose: onClose, title: displayName ? `Manage: ${displayName}` : "Manage User", children: _jsxs(Form, { schema: adminUserUpdateSchema, onSubmit: (e) => {
231
- e.preventDefault();
232
- saveMutation.mutate();
233
- }, padding: "md", children: [_jsx(FormErrorSummary, {}), _jsx(StackedViewShell, { portal: "admin", className: "space-y-4", sections: [
234
- renderAvatarHeader,
235
- renderInfoCard,
236
- renderRoleSection,
237
- renderEmailVerifiedSection,
238
- renderIsTesterSection,
239
- renderProfileSection,
240
- renderAdminNotesSection,
241
- renderActionsSection,
242
- renderModerationSection,
243
- ] })] }) }), _jsx(ConfirmDeleteModal, { isOpen: deleteOpen, onClose: () => setDeleteOpen(false), onConfirm: () => deleteMutation.mutate(), isDeleting: deleteMutation.isPending, title: `Delete ${displayName ?? "user"}?`, message: "This action cannot be undone. The user's account and all associated data will be permanently removed.", confirmText: "Delete user", variant: "danger" })] }));
221
+ return (_jsxs(_Fragment, { children: [_jsx(SideDrawer, { isOpen: open, onClose: onClose, title: displayName ? `Manage: ${displayName}` : "Manage User", children: _jsx(FormShellContext.Provider, { value: form.shellCtx, children: _jsxs(Div, { padding: "md", children: [_jsx(FormErrorSummary, {}), _jsx(StackedViewShell, { portal: "admin", className: "space-y-4", sections: [
222
+ renderAvatarHeader,
223
+ renderInfoCard,
224
+ renderFieldsSection,
225
+ renderModerationSection,
226
+ ] })] }) }) }), _jsx(ConfirmDeleteModal, { isOpen: deleteOpen, onClose: () => setDeleteOpen(false), onConfirm: () => deleteMutation.mutate(), isDeleting: deleteMutation.isPending, title: `Delete ${displayName ?? "user"}?`, message: "This action cannot be undone. The user's account and all associated data will be permanently removed.", confirmText: "Delete user", variant: "danger" })] }));
244
227
  }
@@ -33,7 +33,12 @@ export function AdminPageViewsReportView() {
33
33
  const config = {
34
34
  portal: "admin",
35
35
  title: "Page Views",
36
- searchPlaceholder: "Search by entity or URL",
36
+ search: {
37
+ placeholder: "Search by entity or URL",
38
+ // `/api/admin/analytics/pageviews` filters in memory on `row.entityId`
39
+ // and `row.url`.
40
+ fields: ["entityId", "url"],
41
+ },
37
42
  emptyLabel: "No page views recorded for this range.",
38
43
  filterKeys: ["entityType"],
39
44
  defaultSort: sortBy("count", "DESC"),
@@ -0,0 +1,36 @@
1
+ export interface AdminPageTab {
2
+ id: string;
3
+ label: string;
4
+ description: string;
5
+ }
6
+ /**
7
+ * Roles, and the permission catalogue you consult while building one.
8
+ *
9
+ * The catalogue is a REFERENCE SHEET, not a second editor: it lists what each
10
+ * permission string grants. Reading it is something you do mid-task, with the
11
+ * role you are editing still on screen — which is the argument for a tab
12
+ * rather than a separate page.
13
+ */
14
+ export declare const ROLES_TABS: readonly [{
15
+ readonly id: "roles";
16
+ readonly label: "Roles";
17
+ readonly description: "Who can do what, as named roles.";
18
+ }, {
19
+ readonly id: "permissions";
20
+ readonly label: "Permission catalogue";
21
+ readonly description: "Every permission string and what it grants.";
22
+ }];
23
+ export type RolesTabId = (typeof ROLES_TABS)[number]["id"];
24
+ export declare function isRolesTabId(value: string | null | undefined): value is RolesTabId;
25
+ /** Both toggle platform surfaces on and off, into the same settings document. */
26
+ export declare const ADMIN_SETTINGS_TABS: readonly [{
27
+ readonly id: "actions";
28
+ readonly label: "Actions";
29
+ readonly description: "Turn individual CTAs and bulk actions on or off.";
30
+ }, {
31
+ readonly id: "navigation";
32
+ readonly label: "Navigation";
33
+ readonly description: "Turn nav entries on or off across the portals.";
34
+ }];
35
+ export type AdminSettingsTabId = (typeof ADMIN_SETTINGS_TABS)[number]["id"];
36
+ export declare function isAdminSettingsTabId(value: string | null | undefined): value is AdminSettingsTabId;
@@ -0,0 +1,45 @@
1
+ /*
2
+ * WHY: The admin half of W8's C2. Two consolidations, each between pages that
3
+ * already share one permission — which is the test that decides whether
4
+ * two pages CAN be tabs at all: one page has one `requiredPermission`, so
5
+ * merging across a permission line either widens or narrows access
6
+ * silently.
7
+ *
8
+ * `roles` and `permissions` are both `admin:roles:read`; `settings/actions`
9
+ * and `settings/navigation` are both `admin:settings:write`. Pairs that
10
+ * failed that test were left alone — see the plan's "deliberately NOT
11
+ * consolidated" list.
12
+ *
13
+ * EXPORTS:
14
+ * ROLES_TABS / isRolesTabId, ADMIN_SETTINGS_TABS / isAdminSettingsTabId
15
+ *
16
+ * @tag domain:admin
17
+ * @tag layer:constants
18
+ * @tag pattern:registry
19
+ * @tag access:isomorphic
20
+ * @tag consumers:admin roles + settings/actions pages,action index
21
+ * @tag sideEffects:none
22
+ */
23
+ /**
24
+ * Roles, and the permission catalogue you consult while building one.
25
+ *
26
+ * The catalogue is a REFERENCE SHEET, not a second editor: it lists what each
27
+ * permission string grants. Reading it is something you do mid-task, with the
28
+ * role you are editing still on screen — which is the argument for a tab
29
+ * rather than a separate page.
30
+ */
31
+ export const ROLES_TABS = [
32
+ { id: "roles", label: "Roles", description: "Who can do what, as named roles." },
33
+ { id: "permissions", label: "Permission catalogue", description: "Every permission string and what it grants." },
34
+ ];
35
+ export function isRolesTabId(value) {
36
+ return !!value && ROLES_TABS.some((t) => t.id === value);
37
+ }
38
+ /** Both toggle platform surfaces on and off, into the same settings document. */
39
+ export const ADMIN_SETTINGS_TABS = [
40
+ { id: "actions", label: "Actions", description: "Turn individual CTAs and bulk actions on or off." },
41
+ { id: "navigation", label: "Navigation", description: "Turn nav entries on or off across the portals." },
42
+ ];
43
+ export function isAdminSettingsTabId(value) {
44
+ return !!value && ADMIN_SETTINGS_TABS.some((t) => t.id === value);
45
+ }