@mohasinac/appkit 4.14.0 → 4.15.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 (157) hide show
  1. package/dist/_internal/server/features/auctions/actions.js +2 -2
  2. package/dist/_internal/server/features/cart/service.d.ts +3 -3
  3. package/dist/_internal/server/features/checkout/actions.js +19 -0
  4. package/dist/_internal/server/features/checkout/source-context.d.ts +5 -0
  5. package/dist/_internal/server/features/checkout/source-context.js +157 -0
  6. package/dist/_internal/server/features/offers/adapters.d.ts +103 -0
  7. package/dist/_internal/server/features/offers/adapters.js +88 -0
  8. package/dist/_internal/server/features/offers/index.d.ts +2 -0
  9. package/dist/_internal/server/features/offers/index.js +1 -0
  10. package/dist/_internal/server/features/orders/adapters.js +24 -0
  11. package/dist/_internal/server/jobs/core/offerExpiry.js +10 -10
  12. package/dist/_internal/shared/actions/action-registry.js +3 -1
  13. package/dist/_internal/shared/features/auctions/schema.d.ts +14 -2
  14. package/dist/_internal/shared/features/auctions/schema.js +13 -1
  15. package/dist/_internal/shared/features/cart/schema.d.ts +14 -9
  16. package/dist/_internal/shared/features/cart/schema.js +15 -0
  17. package/dist/_internal/shared/features/products/schema.d.ts +2 -2
  18. package/dist/_internal/shared/history/history.d.ts +22 -0
  19. package/dist/_internal/shared/history/history.js +41 -1
  20. package/dist/client.d.ts +13 -2
  21. package/dist/client.js +20 -2
  22. package/dist/constants/field-names.d.ts +8 -6
  23. package/dist/constants/field-names.js +48 -6
  24. package/dist/features/account/components/UserBidsView.js +12 -4
  25. package/dist/features/account/components/UserReturnsView.d.ts +3 -0
  26. package/dist/features/account/components/UserReturnsView.js +17 -1
  27. package/dist/features/admin/components/AdminAuditLogView.js +1 -0
  28. package/dist/features/admin/components/AdminBidsView.js +15 -2
  29. package/dist/features/admin/components/AdminBlogEditorView.js +65 -33
  30. package/dist/features/admin/components/AdminFeatureEditorView.js +35 -6
  31. package/dist/features/admin/components/AdminNavEditorView.js +15 -5
  32. package/dist/features/admin/components/AdminNewsletterView.js +18 -2
  33. package/dist/features/admin/components/AdminOffersView.js +62 -18
  34. package/dist/features/admin/components/AdminPaymentMethodsView.js +24 -4
  35. package/dist/features/admin/components/AdminScammerEditorView.d.ts +2 -1
  36. package/dist/features/admin/components/AdminScammerEditorView.js +3 -13
  37. package/dist/features/admin/components/AdminScammersView.js +3 -8
  38. package/dist/features/admin/components/AdminSectionsView.js +32 -23
  39. package/dist/features/admin/components/AdminSessionsView.js +17 -2
  40. package/dist/features/admin/components/ViewAuditLogEntryModal.js +1 -0
  41. package/dist/features/admin/components/sections/adminSectionsTypes.d.ts +4 -2
  42. package/dist/features/admin/components/sections/adminSectionsTypes.js +45 -27
  43. package/dist/features/admin/constants/filter-tabs.d.ts +3 -0
  44. package/dist/features/admin/constants/filter-tabs.js +1 -0
  45. package/dist/features/admin/schemas/homepage-section-form.d.ts +22 -0
  46. package/dist/features/admin/schemas/homepage-section-form.js +68 -0
  47. package/dist/features/admin/schemas/nav-item-form.d.ts +74 -0
  48. package/dist/features/admin/schemas/nav-item-form.js +72 -0
  49. package/dist/features/auctions/components/AuctionBidsTable.d.ts +10 -1
  50. package/dist/features/auctions/components/AuctionBidsTable.js +22 -4
  51. package/dist/features/auctions/components/bid-detail-fields.d.ts +29 -0
  52. package/dist/features/auctions/components/bid-detail-fields.js +103 -0
  53. package/dist/features/audit-log/schemas/firestore.d.ts +14 -0
  54. package/dist/features/audit-log/schemas/firestore.js +7 -0
  55. package/dist/features/blog/schemas/blog-form.d.ts +80 -0
  56. package/dist/features/blog/schemas/blog-form.js +100 -0
  57. package/dist/features/events/components/AdminEventEditorView.js +32 -54
  58. package/dist/features/events/components/AdminEventEntriesView.js +22 -3
  59. package/dist/features/events/schemas/event-form.d.ts +113 -0
  60. package/dist/features/events/schemas/event-form.js +82 -0
  61. package/dist/features/grouped/schemas/grouped-listing-form.d.ts +101 -0
  62. package/dist/features/grouped/schemas/grouped-listing-form.js +89 -0
  63. package/dist/features/homepage/actions/homepage-section-actions.d.ts +11 -3
  64. package/dist/features/homepage/actions/homepage-section-actions.js +33 -1
  65. package/dist/features/homepage/schemas/index.d.ts +31 -0
  66. package/dist/features/homepage/schemas/index.js +24 -0
  67. package/dist/features/orders/components/OrderStatusTimeline.d.ts +5 -1
  68. package/dist/features/orders/components/OrderStatusTimeline.js +39 -20
  69. package/dist/features/orders/repository/orders.repository.js +5 -0
  70. package/dist/features/orders/schemas/firestore.d.ts +36 -1
  71. package/dist/features/orders/types/index.d.ts +35 -7
  72. package/dist/features/products/components/GroupSettingsPanel.js +5 -1
  73. package/dist/features/products/schemas/index.d.ts +2 -2
  74. package/dist/features/products/schemas/product-features.validators.d.ts +73 -0
  75. package/dist/features/products/schemas/product-features.validators.js +63 -0
  76. package/dist/features/scams/constants/scammer-status-display.d.ts +28 -0
  77. package/dist/features/scams/constants/scammer-status-display.js +79 -0
  78. package/dist/features/seller/actions/offer-actions.js +32 -2
  79. package/dist/features/seller/components/OfferPhaseTimeline.d.ts +59 -0
  80. package/dist/features/seller/components/OfferPhaseTimeline.js +107 -0
  81. package/dist/features/seller/components/SellerBidsView.js +11 -4
  82. package/dist/features/seller/components/SellerCouponEditorView.js +17 -17
  83. package/dist/features/seller/components/SellerOffersView.js +2 -1
  84. package/dist/features/seller/components/SellerPayoutSettingsView.js +38 -14
  85. package/dist/features/seller/repository/offer.repository.d.ts +69 -8
  86. package/dist/features/seller/repository/offer.repository.js +127 -23
  87. package/dist/features/seller/schemas/coupon-form.d.ts +72 -0
  88. package/dist/features/seller/schemas/coupon-form.js +116 -0
  89. package/dist/features/seller/schemas/firestore.d.ts +56 -2
  90. package/dist/features/seller/schemas/firestore.js +27 -0
  91. package/dist/features/seller/schemas/offer-forms.d.ts +21 -0
  92. package/dist/features/seller/schemas/offer-forms.js +21 -0
  93. package/dist/features/shell/build-sections.d.ts +63 -0
  94. package/dist/features/shell/build-sections.js +207 -0
  95. package/dist/features/shell/field-ui-meta.d.ts +115 -0
  96. package/dist/features/shell/field-ui-meta.js +324 -0
  97. package/dist/features/shell/index.d.ts +4 -0
  98. package/dist/features/shell/index.js +2 -0
  99. package/dist/features/status-history/components/StatusTimeline.d.ts +74 -0
  100. package/dist/features/status-history/components/StatusTimeline.js +46 -0
  101. package/dist/features/status-history/index.d.ts +11 -0
  102. package/dist/features/status-history/index.js +10 -0
  103. package/dist/features/store-extensions/components/ReviewDecisionModal.d.ts +27 -0
  104. package/dist/features/store-extensions/components/ReviewDecisionModal.js +82 -0
  105. package/dist/features/store-extensions/index.d.ts +12 -0
  106. package/dist/features/store-extensions/index.js +12 -0
  107. package/dist/features/store-extensions/schemas/admin-notification-form.d.ts +36 -0
  108. package/dist/features/store-extensions/schemas/admin-notification-form.js +114 -0
  109. package/dist/features/store-extensions/schemas/analytics-forms.d.ts +57 -0
  110. package/dist/features/store-extensions/schemas/analytics-forms.js +158 -0
  111. package/dist/features/store-extensions/schemas/custom-role-form.d.ts +85 -0
  112. package/dist/features/store-extensions/schemas/custom-role-form.js +118 -0
  113. package/dist/features/store-extensions/schemas/firestore.d.ts +21 -1
  114. package/dist/features/store-extensions/schemas/firestore.js +60 -0
  115. package/dist/features/store-extensions/schemas/google-config-form.d.ts +53 -0
  116. package/dist/features/store-extensions/schemas/google-config-form.js +105 -0
  117. package/dist/features/store-extensions/schemas/item-request-create-form.d.ts +28 -0
  118. package/dist/features/store-extensions/schemas/item-request-create-form.js +80 -0
  119. package/dist/features/store-extensions/schemas/listing-template-form.d.ts +78 -0
  120. package/dist/features/store-extensions/schemas/listing-template-form.js +87 -0
  121. package/dist/features/store-extensions/schemas/moderation-review-form.d.ts +49 -0
  122. package/dist/features/store-extensions/schemas/moderation-review-form.js +101 -0
  123. package/dist/features/store-extensions/schemas/payout-method-form.d.ts +153 -0
  124. package/dist/features/store-extensions/schemas/payout-method-form.js +155 -0
  125. package/dist/features/store-extensions/schemas/report-create-form.d.ts +25 -0
  126. package/dist/features/store-extensions/schemas/report-create-form.js +79 -0
  127. package/dist/features/store-extensions/schemas/report-review-form.d.ts +47 -0
  128. package/dist/features/store-extensions/schemas/report-review-form.js +111 -0
  129. package/dist/features/store-extensions/schemas/shipping-config-form.d.ts +148 -0
  130. package/dist/features/store-extensions/schemas/shipping-config-form.js +104 -0
  131. package/dist/features/store-extensions/schemas/store-category-form.d.ts +54 -0
  132. package/dist/features/store-extensions/schemas/store-category-form.js +73 -0
  133. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +326 -0
  134. package/dist/index.d.ts +12 -2
  135. package/dist/index.js +12 -2
  136. package/dist/next/routing/route-map.d.ts +16 -2
  137. package/dist/next/routing/route-map.js +9 -2
  138. package/dist/schemas/firestore-value.d.ts +8 -0
  139. package/dist/schemas/firestore-value.js +44 -0
  140. package/dist/schemas/index.d.ts +1 -1
  141. package/dist/schemas/index.js +1 -1
  142. package/dist/schemas/registry.d.ts +634 -1
  143. package/dist/schemas/registry.js +64 -1
  144. package/dist/seed/bids-seed-data.js +30 -0
  145. package/dist/seed/offers-seed-data.js +212 -7
  146. package/dist/seed/orders-seed-data.js +336 -1
  147. package/dist/seed/products-auctions-seed-data.js +41 -0
  148. package/dist/server.d.ts +2 -0
  149. package/dist/server.js +5 -0
  150. package/dist/styles.css +1 -1
  151. package/dist/tailwind-utilities.css +1 -1
  152. package/dist/ui/components/RecordDetailModal.d.ts +10 -1
  153. package/dist/ui/components/RecordDetailModal.js +2 -2
  154. package/package.json +2 -1
  155. package/scripts/node-esm-loader.mjs +65 -0
  156. package/scripts/seed-cli-loader.mjs +6 -49
  157. package/scripts/seed-cli.mjs +1 -1
@@ -2,25 +2,15 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { normalizeError } from "../../../errors/normalize";
4
4
  import { useApiMutation } from "@mohasinac/appkit/client";
5
- import React, { useEffect, useMemo } from "react";
6
- import { z } from "zod";
5
+ import React, { useEffect } from "react";
7
6
  import { useQuery } from "@tanstack/react-query";
8
7
  import { Button, ConfirmDeleteModal, Div, Heading, Input, RichTextEditor, RichTextRenderer, Row, Select, Span, Stack, StackedViewShell, TagInput, Text, Toggle, useToast } from "../../../ui";
9
8
  import { FormShellContext, useFormShellState, applyZodIssues, FormErrorSummary } from "../../../ui/forms";
10
9
  import { ImageUpload, MediaImage, useMediaUpload } from "../../media";
11
10
  import { apiClient } from "../../../http";
12
11
  import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints";
13
- import { blogPostCategorySchema } from "../../blog/schemas";
14
- import { StepForm } from "../../shell";
15
- // Form-input subset — `blogPostSchema` (blog schemas) requires `id` and uses
16
- // a `MediaField` shape for `coverImage`, neither of which this draft has
17
- // (coverImage here is a plain uploaded-URL string; id doesn't exist pre-save).
18
- const blogDraftSchema = z.object({
19
- title: z.string().min(1, "Title is required"),
20
- slug: z.string().min(1, "Slug is required").regex(/^blog-/, "Slug must start with 'blog-'"),
21
- category: blogPostCategorySchema,
22
- metaDescription: z.string().max(160).optional().or(z.literal("")),
23
- }).passthrough();
12
+ import { blogDraftSchema } from "../../blog/schemas/blog-form";
13
+ import { SectionForm, useSectionFormNav } from "../../shell";
24
14
  const __P = {
25
15
  p4: "p-[var(--appkit-space-4)]",
26
16
  };
@@ -62,7 +52,12 @@ function toSlug(str) {
62
52
  .trim()
63
53
  .replace(/[^a-z0-9]+/g, "-")
64
54
  .replace(/^-+|-+$/g, "");
65
- return base.startsWith("blog-") ? base : `blog-${base}`;
55
+ // Deliberately NOT prefixed with `blog-`. That prefix belongs to the
56
+ // document ID (`generateBlogPostId` adds it); the `slug` FIELD is what
57
+ // `findBySlug` queries and what appears in the public URL, and all 20 stored
58
+ // posts hold it bare. Prefixing here made every newly created post's URL
59
+ // disagree with every existing one.
60
+ return base;
66
61
  }
67
62
  function toDateInputValue(val) {
68
63
  if (!val)
@@ -80,15 +75,9 @@ export function AdminBlogEditorView({ postId, onSaved, onDeleted, embedded, ...r
80
75
  const isEdit = Boolean(postId);
81
76
  const [draft, setDraft] = React.useState(DEFAULT_DRAFT);
82
77
  const [slugManual, setSlugManual] = React.useState(false);
83
- const [currentStep, setCurrentStep] = React.useState(0);
84
78
  const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false);
85
79
  const { showToast } = useToast();
86
80
  const { upload } = useMediaUpload();
87
- const { shellCtx, setFieldError, validate } = useFormShellState(blogDraftSchema);
88
- useEffect(() => {
89
- validate(draft);
90
- // eslint-disable-next-line react-hooks/exhaustive-deps
91
- }, [draft, validate]);
92
81
  const update = React.useCallback((partial) => {
93
82
  setDraft((prev) => ({ ...prev, ...partial }));
94
83
  }, []);
@@ -146,13 +135,21 @@ export function AdminBlogEditorView({ postId, onSaved, onDeleted, embedded, ...r
146
135
  metaTitle: draft.metaTitle || undefined,
147
136
  metaDescription: draft.metaDescription || undefined,
148
137
  };
138
+ // T1 calculated field. It used to be computed ONLY on create, so editing
139
+ // a post's body left its "N min read" frozen at whatever the first draft
140
+ // happened to be — Root Cause #39, a CREATE transform with no UPDATE
141
+ // counterpart. Computed once here and sent on both paths.
142
+ const readTimeMinutes = Math.max(1, Math.round(draft.content.replace(/<[^>]*>/g, "").split(/\s+/).filter(Boolean).length / 200));
149
143
  if (isEdit) {
150
- return apiClient.patch(ADMIN_ENDPOINTS.BLOG_BY_ID(postId), payload);
144
+ return apiClient.patch(ADMIN_ENDPOINTS.BLOG_BY_ID(postId), {
145
+ ...payload,
146
+ readTimeMinutes,
147
+ });
151
148
  }
152
149
  return apiClient.post(ADMIN_ENDPOINTS.BLOG, {
153
150
  ...payload,
154
151
  authorId: "admin",
155
- readTimeMinutes: Math.max(1, Math.round(draft.content.replace(/<[^>]*>/g, "").split(/\s+/).filter(Boolean).length / 200)),
152
+ readTimeMinutes,
156
153
  views: 0,
157
154
  });
158
155
  },
@@ -181,11 +178,29 @@ export function AdminBlogEditorView({ postId, onSaved, onDeleted, embedded, ...r
181
178
  onError: (err) => showToast(err?.message ?? "Failed to delete post.", "error"),
182
179
  });
183
180
  const isLoading = saveMutation.isPending || postQuery.isLoading;
184
- const steps = [
181
+ /**
182
+ * Sections, not steps. Every field is reachable at once and one submit at
183
+ * the bottom saves the lot — the old wizard blocked step 2 until step 1
184
+ * validated, so a typo in the title made the whole post uneditable.
185
+ *
186
+ * The `render` bodies are unchanged: this form's controls are genuinely
187
+ * bespoke (a slug that auto-follows the title until touched, a read-time
188
+ * estimate, a live preview). What is no longer hand-written is which field
189
+ * belongs where — that is read off the schema's annotations, and the ids
190
+ * below match them exactly so `<FormErrorSummary>` can jump to the right
191
+ * section.
192
+ *
193
+ * The per-step `validate` callbacks are gone: they duplicated rules the Zod
194
+ * schema already states, and only ran as a gate to the NEXT step, which no
195
+ * longer exists.
196
+ */
197
+ const sections = [
185
198
  {
199
+ id: "content",
186
200
  label: "Content",
201
+ required: true,
202
+ quick: true,
187
203
  fields: ["title", "slug", "excerpt", "content"],
188
- validate: (values) => !values.title.trim() ? "Title is required" : null,
189
204
  render: ({ values, onChange }) => (_jsxs(Stack, { gap: "5", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Content" }), _jsx(Input, { label: "Title", value: values.title, onChange: (e) => {
190
205
  const v = e.target.value;
191
206
  onChange({ title: v, ...(!slugManual && { slug: toSlug(v) }) });
@@ -195,7 +210,11 @@ export function AdminBlogEditorView({ postId, onSaved, onDeleted, embedded, ...r
195
210
  }, placeholder: "blog-how-to-grade-pokemon-cards", helperText: "Auto-generated from title. Must start with 'blog-'." }), _jsx(Input, { label: "Excerpt", value: values.excerpt, onChange: (e) => onChange({ excerpt: e.target.value }), placeholder: "Short summary shown in listings and cards" }), _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", weight: "medium", color: "muted", children: "Content" }), _jsx(RichTextEditor, { value: values.content, onChange: (v) => onChange({ content: v }), placeholder: "Write your article here...", minHeightClassName: "min-h-[320px]" })] })] })),
196
211
  },
197
212
  {
213
+ id: "media",
198
214
  label: "Media",
215
+ // Media sections MUST stay mounted while collapsed: <Collapse> unmounts
216
+ // its children, which would abort an in-flight upload.
217
+ keepMounted: true,
199
218
  fields: ["coverImage", "youtubeId"],
200
219
  render: ({ values, onChange }) => (_jsxs(Stack, { gap: "5", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Media" }), _jsx(ImageUpload, { label: "Cover Image", currentImage: values.coverImage, onUpload: (file) => upload(file, "blog", true, {
201
220
  type: "blog-cover",
@@ -204,7 +223,9 @@ export function AdminBlogEditorView({ postId, onSaved, onDeleted, embedded, ...r
204
223
  }), onChange: (url) => onChange({ coverImage: url }) }), _jsx(Input, { label: "YouTube Video ID (optional)", value: values.youtubeId, onChange: (e) => onChange({ youtubeId: e.target.value }), placeholder: "e.g. dQw4w9WgXcQ", helperText: "The 11-character video ID from the YouTube URL." })] })),
205
224
  },
206
225
  {
226
+ id: "seo",
207
227
  label: "SEO & Tags",
228
+ quick: true,
208
229
  fields: ["category", "tags", "metaTitle", "metaDescription"],
209
230
  render: ({ values, onChange }) => {
210
231
  const readTime = Math.max(1, Math.round(values.content.replace(/<[^>]*>/g, "").split(/\s+/).filter(Boolean).length / 200));
@@ -212,7 +233,9 @@ export function AdminBlogEditorView({ postId, onSaved, onDeleted, embedded, ...r
212
233
  },
213
234
  },
214
235
  {
236
+ id: "publish",
215
237
  label: "Publish",
238
+ quick: true,
216
239
  fields: ["status", "publishedAt", "authorName", "isFeatured"],
217
240
  render: ({ values, onChange }) => (_jsxs(Stack, { gap: "5", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Publish Settings" }), _jsxs(Div, { layout: "grid", gap: "4", className: "grid-cols-2", children: [_jsx(Select, { label: "Status", options: STATUS_OPTIONS, value: values.status, onValueChange: (v) => onChange({ status: v }) }), _jsx(Input, { label: "Publish date (optional)", value: values.publishedAt, onChange: (e) => onChange({ publishedAt: e.target.value }), type: "date", helperText: "Auto-set to now when publishing." })] }), _jsx(Input, { label: "Author Name", value: values.authorName, onChange: (e) => onChange({ authorName: e.target.value }), placeholder: "Author display name" }), _jsx(Toggle, { label: "Featured post", checked: values.isFeatured, onChange: (checked) => onChange({ isFeatured: checked }) }), isEdit && (_jsx(Div, { className: "border-t border-[var(--appkit-color-border)]", padding: "t-md", children: _jsx(Button, { type: "button", variant: "danger", size: "sm", onClick: () => setShowDeleteConfirm(true), isLoading: deleteMutation.isPending, children: "Delete post" }) }))] })),
218
241
  },
@@ -221,16 +244,25 @@ export function AdminBlogEditorView({ postId, onSaved, onDeleted, embedded, ...r
221
244
  setShowDeleteConfirm(false);
222
245
  deleteMutation.mutate();
223
246
  }, onClose: () => setShowDeleteConfirm(false), title: "Delete post", message: "This will permanently delete the blog post. This cannot be undone." }));
224
- const fieldToStepIndex = useMemo(() => {
225
- const map = {};
226
- steps.forEach((step, i) => {
227
- step.fields?.forEach((field) => { map[field] = i; });
228
- });
229
- return map;
247
+ // Replaces the hand-rolled fieldToStepIndex + goToStep pair. The hook owns
248
+ // expand-then-scroll ordering, which a raw setState could not: a collapsed
249
+ // panel has to MOUNT before scrollIntoView/focus can reach the field. That
250
+ // jump was dead before W0 — `steps: []` was hardcoded in both context paths,
251
+ // so FormErrorSummary's label resolved to undefined for every form.
252
+ const { openIds, setOpenIds, goToSection, fieldToSectionIndex, sectionMeta } = useSectionFormNav(sections, draft);
253
+ const { shellCtx, setFieldError, validate } = useFormShellState(blogDraftSchema, {
254
+ sections: sectionMeta,
255
+ onGoToSection: goToSection,
256
+ fieldToSectionIndex,
257
+ });
258
+ // Live validation. Must sit AFTER useFormShellState — it used to run above
259
+ // the hook, which only compiled because the wizard declared the hook higher
260
+ // up the component.
261
+ useEffect(() => {
262
+ validate(draft);
230
263
  // eslint-disable-next-line react-hooks/exhaustive-deps
231
- }, [steps.length]);
232
- const wizardShellCtx = useMemo(() => ({ ...shellCtx, fieldToStepIndex, goToStep: (n) => setCurrentStep(n) }), [shellCtx, fieldToStepIndex, setCurrentStep]);
233
- const formContent = (_jsxs(FormShellContext.Provider, { value: wizardShellCtx, children: [_jsx(FormErrorSummary, {}), _jsx(StepForm, { steps: steps, values: draft, onChange: update, onComplete: () => { saveMutation.mutate(); }, formId: "admin-blog", currentStep: currentStep, onStepChange: setCurrentStep, completeLabel: isEdit ? "Save Changes" : "Create Post", isLoading: isLoading }), deleteModal] }));
264
+ }, [draft, validate]);
265
+ const formContent = (_jsxs(FormShellContext.Provider, { value: shellCtx, children: [_jsx(FormErrorSummary, {}), _jsx(SectionForm, { sections: sections, values: draft, onChange: update, onSubmit: () => { saveMutation.mutate(); }, schema: blogDraftSchema, openIds: openIds, onOpenChange: setOpenIds, submitLabel: isEdit ? "Save Changes" : "Create Post", isLoading: isLoading }), deleteModal] }));
234
266
  if (embedded) {
235
267
  return _jsx(Div, { className: `${__O.yAuto} ${__P.p4}`, children: formContent });
236
268
  }
@@ -8,6 +8,8 @@ import { Button, Checkbox, ConfirmDeleteModal, Div, Form, Grid, Input, Label, Se
8
8
  import { apiClient } from "../../../http";
9
9
  import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints";
10
10
  import { ERROR_MESSAGES } from "../../../errors/messages";
11
+ import { FormErrorSummary, applyZodIssues } from "../../../ui/forms";
12
+ import { productFeatureFormSchema } from "../../products/schemas/product-features.validators";
11
13
  import { PRODUCT_FEATURE_CATEGORY_OPTIONS, PRODUCT_FEATURE_DEFAULT_DISPLAY_ORDER, PRODUCT_FEATURE_ICON_COLOR_OPTIONS, PRODUCT_FEATURE_PRODUCT_TYPE_OPTIONS, PRODUCT_FEATURE_SCOPE_OPTIONS, } from "../../products/constants/product-features.constants";
12
14
  const __P = {
13
15
  p4: "p-[var(--appkit-space-4)]",
@@ -139,15 +141,42 @@ export function AdminFeatureEditorView({ featureId, fixedScope, fixedStoreId, em
139
141
  label: s.storeName ?? s.id,
140
142
  })),
141
143
  ], [storesQuery.data]);
142
- const isDisabled = !label || !icon || (scope === "store" && !storeId) || isSubmitting;
143
- const formSection = (_jsx(Form, { onSubmit: (e) => {
144
- e.preventDefault();
145
- saveMutation.mutate();
146
- }, children: _jsxs(Stack, { gap: "md", children: [_jsx(Input, { label: "Label", value: label, onChange: (e) => setLabel(e.target.value), required: true, placeholder: "e.g. Free Shipping", helperText: "Shown on product cards and detail pages." }), _jsx(Input, { label: "Description", value: description, onChange: (e) => setDescription(e.target.value), placeholder: "Optional \u2014 shown as a tooltip on the badge." }), _jsxs(Grid, { cols: "halves", gap: "md", children: [_jsx(Input, { label: "Icon", value: icon, onChange: (e) => setIcon(e.target.value), required: true, placeholder: "truck", helperText: "Icon-set name key (e.g. truck, star, trophy) OR an SVG path starting with M." }), _jsx(Select, { label: "Icon colour", value: iconColor, onChange: (e) => setIconColor(e.target.value), options: PRODUCT_FEATURE_ICON_COLOR_OPTIONS })] }), _jsxs(Grid, { cols: "halves", gap: "md", children: [_jsx(Select, { label: "Category", value: category, onChange: (e) => setCategory(e.target.value), options: PRODUCT_FEATURE_CATEGORY_OPTIONS, required: true }), _jsx(Input, { label: "Display order", value: displayOrder, onChange: (e) => setDisplayOrder(e.target.value), type: "number", min: 0 })] }), _jsxs(Div, { children: [_jsx(Text, { className: FIELD_LABEL_CLASS, children: "Applies to" }), _jsx(Row, { gap: "sm", wrap: true, className: "mt-2", children: PRODUCT_FEATURE_PRODUCT_TYPE_OPTIONS.map((opt) => {
144
+ /**
145
+ * Submit is gated on SUBMISSION, not on a truthiness guess.
146
+ *
147
+ * This used to be `!label || !icon || (scope === "store" && !storeId)` — a
148
+ * hand-rolled restatement of three of the schema's rules, which silently
149
+ * disagreed with it on every other rule (max lengths, displayOrder bounds,
150
+ * the productTypes minimum). The button now stays enabled and the schema
151
+ * reports what is actually wrong, on the field itself.
152
+ */
153
+ const isDisabled = isSubmitting;
154
+ const formSection = (_jsx(Form, { schema: productFeatureFormSchema, onSubmit: (e) => e.preventDefault(), children: ({ setFieldError, clearErrors }) => (_jsxs(Stack, { gap: "md", children: [_jsx(FormErrorSummary, {}), _jsx(Input, { label: "Label", value: label, onChange: (e) => setLabel(e.target.value), required: true, placeholder: "e.g. Free Shipping", helperText: "Shown on product cards and detail pages." }), _jsx(Input, { label: "Description", value: description, onChange: (e) => setDescription(e.target.value), placeholder: "Optional \u2014 shown as a tooltip on the badge." }), _jsxs(Grid, { cols: "halves", gap: "md", children: [_jsx(Input, { label: "Icon", value: icon, onChange: (e) => setIcon(e.target.value), required: true, placeholder: "truck", helperText: "Icon-set name key (e.g. truck, star, trophy) OR an SVG path starting with M." }), _jsx(Select, { label: "Icon colour", value: iconColor, onChange: (e) => setIconColor(e.target.value), options: PRODUCT_FEATURE_ICON_COLOR_OPTIONS })] }), _jsxs(Grid, { cols: "halves", gap: "md", children: [_jsx(Select, { label: "Category", value: category, onChange: (e) => setCategory(e.target.value), options: PRODUCT_FEATURE_CATEGORY_OPTIONS, required: true }), _jsx(Input, { label: "Display order", value: displayOrder, onChange: (e) => setDisplayOrder(e.target.value), type: "number", min: 0 })] }), _jsxs(Div, { children: [_jsx(Text, { className: FIELD_LABEL_CLASS, children: "Applies to" }), _jsx(Row, { gap: "sm", wrap: true, className: "mt-2", children: PRODUCT_FEATURE_PRODUCT_TYPE_OPTIONS.map((opt) => {
147
155
  const checked = productTypes.includes(opt.value);
148
156
  const pillClass = `${PILL_BASE_CLASS} ${checked ? PILL_CHECKED_CLASS : PILL_UNCHECKED_CLASS}`;
149
157
  return (_jsxs(Label, { className: pillClass, children: [_jsx(Checkbox, { bare: true, className: "sr-only", checked: checked, onChange: () => toggleProductType(opt.value) }), opt.label] }, opt.value));
150
- }) })] }), !fixedScope && (_jsx(Select, { label: "Scope", value: scope, onChange: (e) => setScope(e.target.value), options: PRODUCT_FEATURE_SCOPE_OPTIONS })), scope === "store" && !fixedStoreId && (_jsx(Select, { label: "Store", value: storeId, onChange: (e) => setStoreId(e.target.value), options: storeOptions, required: true, helperText: "Store-scope features are visible only on this store's listings." })), _jsx(Toggle, { label: "Active", checked: isActive, onChange: setIsActive }), _jsxs(Row, { gap: "3", padding: "t-xs", children: [_jsx(Button, { type: "submit", isLoading: isSubmitting, disabled: isDisabled, children: isEdit ? "Save changes" : "Create feature" }), isEdit && (_jsx(Button, { type: "button", variant: "danger", isLoading: deleteMutation.isPending, onClick: () => setDeleteConfirmOpen(true), children: "Delete" }))] })] }) }, "feature-form"));
158
+ }) })] }), !fixedScope && (_jsx(Select, { label: "Scope", value: scope, onChange: (e) => setScope(e.target.value), options: PRODUCT_FEATURE_SCOPE_OPTIONS })), scope === "store" && !fixedStoreId && (_jsx(Select, { label: "Store", value: storeId, onChange: (e) => setStoreId(e.target.value), options: storeOptions, required: true, helperText: "Store-scope features are visible only on this store's listings." })), _jsx(Toggle, { label: "Active", checked: isActive, onChange: setIsActive }), _jsxs(Row, { gap: "3", padding: "t-xs", children: [_jsx(Button, { type: "submit", isLoading: isSubmitting, disabled: isDisabled, onClick: () => {
159
+ // Parse before saving. This form had NO validation at all: the
160
+ // only guard was `required` on the inputs, an HTML attribute
161
+ // that any programmatic submit bypasses, and the max lengths /
162
+ // displayOrder bounds / productTypes minimum were never checked
163
+ // client-side at all.
164
+ clearErrors();
165
+ const parsed = productFeatureFormSchema.safeParse({
166
+ label, description: description || undefined,
167
+ icon, iconColor: iconColor || undefined,
168
+ category, scope,
169
+ productTypes: productTypes.length === 0 ? ["all"] : productTypes,
170
+ storeId: scope === "store" ? storeId || undefined : undefined,
171
+ isActive,
172
+ displayOrder: Number(displayOrder) || PRODUCT_FEATURE_DEFAULT_DISPLAY_ORDER,
173
+ });
174
+ if (!parsed.success) {
175
+ applyZodIssues(parsed.error.issues, setFieldError);
176
+ return;
177
+ }
178
+ saveMutation.mutate();
179
+ }, children: isEdit ? "Save changes" : "Create feature" }), isEdit && (_jsx(Button, { type: "button", variant: "danger", isLoading: deleteMutation.isPending, onClick: () => setDeleteConfirmOpen(true), children: "Delete" }))] })] })) }, "feature-form"));
151
180
  const deleteModal = deleteConfirmOpen && (_jsx(ConfirmDeleteModal, { isOpen: true, title: "Delete Feature", message: DELETE_CONFIRM_TEXT, onConfirm: () => { deleteMutation.mutate(); setDeleteConfirmOpen(false); }, onClose: () => setDeleteConfirmOpen(false), isDeleting: deleteMutation.isPending }));
152
181
  if (embedded) {
153
182
  return _jsxs(Div, { className: `${__O.yAuto} ${__P.p4}`, children: [formSection, deleteModal] });
@@ -1,8 +1,10 @@
1
1
  "use client";
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useApiMutation } from "@mohasinac/appkit/client";
4
4
  import React from "react";
5
5
  import { Button, Form, FormActions, Input, Select, SideDrawer, Toggle, useToast, } from "../../../ui";
6
+ import { FormErrorSummary, applyZodIssues } from "../../../ui/forms";
7
+ import { navItemFormSchema } from "../schemas/nav-item-form";
6
8
  import { apiClient } from "../../../http";
7
9
  import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints";
8
10
  import { ROUTES } from "../../../next/routing/route-map";
@@ -78,8 +80,16 @@ export function AdminNavEditorView({ open, onClose, onSaved, item, parentOptions
78
80
  { label: "None (top-level)", value: "" },
79
81
  ...parentOptions,
80
82
  ];
81
- return (_jsx(SideDrawer, { isOpen: open, onClose: onClose, title: isEdit ? "Edit Nav Item" : "New Nav Item", children: _jsxs(Form, { onSubmit: (e) => {
82
- e.preventDefault();
83
- saveMutation.mutate();
84
- }, spacing: "md", padding: "md", children: [_jsx(Input, { label: "Label", value: label, onChange: (e) => setLabel(e.target.value), required: true, placeholder: "e.g. Products" }), _jsx(Input, { label: "URL / href", value: href, onChange: (e) => setHref(e.target.value), required: true, placeholder: String(ROUTES.PUBLIC.PRODUCTS), helperText: "Use relative paths (e.g. /products) or full URLs." }), _jsx(Select, { label: "Icon (optional)", options: ICON_OPTIONS, value: icon, onValueChange: setIcon }), parentOptions.length > 0 && (_jsx(Select, { label: "Parent item (for nested nav)", options: parentSelectOptions, value: parentId, onValueChange: setParentId })), _jsx(Input, { label: "Order", value: String(order), onChange: (e) => setOrder(parseInt(e.target.value, 10) || 0), type: "number", min: 0, helperText: "Lower numbers appear first." }), _jsx(Toggle, { label: "Visible in navigation", checked: isVisible, onChange: setIsVisible }), _jsxs(FormActions, { align: "right", children: [_jsx(Button, { type: "button", variant: "secondary", onClick: onClose, children: "Cancel" }), _jsx(Button, { type: "submit", isLoading: saveMutation.isPending, disabled: !canSave || saveMutation.isPending, children: isEdit ? "Save changes" : "Create item" })] })] }) }));
83
+ return (_jsx(SideDrawer, { isOpen: open, onClose: onClose, title: isEdit ? "Edit Nav Item" : "New Nav Item", children: _jsx(Form, { schema: navItemFormSchema, onSubmit: (e) => e.preventDefault(), spacing: "md", padding: "md", children: ({ setFieldError, clearErrors }) => (_jsxs(_Fragment, { children: [_jsx(FormErrorSummary, {}), _jsx(Input, { label: "Label", value: label, onChange: (e) => setLabel(e.target.value), required: true, placeholder: "e.g. Products" }), _jsx(Input, { label: "URL / href", value: href, onChange: (e) => setHref(e.target.value), required: true, placeholder: String(ROUTES.PUBLIC.PRODUCTS), helperText: "Use relative paths (e.g. /products) or full URLs." }), _jsx(Select, { label: "Icon (optional)", options: ICON_OPTIONS, value: icon, onValueChange: setIcon }), parentOptions.length > 0 && (_jsx(Select, { label: "Parent item (for nested nav)", options: parentSelectOptions, value: parentId, onValueChange: setParentId })), _jsx(Input, { label: "Order", value: String(order), onChange: (e) => setOrder(parseInt(e.target.value, 10) || 0), type: "number", min: 0, helperText: "Lower numbers appear first." }), _jsx(Toggle, { label: "Visible in navigation", checked: isVisible, onChange: setIsVisible }), _jsxs(FormActions, { align: "right", children: [_jsx(Button, { type: "button", variant: "secondary", onClick: onClose, children: "Cancel" }), _jsx(Button, { type: "submit", isLoading: saveMutation.isPending, disabled: saveMutation.isPending, onClick: () => {
84
+ clearErrors();
85
+ const parsed = navItemFormSchema.safeParse({
86
+ label, href, icon: icon || undefined, order,
87
+ parentId: parentId || undefined, isVisible,
88
+ });
89
+ if (!parsed.success) {
90
+ applyZodIssues(parsed.error.issues, setFieldError);
91
+ return;
92
+ }
93
+ saveMutation.mutate();
94
+ }, children: isEdit ? "Save changes" : "Create item" })] })] })) }) }));
85
95
  }
@@ -6,7 +6,7 @@ import { sieveFilter, SIEVE_OP } from "@mohasinac/appkit/client";
6
6
  import { sortBy } from "@mohasinac/appkit/client";
7
7
  import React, { useCallback, useEffect, useState } from "react";
8
8
  import { useQueryClient } from "@tanstack/react-query";
9
- import { Button, ConfirmDeleteModal, FilterChipGroup, ListingLayout, RowActionMenu, useToast } from "../../../ui";
9
+ import { Button, ConfirmDeleteModal, FilterChipGroup, ListingLayout, RowActionMenu, useToast, RecordDetailModal, } from "../../../ui";
10
10
  import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints";
11
11
  import { ACTIONS } from "../../../_internal/shared/actions/action-registry";
12
12
  import { buildBulkAction } from "../../../_internal/shared/actions/bulk-helpers";
@@ -15,6 +15,7 @@ import { toRecordArray, toRelativeDate, toStringValue, } from "../hooks/useAdmin
15
15
  import { DataListingView } from "./DataListingView";
16
16
  import { apiClient } from "../../../http";
17
17
  export function AdminNewsletterView({ children, onBulkUnsubscribe, ...props }) {
18
+ const [detail, setDetail] = useState(null);
18
19
  const queryClient = useQueryClient();
19
20
  const { showToast } = useToast();
20
21
  const [unsubscribeOpen, setUnsubscribeOpen] = useState(false);
@@ -98,6 +99,9 @@ export function AdminNewsletterView({ children, onBulkUnsubscribe, ...props }) {
98
99
  { value: sortBy("subscribedAt", "DESC"), label: "Newest" },
99
100
  { value: "subscribedAt", label: "Oldest" },
100
101
  ],
102
+ // Opening a row is what makes the destructive actions beside it
103
+ // safe to offer at all.
104
+ onRowClick: (row) => setDetail(row),
101
105
  mapRows: (response) => toRecordArray(response.subscribers).map((item, index) => ({
102
106
  id: toStringValue(item.id, `sub-${index}`),
103
107
  primary: toStringValue(item.email, "Unknown email"),
@@ -105,6 +109,7 @@ export function AdminNewsletterView({ children, onBulkUnsubscribe, ...props }) {
105
109
  .filter(Boolean)
106
110
  .join(" · ") || "—",
107
111
  status: toStringValue(item.status, "unknown"),
112
+ detail: item,
108
113
  updatedAt: toRelativeDate(item.subscribedAt ??
109
114
  item.createdAt),
110
115
  })),
@@ -138,7 +143,18 @@ export function AdminNewsletterView({ children, onBulkUnsubscribe, ...props }) {
138
143
  ] })),
139
144
  renderFilterPanel: ({ pendingFilters, setPendingFilters }) => (_jsx(FilterChipGroup, { label: "Status", tabs: ADMIN_NEWSLETTER_STATUS_TABS, value: pendingFilters.status ?? "", onChange: (id) => setPendingFilters((p) => ({ ...p, status: id })) })),
140
145
  };
141
- return (_jsxs(_Fragment, { children: [_jsx(DataListingView, { config: config }), _jsx(ConfirmDeleteModal, { isOpen: unsubscribeOpen, onClose: () => {
146
+ return (_jsxs(_Fragment, { children: [_jsx(DataListingView, { config: config }), _jsx(RecordDetailModal, { isOpen: detail !== null, onClose: () => setDetail(null), title: toStringValue(detail?.detail?.email, "Subscriber"), fields: (() => {
147
+ const d = (detail?.detail ?? {});
148
+ return detail ? [
149
+ { label: "Email", value: toStringValue(d.email, "—") },
150
+ { label: "Status", value: toStringValue(d.status, "—") },
151
+ { label: "Source", value: toStringValue(d.source, "—") },
152
+ { label: "Subscribed", value: toRelativeDate(d.subscribedAt ?? d.createdAt) },
153
+ { label: "Unsubscribed", value: toRelativeDate(d.unsubscribedAt) },
154
+ { label: "IP address", value: d.ipAddress ? "Logged" : "Not logged" },
155
+ { label: "Subscriber ID", value: detail?.id ?? "—" },
156
+ ] : undefined;
157
+ })() }), _jsx(ConfirmDeleteModal, { isOpen: unsubscribeOpen, onClose: () => {
142
158
  setUnsubscribeOpen(false);
143
159
  setSelectedRow(null);
144
160
  }, onConfirm: () => {
@@ -34,15 +34,19 @@ import { sieveFilter, SIEVE_OP } from "@mohasinac/appkit/client";
34
34
  import { sortBy } from "@mohasinac/appkit/client";
35
35
  import React, { useState } from "react";
36
36
  import { useQueryClient } from "@tanstack/react-query";
37
- import { ConfirmDeleteModal, FilterChipGroup, ListingLayout, RecordDetailModal, RowActionMenu, useToast, } from "../../../ui";
37
+ import { FilterChipGroup, ListingLayout, RecordDetailModal, RowActionMenu, useToast, } from "../../../ui";
38
38
  import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints";
39
39
  import { ADMIN_OFFER_STATUS_TABS } from "../constants/filter-tabs";
40
+ import { OfferPhaseTimeline } from "../../seller/components/OfferPhaseTimeline";
41
+ import { QuickFormDrawer } from "../../shell/QuickFormDrawer";
42
+ import { cancelOfferFormSchema } from "../../seller/schemas/offer-forms";
40
43
  import { ACTIONS } from "../../../_internal/shared/actions/action-registry";
41
44
  import { ROW_ACTION_META, ROW_ACTION_ID } from "../../products/constants/action-defs";
42
45
  import { ROUTES } from "../../../next/routing/route-map";
43
46
  import { toRecordArray, toRelativeDate, toCurrency, toStringValue, } from "../hooks/useAdminListingData";
44
47
  import { DataListingView } from "./DataListingView";
45
48
  import { apiClient } from "../../../http";
49
+ import { normalizeError } from "../../../errors/normalize";
46
50
  /** Terminal states — an offer here has nothing left to cancel. */
47
51
  const TERMINAL_STATUSES = new Set(["paid", "declined", "expired", "withdrawn"]);
48
52
  export function AdminOffersView({ children, ...props }) {
@@ -51,9 +55,39 @@ export function AdminOffersView({ children, ...props }) {
51
55
  const [detailRow, setDetailRow] = useState(null);
52
56
  const [cancelOpen, setCancelOpen] = useState(false);
53
57
  const [selectedRow, setSelectedRow] = useState(null);
58
+ /**
59
+ * Open on the SINGLE-ITEM response, not the row's cached list blob.
60
+ *
61
+ * Two reasons. The list payload has no `chain`, so a three-round negotiation
62
+ * would render as one orphan round; and a modal opened on a list snapshot
63
+ * shows whatever the last refetch happened to hold, which is the stale-editor
64
+ * shape of Root Cause #38.
65
+ *
66
+ * The row is shown immediately and enriched when the fetch lands, so the
67
+ * modal never blocks on the network.
68
+ */
69
+ const openDetail = async (row) => {
70
+ setDetailRow(row);
71
+ try {
72
+ const full = await apiClient.get(ADMIN_ENDPOINTS.OFFER_BY_ID(row.id));
73
+ const detail = (full?.data ?? full);
74
+ if (detail && typeof detail === "object") {
75
+ setDetailRow((prev) => (prev?.id === row.id ? { ...prev, detail } : prev));
76
+ }
77
+ }
78
+ catch (err) {
79
+ // The row's own data is already on screen, so this degrades to "no
80
+ // chain" rather than to an empty modal. Say so instead of failing silently.
81
+ const normalized = normalizeError(err);
82
+ showToast(normalized.message || "Couldn't load the full offer history.", "error");
83
+ }
84
+ };
54
85
  const cancelMutation = useApiMutation({
55
- mutationFn: async (offerId) => {
56
- await apiClient.patch(ADMIN_ENDPOINTS.OFFER_BY_ID(offerId), { status: "expired" });
86
+ mutationFn: async ({ offerId, reason }) => {
87
+ await apiClient.patch(ADMIN_ENDPOINTS.OFFER_BY_ID(offerId), {
88
+ status: "expired",
89
+ reason,
90
+ });
57
91
  },
58
92
  onSuccess: () => {
59
93
  showToast("Offer cancelled and cleared from the buyer's cart.", "success");
@@ -100,20 +134,17 @@ export function AdminOffersView({ children, ...props }) {
100
134
  getTotal: (response, mappedRows) => typeof response.total === "number" ? response.total : mappedRows.length,
101
135
  // A menu of pure mutations is not a detail affordance (Root Cause #56) —
102
136
  // cancelling an offer without first reading it is acting blind.
103
- onRowClick: (row) => setDetailRow(row),
137
+ onRowClick: (row) => void openDetail(row),
104
138
  buildFilters: (state) => state.status && state.status !== "All"
105
139
  ? sieveFilter("status", SIEVE_OP.EQ, state.status)
106
140
  : undefined,
107
- buildBulkActions: (selection) => [
108
- {
109
- id: ROW_ACTION_ID.CANCEL,
110
- label: ACTIONS.ADMIN["cancel-offer"].label,
111
- variant: "secondary",
112
- onClick: () => selection.clearSelection(),
113
- },
114
- ],
141
+ // No bulk actions, deliberately. There was a destructively-labelled
142
+ // "Cancel offer" entry here whose onClick only called clearSelection() — a
143
+ // registry-backed control that read as working and did nothing. Cancel now
144
+ // requires a per-offer reason, and one shared reason across a mixed
145
+ // selection is worse audit data than no bulk action at all.
115
146
  renderRowActions: (row) => (_jsx(RowActionMenu, { actions: [
116
- { label: ROW_ACTION_META[ROW_ACTION_ID.VIEW].label, onClick: () => setDetailRow(row) },
147
+ { label: ROW_ACTION_META[ROW_ACTION_ID.VIEW].label, onClick: () => void openDetail(row) },
117
148
  {
118
149
  label: ACTIONS.ADMIN["cancel-offer"].label,
119
150
  destructive: true,
@@ -176,11 +207,24 @@ export function AdminOffersView({ children, ...props }) {
176
207
  : []),
177
208
  ],
178
209
  }
179
- : undefined }), _jsx(ConfirmDeleteModal, { isOpen: cancelOpen, onClose: () => {
210
+ : undefined, extra: _jsx(OfferPhaseTimeline, { status: toStringValue(d.status, "pending"), timeline: d.timeline, timelineTruncated: d.timelineTruncated, superseded: Boolean(d.superseded), createdAt: toStringValue(d.createdAt, ""), respondedAt: toStringValue(d.respondedAt, ""), acceptedAt: toStringValue(d.acceptedAt, ""), paidAt: toStringValue(d.paidAt, ""), chain: d.chain }) }), _jsx(QuickFormDrawer, { isOpen: cancelOpen, onClose: () => {
180
211
  setCancelOpen(false);
181
212
  setSelectedRow(null);
182
- }, onConfirm: () => {
183
- if (selectedRow)
184
- cancelMutation.mutate(selectedRow.id);
185
- }, isDeleting: cancelMutation.isPending, title: ACTIONS.ADMIN["cancel-offer"].confirmation.title, message: ACTIONS.ADMIN["cancel-offer"].confirmation.body, confirmText: ACTIONS.ADMIN["cancel-offer"].confirmation.confirmLabel, variant: "warning" })] }));
213
+ }, title: ACTIONS.ADMIN["cancel-offer"].confirmation.title, schema: cancelOfferFormSchema, fields: [
214
+ {
215
+ name: "reason",
216
+ label: "Reason for cancelling",
217
+ type: "textarea",
218
+ required: true,
219
+ placeholder: "e.g. Listing withdrawn by the seller after a pricing error",
220
+ helperText: `${ACTIONS.ADMIN["cancel-offer"].confirmation.body} The buyer sees this reason, and it is recorded in the audit log.`,
221
+ },
222
+ ], submitLabel: ACTIONS.ADMIN["cancel-offer"].confirmation.confirmLabel, isLoading: cancelMutation.isPending, onSubmit: (values) => {
223
+ if (!selectedRow)
224
+ return;
225
+ cancelMutation.mutate({
226
+ offerId: selectedRow.id,
227
+ reason: String(values.reason ?? ""),
228
+ });
229
+ } })] }));
186
230
  }
@@ -1,9 +1,10 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { sortBy } from "@mohasinac/appkit/client";
4
+ import { useState } from "react";
4
5
  import { useUrlTable } from "../../../react/hooks/useUrlTable";
5
6
  import { useQueryClient } from "@tanstack/react-query";
6
- import { Badge, Div, FilterChipGroup, Span, Stack, Text, useToast } from "../../../ui";
7
+ import { Badge, Div, FilterChipGroup, Span, Stack, Text, useToast, RecordDetailModal, } from "../../../ui";
7
8
  import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints";
8
9
  import { ACTIONS } from "../../../_internal/shared/actions/action-registry";
9
10
  import { toRecordArray, toRelativeDate, toStringValue } from "../hooks/useAdminListingData";
@@ -44,7 +45,10 @@ const PM_COLUMNS = [
44
45
  render: (row) => _jsx(Span, { size: "sm", color: "muted", children: row.updatedAt }),
45
46
  },
46
47
  ];
47
- function buildConfig(onAction) {
48
+ function buildConfig(onAction,
49
+ // Threaded in rather than closed over: this factory sits OUTSIDE the
50
+ // component, so the component's state setter is not in scope here.
51
+ onOpen) {
48
52
  return {
49
53
  portal: "admin",
50
54
  title: "Payment Methods",
@@ -65,6 +69,9 @@ function buildConfig(onAction) {
65
69
  { value: "type", label: "Type" },
66
70
  ],
67
71
  columns: PM_COLUMNS,
72
+ // Opening a row is what makes the destructive actions beside it
73
+ // safe to offer at all.
74
+ onRowClick: onOpen,
68
75
  mapRows: (response) => toRecordArray(response.items).map((item, index) => ({
69
76
  id: toStringValue(item.id, `pm-${index}`),
70
77
  primary: `${toStringValue(item.type, "unknown").toUpperCase()} · ${toStringValue(item.displayLabel, "—")}`,
@@ -72,6 +79,7 @@ function buildConfig(onAction) {
72
79
  status: toStringValue(item.banStatus, ""),
73
80
  updatedAt: toRelativeDate(item.bannedAt ?? item.updatedAt),
74
81
  _raw: item,
82
+ detail: item,
75
83
  })),
76
84
  getTotal: (response, rows) => typeof response.total === "number" ? response.total : rows.length,
77
85
  buildFilters: () => undefined,
@@ -111,6 +119,7 @@ function buildConfig(onAction) {
111
119
  };
112
120
  }
113
121
  export function AdminPaymentMethodsView(_props) {
122
+ const [detail, setDetail] = useState(null);
114
123
  // Independent useUrlTable() against the same URL param the listing reads
115
124
  // via filterKeys (Root Cause #35 pattern).
116
125
  const chipTable = useUrlTable({ defaults: {} });
@@ -132,6 +141,17 @@ export function AdminPaymentMethodsView(_props) {
132
141
  const handleAction = (id, action) => {
133
142
  actionMutation.mutate({ id, action });
134
143
  };
135
- const config = buildConfig(handleAction);
136
- return (_jsxs(Stack, { gap: "md", children: [_jsx(Div, { padding: "inline", border: "default", className: "border-b", children: _jsx(FilterChipGroup, { label: "Status", tabs: BAN_STATUS_TABS, value: banStatus, onChange: (v) => chipTable.set("banStatus", v) }) }), _jsx(DataListingView, { config: config })] }));
144
+ const config = buildConfig(handleAction, setDetail);
145
+ return (_jsxs(Stack, { gap: "md", children: [_jsx(RecordDetailModal, { isOpen: detail !== null, onClose: () => setDetail(null), title: toStringValue(detail?.detail?.displayLabel, "Payment method"), fields: (() => {
146
+ const d = (detail?.detail ?? {});
147
+ return detail ? [
148
+ { label: "Type", value: toStringValue(d.type, "—").toUpperCase() },
149
+ { label: "Label", value: toStringValue(d.displayLabel, "—") },
150
+ { label: "Owner", value: toStringValue(d.userId, "—") },
151
+ { label: "Ban status", value: toStringValue(d.banStatus, "Not banned") },
152
+ { label: "Ban reason", value: toStringValue(d.banReason, "—") },
153
+ { label: "Banned at", value: toRelativeDate(d.bannedAt) },
154
+ { label: "Method ID", value: detail?.id ?? "—" },
155
+ ] : undefined;
156
+ })() }), _jsx(Div, { padding: "inline", border: "default", className: "border-b", children: _jsx(FilterChipGroup, { label: "Status", tabs: BAN_STATUS_TABS, value: banStatus, onChange: (v) => chipTable.set("banStatus", v) }) }), _jsx(DataListingView, { config: config })] }));
137
157
  }
@@ -1,4 +1,5 @@
1
1
  import React from "react";
2
+ import { type ScammerStatus } from "../../scams/schemas/firestore";
2
3
  export interface AdminScammerEditorViewProps {
3
4
  open: boolean;
4
5
  onClose: () => void;
@@ -8,7 +9,7 @@ export interface AdminScammerEditorViewProps {
8
9
  description?: string;
9
10
  phones?: string[];
10
11
  upiIds?: string[];
11
- currentStatus?: string;
12
+ currentStatus?: ScammerStatus;
12
13
  verificationNote?: string;
13
14
  reportedBy?: string;
14
15
  reportedByAnon?: boolean;
@@ -6,23 +6,13 @@ import { useQueryClient } from "@tanstack/react-query";
6
6
  import { Button, Code, Div, FormActions, HorizontalRule, Label, Row, Select, SideDrawer, Span, Stack, Text, Textarea, useToast } from "../../../ui";
7
7
  import { apiClient } from "../../../http";
8
8
  import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints";
9
+ import { ScammerStatusValues, } from "../../scams/schemas/firestore";
10
+ import { SCAMMER_STATUS_BADGE, SCAMMER_STATUS_LABEL, SCAMMER_STATUS_OPTIONS, } from "../../scams/constants/scammer-status-display";
9
11
  const __P = {
10
12
  p3: "p-[var(--appkit-space-3)]",
11
13
  p4: "p-[var(--appkit-space-4)]",
12
14
  };
13
15
  const CLS_SECTION_LABEL = "text-[length:var(--appkit-text-xs)] font-medium text-[var(--appkit-color-text-muted)] uppercase tracking-wide";
14
- const STATUS_OPTIONS = [
15
- { label: "Pending review", value: "pending_review" },
16
- { label: "Verified", value: "verified" },
17
- { label: "Rejected", value: "rejected" },
18
- { label: "Removed", value: "removed" },
19
- ];
20
- const STATUS_COLOR = {
21
- pending_review: "bg-warning-surface text-warning",
22
- verified: "bg-success-surface text-success",
23
- rejected: "bg-error-surface text-error",
24
- removed: "bg-[var(--appkit-color-surface)] text-[var(--appkit-color-text-muted)] bg-[var(--appkit-color-surface-elevated)] text-[var(--appkit-color-text-muted)]",
25
- };
26
16
  export function AdminScammerEditorView({ open, onClose, scammerId, displayNames = [], scamType, description, phones = [], upiIds = [], currentStatus, verificationNote, reportedBy, reportedByAnon, }) {
27
17
  const queryClient = useQueryClient();
28
18
  const { showToast } = useToast();
@@ -63,5 +53,5 @@ export function AdminScammerEditorView({ open, onClose, scammerId, displayNames
63
53
  showToast(err?.message ?? "Failed to delete profile.", "error");
64
54
  },
65
55
  });
66
- return (_jsx(SideDrawer, { isOpen: open, onClose: onClose, title: displayNames.length > 0 ? displayNames[0] : "Scammer Profile", children: _jsxs(Stack, { className: `${__P.p4}`, gap: "md", children: [_jsxs(Row, { align: "center", gap: "sm", children: [_jsx(Span, { size: "xs", weight: "medium", className: `inline-flex ${STATUS_COLOR[currentStatus ?? "pending_review"] ?? STATUS_COLOR.pending_review}`, rounded: "full", padding: "pill-sm-tall", children: (currentStatus ?? "pending_review").replace(/_/g, " ") }), scamType && (_jsx(Span, { size: "xs", rounded: "full", padding: "pill-sm-tall", surface: "subtle", color: "muted", children: scamType.replace(/_/g, " ") }))] }), displayNames.length > 0 && (_jsxs(Div, { children: [_jsx(Text, { className: "mb-1 tracking-wide", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "Names / Aliases" }), _jsx(Row, { wrap: true, gap: "xs", children: displayNames.map((name, i) => (_jsx(Span, { size: "xs", rounded: "full", surface: "subtle", padding: "pill-sm", children: name }, i))) })] })), (phones.length > 0 || upiIds.length > 0) && (_jsxs(Div, { className: `${__P.p3}`, rounded: "lg", surface: "muted", border: "default", children: [phones.length > 0 && (_jsxs(Div, { className: "mb-2", children: [_jsx(Text, { className: CLS_SECTION_LABEL, children: "Phone numbers" }), _jsx(Row, { wrap: true, gap: "xs", children: phones.map((p, i) => (_jsx(Code, { size: "xs", rounded: "default", padding: "sm", surface: "subtle", children: p }, i))) })] })), upiIds.length > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { className: CLS_SECTION_LABEL, children: "UPI IDs" }), _jsx(Row, { wrap: true, gap: "xs", children: upiIds.map((u, i) => (_jsx(Code, { size: "xs", rounded: "default", padding: "sm", surface: "subtle", children: u }, i))) })] }))] })), description && (_jsxs(Div, { className: `${__P.p3}`, rounded: "lg", surface: "muted", border: "default", children: [_jsx(Text, { className: CLS_SECTION_LABEL, children: "Description" }), _jsx(Text, { className: "whitespace-pre-wrap", color: "primary", size: "sm", children: description })] })), _jsxs(Div, { textSize: "xs", color: "muted", children: ["Reported by:", " ", _jsx(Span, { weight: "medium", color: "muted", children: reportedByAnon ? "Anonymous" : (reportedBy ?? "Unknown") })] }), _jsx(HorizontalRule, { tone: "subtle", spacing: "comfortable" }), _jsx(Select, { label: "Status", options: STATUS_OPTIONS, value: status, onValueChange: setStatus }), _jsxs(Stack, { gap: "xs", children: [_jsx(Label, { className: "uppercase tracking-wide", color: "muted", size: "xs", weight: "semibold", children: "Verification note (internal)" }), _jsx(Textarea, { value: note, onChange: (e) => setNote(e.target.value), rows: 3, placeholder: "e.g. Verified via 3 independent reports\u2026" })] }), _jsxs(FormActions, { align: "right", children: [_jsx(Button, { type: "button", variant: "danger", size: "sm", disabled: !scammerId, isLoading: deleteMutation.isPending, onClick: () => deleteMutation.mutate(), children: "Delete" }), _jsx(Button, { type: "button", variant: "secondary", onClick: onClose, children: "Cancel" }), _jsx(Button, { type: "button", isLoading: updateMutation.isPending, disabled: !scammerId || updateMutation.isPending, onClick: () => updateMutation.mutate(), children: "Save changes" })] })] }) }));
56
+ return (_jsx(SideDrawer, { isOpen: open, onClose: onClose, title: displayNames.length > 0 ? displayNames[0] : "Scammer Profile", children: _jsxs(Stack, { className: `${__P.p4}`, gap: "md", children: [_jsxs(Row, { align: "center", gap: "sm", children: [_jsx(Span, { size: "xs", weight: "medium", className: `inline-flex ${SCAMMER_STATUS_BADGE[currentStatus ?? ScammerStatusValues.PENDING_REVIEW]}`, rounded: "full", padding: "pill-sm-tall", children: SCAMMER_STATUS_LABEL[currentStatus ?? ScammerStatusValues.PENDING_REVIEW] }), scamType && (_jsx(Span, { size: "xs", rounded: "full", padding: "pill-sm-tall", surface: "subtle", color: "muted", children: scamType.replace(/_/g, " ") }))] }), displayNames.length > 0 && (_jsxs(Div, { children: [_jsx(Text, { className: "mb-1 tracking-wide", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "Names / Aliases" }), _jsx(Row, { wrap: true, gap: "xs", children: displayNames.map((name, i) => (_jsx(Span, { size: "xs", rounded: "full", surface: "subtle", padding: "pill-sm", children: name }, i))) })] })), (phones.length > 0 || upiIds.length > 0) && (_jsxs(Div, { className: `${__P.p3}`, rounded: "lg", surface: "muted", border: "default", children: [phones.length > 0 && (_jsxs(Div, { className: "mb-2", children: [_jsx(Text, { className: CLS_SECTION_LABEL, children: "Phone numbers" }), _jsx(Row, { wrap: true, gap: "xs", children: phones.map((p, i) => (_jsx(Code, { size: "xs", rounded: "default", padding: "sm", surface: "subtle", children: p }, i))) })] })), upiIds.length > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { className: CLS_SECTION_LABEL, children: "UPI IDs" }), _jsx(Row, { wrap: true, gap: "xs", children: upiIds.map((u, i) => (_jsx(Code, { size: "xs", rounded: "default", padding: "sm", surface: "subtle", children: u }, i))) })] }))] })), description && (_jsxs(Div, { className: `${__P.p3}`, rounded: "lg", surface: "muted", border: "default", children: [_jsx(Text, { className: CLS_SECTION_LABEL, children: "Description" }), _jsx(Text, { className: "whitespace-pre-wrap", color: "primary", size: "sm", children: description })] })), _jsxs(Div, { textSize: "xs", color: "muted", children: ["Reported by:", " ", _jsx(Span, { weight: "medium", color: "muted", children: reportedByAnon ? "Anonymous" : (reportedBy ?? "Unknown") })] }), _jsx(HorizontalRule, { tone: "subtle", spacing: "comfortable" }), _jsx(Select, { label: "Status", options: SCAMMER_STATUS_OPTIONS, value: status, onValueChange: setStatus }), _jsxs(Stack, { gap: "xs", children: [_jsx(Label, { className: "uppercase tracking-wide", color: "muted", size: "xs", weight: "semibold", children: "Verification note (internal)" }), _jsx(Textarea, { value: note, onChange: (e) => setNote(e.target.value), rows: 3, placeholder: "e.g. Verified via 3 independent reports\u2026" })] }), _jsxs(FormActions, { align: "right", children: [_jsx(Button, { type: "button", variant: "danger", size: "sm", disabled: !scammerId, isLoading: deleteMutation.isPending, onClick: () => deleteMutation.mutate(), children: "Delete" }), _jsx(Button, { type: "button", variant: "secondary", onClick: onClose, children: "Cancel" }), _jsx(Button, { type: "button", isLoading: updateMutation.isPending, disabled: !scammerId || updateMutation.isPending, onClick: () => updateMutation.mutate(), children: "Save changes" })] })] }) }));
67
57
  }