@mohasinac/appkit 4.18.0 → 4.20.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 (29) hide show
  1. package/dist/_internal/client/features/lottery/LotteryAdminEditView.js +3 -1
  2. package/dist/_internal/client/features/lottery/LotteryPullForm.js +3 -1
  3. package/dist/_internal/shared/features/lottery/config-form.d.ts +58 -0
  4. package/dist/_internal/shared/features/lottery/config-form.js +71 -0
  5. package/dist/client.d.ts +3 -0
  6. package/dist/client.js +3 -0
  7. package/dist/features/account/schemas/index.d.ts +23 -0
  8. package/dist/features/account/schemas/index.js +21 -0
  9. package/dist/features/admin/components/AdminSiteSettingsView.js +8 -6
  10. package/dist/features/admin/components/AdminUserEditorView.js +14 -12
  11. package/dist/features/admin/schemas/admin-user-form.d.ts +82 -0
  12. package/dist/features/admin/schemas/admin-user-form.js +102 -0
  13. package/dist/features/admin/schemas/site-settings-form.d.ts +309 -0
  14. package/dist/features/admin/schemas/site-settings-form.js +159 -0
  15. package/dist/features/auctions/components/PlaceBidFormClient.js +3 -2
  16. package/dist/features/seller/components/SellerProductShell.js +107 -71
  17. package/dist/features/seller/components/SellerShippingView.js +21 -18
  18. package/dist/features/seller/components/SellerStorefrontView.js +21 -19
  19. package/dist/features/shell/FormShell.d.ts +4 -4
  20. package/dist/features/shell/FormShell.js +1 -1
  21. package/dist/features/shell/index.d.ts +0 -2
  22. package/dist/features/shell/index.js +0 -1
  23. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +24 -0
  24. package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.js +4 -2
  25. package/dist/index.d.ts +3 -2
  26. package/dist/index.js +5 -3
  27. package/dist/schemas/registry.d.ts +682 -0
  28. package/dist/schemas/registry.js +22 -0
  29. package/package.json +1 -1
@@ -0,0 +1,159 @@
1
+ /*
2
+ * WHY: `AdminSiteSettingsView` is 1826 lines with **21 `<Form>` tags and no
3
+ * schema on any of them**. All 21 are tabs over one document and save
4
+ * through a single `buildFullPayload()` → one PUT, so this is one form
5
+ * wearing twenty-one hats, entirely unvalidated.
6
+ *
7
+ * Live consequences already documented for this exact payload:
8
+ * · `announcementBar` was saved under the key `text` until 2026-08-24 —
9
+ * a key no renderer has ever read — so admin-authored announcements
10
+ * saved successfully and never appeared.
11
+ * · `adSettings`, `integrations`, `platformLimits`, `tagline` and
12
+ * `favicon` were written here while undeclared on
13
+ * `SiteSettingsDocument`, which is how an unmasked ad-credentials
14
+ * blob reached anonymous callers (Recurrent Root Cause #70).
15
+ *
16
+ * WHAT: A schema that validates the fields with real rules and **passes the
17
+ * rest through untouched**.
18
+ *
19
+ * ## 🛑 This one is `.passthrough()`, deliberately, and that is the whole point
20
+ *
21
+ * Everywhere else in this rework an update schema is `.strict()`. Here that
22
+ * would be actively dangerous. `buildFullPayload()` demonstrably writes keys
23
+ * the TypeScript interface does not declare, and a closed schema would SILENTLY
24
+ * STRIP every one of them on the next save — turning a validation improvement
25
+ * into the largest single act of data loss in the codebase. That is the
26
+ * `productBaseSchema` failure, aimed at the site's entire configuration.
27
+ *
28
+ * So: constrain what can be reasoned about (percentages that must be 0–100,
29
+ * money that must be non-negative, a contact email that must be an email), and
30
+ * let the rest pass. Tightening any group is a separate, evidence-led change —
31
+ * per the data-integrity protocol, only after a full-collection round-trip
32
+ * diff proves the schema is a superset of what is stored.
33
+ *
34
+ * EXPORTS: siteSettingsFormSchema, type SiteSettingsFormValues
35
+ *
36
+ * @tag domain:admin
37
+ * @tag layer:schema
38
+ * @tag pattern:none
39
+ * @tag access:isomorphic
40
+ * @tag consumers:AdminSiteSettingsView
41
+ * @tag sideEffects:none
42
+ */
43
+ import { z } from "zod";
44
+ /** A percentage a human types into a settings box. */
45
+ const percent = (label) => z.coerce
46
+ .number({ invalid_type_error: `${label} must be a number.` })
47
+ .min(0, `${label} cannot be negative.`)
48
+ .max(100, `${label} cannot exceed 100%.`)
49
+ .optional();
50
+ /** A non-negative rupee amount. */
51
+ const money = (label) => z.coerce
52
+ .number({ invalid_type_error: `${label} must be a number.` })
53
+ .min(0, `${label} cannot be negative.`)
54
+ .optional();
55
+ /** An optional email box — blank is allowed, malformed is not. */
56
+ const optionalEmail = z
57
+ .string()
58
+ .trim()
59
+ .toLowerCase()
60
+ .email("That doesn't look like an email address.")
61
+ .optional()
62
+ .or(z.literal(""));
63
+ /** An optional URL box — blank is allowed, malformed is not. */
64
+ const optionalUrl = z
65
+ .string()
66
+ .trim()
67
+ .url("Enter a full URL, including https://.")
68
+ .optional()
69
+ .or(z.literal(""));
70
+ export const siteSettingsFormSchema = z
71
+ .object({
72
+ /*
73
+ * 🛑 `message` is the canonical key. Naming it here is what stops the
74
+ * `text` mistake recurring: a payload carrying `text` still passes (the
75
+ * schema is open), but the one key a renderer reads is now declared, and
76
+ * the form is typed against it.
77
+ */
78
+ announcementBar: z
79
+ .object({
80
+ message: z.string().trim().max(300, "Keep the announcement under 300 characters.").optional(),
81
+ enabled: z.boolean().optional(),
82
+ link: optionalUrl,
83
+ })
84
+ .passthrough()
85
+ .optional(),
86
+ contact: z
87
+ .object({
88
+ email: optionalEmail,
89
+ supportEmail: optionalEmail,
90
+ phone: z.string().trim().max(40).optional(),
91
+ })
92
+ .passthrough()
93
+ .optional(),
94
+ /*
95
+ * The commission block. Every one of these is a percentage or an amount
96
+ * that multiplies against real orders, and all were free text — a stray
97
+ * keystroke in `platformFeePercent` changes what every seller is charged.
98
+ */
99
+ commissions: z
100
+ .object({
101
+ platformFeePercent: percent("Platform fee"),
102
+ gatewayFeePercent: percent("Gateway fee"),
103
+ platformFeeMax: money("Platform fee cap"),
104
+ minPayoutAmount: money("Minimum payout"),
105
+ payoutHoldDays: z.coerce.number().int().min(0).max(365).optional(),
106
+ codHandlingFee: money("COD handling fee"),
107
+ codDepositPercent: percent("COD deposit"),
108
+ })
109
+ .passthrough()
110
+ .optional(),
111
+ laborRate: z
112
+ .object({ hourlyRate: money("Hourly rate") })
113
+ .passthrough()
114
+ .optional(),
115
+ emi: z
116
+ .object({
117
+ surchargePercent: percent("EMI surcharge"),
118
+ surchargeSellerSharePercent: percent("Seller share"),
119
+ minOrderAmount: money("Minimum EMI order"),
120
+ })
121
+ .passthrough()
122
+ .optional(),
123
+ gst: z
124
+ .object({
125
+ // 15 characters, the statutory format. A wrong GSTIN appears on every
126
+ // invoice the platform issues.
127
+ gstin: z
128
+ .string()
129
+ .trim()
130
+ .toUpperCase()
131
+ .regex(/^\d{2}[A-Z]{5}\d{4}[A-Z]\d[A-Z\d][A-Z\d]$/, "That is not a valid 15-character GSTIN.")
132
+ .optional()
133
+ .or(z.literal("")),
134
+ ratePercent: percent("GST rate"),
135
+ })
136
+ .passthrough()
137
+ .optional(),
138
+ auctionConfig: z
139
+ .object({
140
+ minBidIncrement: money("Minimum bid increment"),
141
+ extensionMinutes: z.coerce.number().int().min(0).max(1440).optional(),
142
+ })
143
+ .passthrough()
144
+ .optional(),
145
+ seo: z
146
+ .object({
147
+ title: z.string().trim().max(70, "Search engines truncate titles past ~70 characters.").optional(),
148
+ description: z
149
+ .string()
150
+ .trim()
151
+ .max(160, "Search engines truncate descriptions past ~160 characters.")
152
+ .optional(),
153
+ })
154
+ .passthrough()
155
+ .optional(),
156
+ })
157
+ // See the header: OPEN on purpose. `buildFullPayload()` writes keys the
158
+ // document interface does not declare, and closing this would delete them.
159
+ .passthrough();
@@ -13,6 +13,7 @@ import { useLiveAuctionBid } from "../hooks/useLiveAuctionBid";
13
13
  import { AUCTION_BUYOUT_WINDOW_MINUTES } from "../../../_internal/shared/checkout/lanes";
14
14
  import { BID_PRESET_MULTIPLIERS, isBuyNowAvailable, resolveMinBid, resolveMinBidIncrement, } from "../../../_internal/shared/features/auctions/config";
15
15
  import { normalizeError } from "../../../errors/normalize";
16
+ import { FormErrorSummary } from "../../../ui/forms/FormErrorSummary";
16
17
  const __P = {
17
18
  p5: "p-[var(--appkit-space-5)]",
18
19
  };
@@ -179,10 +180,10 @@ export function PlaceBidFormClient({ productId, currentBid: ssrCurrentBid, start
179
180
  }
180
181
  });
181
182
  }
182
- return (_jsxs(Stack, { className: `${__P.p5}`, border: "subtle", gap: "md", rounded: "xl", surface: "muted", children: [_jsxs(Stack, { gap: "xs", children: [_jsxs(Row, { justify: "between", align: "center", children: [_jsx(Text, { size: "xs", color: "muted", children: hasBids ? "Current bid" : "Starting bid" }), _jsx(Text, { size: "xs", color: "muted", children: hasBids ? "Starting bid" : "Minimum bid" })] }), _jsxs(Row, { justify: "between", align: "baseline", children: [_jsx(Span, { size: "xl", weight: "bold", className: "text-primary-600 dark:text-primary-400", children: formatCurrency(currentBid, currency) }), _jsx(Span, { size: "sm", color: "muted", children: formatCurrency(hasBids ? startingBid : minBid, currency) })] }), _jsxs(Text, { size: "xs", color: "faint", children: [bidCount, " ", bidCount === 1 ? "bid" : "bids", " \u00B7 min increment", " ", formatCurrency(minBidIncrement, currency)] })] }), _jsx(LoginRequiredModal, { isOpen: showLoginModal, onClose: () => setShowLoginModal(false), message: "You need to be signed in to place a bid. Please log in or create an account to continue." }), _jsx(Form, { onSubmit: (e) => {
183
+ return (_jsxs(Stack, { className: `${__P.p5}`, border: "subtle", gap: "md", rounded: "xl", surface: "muted", children: [_jsxs(Stack, { gap: "xs", children: [_jsxs(Row, { justify: "between", align: "center", children: [_jsx(Text, { size: "xs", color: "muted", children: hasBids ? "Current bid" : "Starting bid" }), _jsx(Text, { size: "xs", color: "muted", children: hasBids ? "Starting bid" : "Minimum bid" })] }), _jsxs(Row, { justify: "between", align: "baseline", children: [_jsx(Span, { size: "xl", weight: "bold", className: "text-primary-600 dark:text-primary-400", children: formatCurrency(currentBid, currency) }), _jsx(Span, { size: "sm", color: "muted", children: formatCurrency(hasBids ? startingBid : minBid, currency) })] }), _jsxs(Text, { size: "xs", color: "faint", children: [bidCount, " ", bidCount === 1 ? "bid" : "bids", " \u00B7 min increment", " ", formatCurrency(minBidIncrement, currency)] })] }), _jsx(LoginRequiredModal, { isOpen: showLoginModal, onClose: () => setShowLoginModal(false), message: "You need to be signed in to place a bid. Please log in or create an account to continue." }), _jsx(Form, { schema: schema, onSubmit: (e) => {
183
184
  // handler wired through render-prop below — keeps setFieldError in scope
184
185
  e.preventDefault();
185
- }, children: ({ setFieldError, clearErrors }) => (_jsxs(Stack, { gap: "sm", children: [_jsxs(Row, { gap: "xs", wrap: true, role: "radiogroup", "aria-label": "Bid amount preset", children: [BID_PRESET_MULTIPLIERS.map((n) => {
186
+ }, children: ({ setFieldError, clearErrors }) => (_jsxs(Stack, { gap: "sm", children: [_jsx(FormErrorSummary, {}), _jsxs(Row, { gap: "xs", wrap: true, role: "radiogroup", "aria-label": "Bid amount preset", children: [BID_PRESET_MULTIPLIERS.map((n) => {
186
187
  const amount = presetAmount(n);
187
188
  const delta = amount - currentBid;
188
189
  // `delta` is 0 only for the opening preset on a bid-free
@@ -3,9 +3,9 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
4
  import { useRouter } from "next/navigation";
5
5
  import { z } from "zod";
6
- import { FormShell, StepForm, StepFormActions, useFormShell } from "../../shell";
6
+ import { FormShell, SectionForm, useSectionFormNav, useFormShell } from "../../shell";
7
7
  import { FormShellContext, useFormShellState, applyZodIssues, FormErrorSummary } from "../../../ui/forms";
8
- import { Alert, Button, Div, FormField, FormGroup, Heading, Section, Stack, TagInput, Text, Toggle, useToast } from "../../../ui";
8
+ import { Alert, Button, Div, FormField, FormGroup, Heading, Section, Stack, TagInput, Text, Toggle, useToast, Row } from "../../../ui";
9
9
  import { ImageUpload, MediaUploadField, MediaUploadList, useMediaUpload } from "../../media";
10
10
  import { StoreAddressSelectorCreate } from "../../stores/components/StoreAddressSelectorCreate";
11
11
  import { QuickProductForm } from "./QuickProductForm";
@@ -153,25 +153,65 @@ function StepLiveItemSettings({ values, onChange, }) {
153
153
  function StepPrintMetaSettings({ values, onChange, intro, }) {
154
154
  return (_jsxs(Stack, { gap: "md", children: [_jsx(Alert, { variant: "info", children: intro }), _jsxs(FormGroup, { columns: 2, children: [_jsx(FormField, { name: "printSize", label: "Size", type: "text", value: values.printSize ?? "", onChange: (v) => onChange({ printSize: v }), placeholder: "e.g. A4, 12x18 in" }), _jsx(FormField, { name: "printMaterial", label: "Material", type: "text", value: values.printMaterial ?? "", onChange: (v) => onChange({ printMaterial: v }), placeholder: "e.g. Matte photo paper, Vinyl" })] }), _jsxs(FormGroup, { columns: 2, children: [_jsx(FormField, { name: "printFinish", label: "Finish (optional)", type: "text", value: values.printFinish ?? "", onChange: (v) => onChange({ printFinish: v }), placeholder: "e.g. Glossy, Matte, Holographic" }), _jsx(FormField, { name: "printEditionSize", label: "Edition Size (optional)", type: "number", value: String(values.printEditionSize ?? ""), onChange: (v) => onChange({ printEditionSize: v ? Number(v) : undefined }), placeholder: "Leave blank for an open edition" })] })] }));
155
155
  }
156
- const TYPE_SPECIFIC_STEPS = {
156
+ // ── Type-specific step registry ──────────────────────────────────────────────
157
+ // Replaces the ad-hoc per-type ternary chain that used to pick the wizard step
158
+ // (create mode) / section (edit mode) — one Record lookup instead of a
159
+ // duplicated 5(+)-way chain in each mode.
160
+ /*
161
+ * Per-section schemas for the product form.
162
+ *
163
+ * These replace the `validate: (v) => "message" | null` callbacks each step
164
+ * used to carry. The rules are unchanged; what changes is where the failure
165
+ * surfaces. A step-level string could only ever render as one banner above the
166
+ * whole step — a Zod issue carries a PATH, so the message lands on the field
167
+ * that caused it and `<FormErrorSummary>` can offer a jump link to it.
168
+ *
169
+ * Kept beside the sections rather than folded into `sellerProductSchema`
170
+ * because they are per-LISTING-TYPE: a starting bid is required for an auction
171
+ * and meaningless for a sticker sheet, and a single whole-form schema would
172
+ * have to branch on type for every one of them.
173
+ */
174
+ const sectionSchemas = {
175
+ basic: z.object({
176
+ title: z.string().trim().min(3, "Title must be at least 3 characters"),
177
+ description: z.string().trim().min(20, "Description must be at least 20 characters"),
178
+ }).passthrough(),
179
+ media: z.object({
180
+ mainImage: z.string().min(1, "A main image is required"),
181
+ }).passthrough(),
182
+ auction: z.object({
183
+ startingBid: z.coerce.number({ invalid_type_error: "Starting bid is required" }).positive("Starting bid must be greater than zero"),
184
+ auctionEndDate: z.string().min(1, "Auction end date is required"),
185
+ }).passthrough(),
186
+ preorder: z.object({
187
+ preOrderDeliveryDate: z.string().min(1, "Estimated delivery date is required"),
188
+ }).passthrough(),
189
+ classified: z.object({
190
+ classifiedCity: z.string().trim().min(1, "City is required for classified listings"),
191
+ }).passthrough(),
192
+ live: z.object({
193
+ liveSpecies: z.string().trim().min(1, "Species name is required"),
194
+ }).passthrough(),
195
+ };
196
+ const TYPE_SPECIFIC_SECTIONS = {
157
197
  auction: {
158
198
  id: "auction",
159
199
  label: "Auction Settings",
160
200
  render: ({ values, onChange }) => (_jsx(StepAuctionSettings, { values: values, onChange: onChange })),
161
- validate: (v) => (!v.startingBid ? "Starting bid is required" : !v.auctionEndDate ? "Auction end date is required" : null),
201
+ schema: sectionSchemas.auction,
162
202
  },
163
203
  "pre-order": {
164
204
  id: "preorder",
165
205
  label: "Pre-Order",
166
206
  sectionHeading: "Pre-Order Settings",
167
207
  render: ({ values, onChange }) => (_jsx(StepPreOrderSettings, { values: values, onChange: onChange })),
168
- validate: (v) => (!v.preOrderDeliveryDate ? "Estimated delivery date is required" : null),
208
+ schema: sectionSchemas.preorder,
169
209
  },
170
210
  classified: {
171
211
  id: "classified",
172
212
  label: "Meetup Details",
173
213
  render: ({ values, onChange }) => (_jsx(StepClassifiedSettings, { values: values, onChange: onChange })),
174
- validate: (v) => (!v.classifiedCity?.trim() ? "City is required for classified listings" : null),
214
+ schema: sectionSchemas.classified,
175
215
  },
176
216
  "digital-code": {
177
217
  id: "digitalcode",
@@ -182,7 +222,7 @@ const TYPE_SPECIFIC_STEPS = {
182
222
  id: "live",
183
223
  label: "Live Item Details",
184
224
  render: ({ values, onChange }) => (_jsx(StepLiveItemSettings, { values: values, onChange: onChange })),
185
- validate: (v) => (!v.liveSpecies?.trim() ? "Species name is required" : null),
225
+ schema: sectionSchemas.live,
186
226
  },
187
227
  art: {
188
228
  id: "printmeta",
@@ -241,13 +281,18 @@ const EDIT_SECTIONS = [
241
281
  export function SellerProductShell({ mode, listingType = "standard", initialValues, onSave, onPublish, onDiscard, isLoading = false, storeSlug, renderCategorySelector, renderBrandSelector, renderAddressSelector, renderTemplateSelector, onSaveAsTemplate, previewSlot, }) {
242
282
  const [draft, setDraft] = useState(initialValues ?? { status: "draft", condition: "new" });
243
283
  const [formMode, setFormMode] = useState(mode === "create" && listingType === "standard" ? "quick" : "full");
244
- const [currentStep, setCurrentStep] = useState(0);
284
+ /*
285
+ * Declared here, ABOVE the sections array, because section render callbacks
286
+ * close over `setFieldError`/`clearErrors`. The nav seam cannot be supplied
287
+ * yet — it needs the sections — so it is composed in below. That ordering is
288
+ * why the original `wizardShellCtx` existed; only its contents change.
289
+ */
290
+ const { shellCtx: baseShellCtx, setFieldError, clearErrors, validate } = useFormShellState(sellerProductSchema);
245
291
  const [stepError, setStepError] = useState(null);
246
292
  const { isDirty, markDirty, markClean } = useFormShell();
247
293
  const router = useRouter();
248
294
  const { showToast } = useToast();
249
295
  const { upload: shellUpload } = useMediaUpload();
250
- const { shellCtx, setFieldError, clearErrors, validate } = useFormShellState(sellerProductSchema);
251
296
  const update = useCallback((partial) => {
252
297
  setDraft((prev) => {
253
298
  const next = { ...prev, ...partial };
@@ -346,63 +391,73 @@ export function SellerProductShell({ mode, listingType = "standard", initialValu
346
391
  }
347
392
  }, [draft, onPublish, markClean, clearErrors, setFieldError, showToast]);
348
393
  const listingTypeLabel = pluginForMode(listingType).typeLabel;
349
- const typeSpecificStep = TYPE_SPECIFIC_STEPS[listingType] ?? null;
350
- const steps = [
394
+ const typeSpecificSection = TYPE_SPECIFIC_SECTIONS[listingType] ?? null;
395
+ const sections = [
351
396
  {
397
+ id: "basic",
352
398
  label: "Basic",
399
+ required: true,
400
+ quick: true,
353
401
  fields: ["title", "description", "category", "brand", "condition", "tags", "barcodeId"],
354
402
  render: ({ values, onChange }) => (_jsx(StepBasic, { values: values, onChange: onChange, renderCategorySelector: renderCategorySelector, renderBrandSelector: renderBrandSelector, renderTemplateSelector: renderTemplateSelector })),
355
- validate: (v) => {
356
- if (!v.title?.trim() || v.title.trim().length < 3)
357
- return "Title must be at least 3 characters";
358
- if (!v.description?.trim() || v.description.trim().length < 20)
359
- return "Description must be at least 20 characters";
360
- return null;
361
- },
403
+ schema: sectionSchemas.basic,
362
404
  },
363
405
  {
406
+ id: "media",
364
407
  label: "Media",
408
+ keepMounted: true,
365
409
  fields: ["mainImage", "images", "video", "video.url", "video.duration", "video.thumbnailUrl", "youtubeId", "externalVideoUrl"],
366
410
  render: ({ values, onChange }) => (_jsx(StepMedia, { values: values, onChange: onChange, storeSlug: storeSlug })),
367
- validate: (v) => (!v.mainImage ? "A main image is required" : null),
411
+ schema: sectionSchemas.media,
368
412
  },
369
- ...(typeSpecificStep ? [typeSpecificStep] : []),
413
+ ...(typeSpecificSection ? [typeSpecificSection] : []),
370
414
  {
415
+ id: "pricing",
371
416
  label: "Pricing",
417
+ required: true,
418
+ quick: true,
372
419
  fields: ["price", "compareAtPrice", "stockQuantity", "allowOffers", "minOfferPercent", "gstRate", "hsnCode"],
373
420
  render: ({ values, onChange }) => (_jsx(StepPricing, { values: values, onChange: onChange, listingType: listingType })),
374
- validate: (v) => {
375
- if (!v.price)
376
- return "Price is required";
377
- if (pluginForMode(listingType).showsStockQuantity &&
378
- (v.stockQuantity === undefined || v.stockQuantity === null)) {
379
- return "Stock quantity is required";
380
- }
381
- return null;
382
- },
421
+ // Built per render because whether stock is required depends on the
422
+ // listing type's own plugin — a digital code has no stock to speak of.
423
+ schema: z
424
+ .object({
425
+ price: z.coerce
426
+ .number({ invalid_type_error: "Price is required" })
427
+ .positive("Price is required"),
428
+ stockQuantity: pluginForMode(listingType).showsStockQuantity
429
+ ? z.coerce.number({ invalid_type_error: "Stock quantity is required" }).int().min(0)
430
+ : z.coerce.number().int().min(0).optional(),
431
+ })
432
+ .passthrough(),
383
433
  },
384
434
  {
435
+ id: "shipping",
385
436
  label: "Shipping",
386
437
  fields: ["shippingPaidBy", "pickupAddressId", "insurance", "insuranceCost"],
387
438
  render: ({ values, onChange }) => (_jsx(StepShipping, { values: values, onChange: onChange, renderAddressSelector: renderAddressSelector })),
388
439
  },
389
440
  {
441
+ id: "publish",
390
442
  label: "Publish",
391
443
  fields: ["seoTitle", "seoDescription", "isOnSale", "status"],
392
444
  render: ({ values, onChange }) => (_jsx(StepPublish, { values: values, onChange: onChange })),
393
445
  },
394
446
  ];
395
- // Field-name → owning-step-index map for FormErrorSummary's step
396
- // attribution/jump-to-step links (full-wizard create mode only — quick
397
- // and edit modes don't have step navigation).
398
- const fieldToStepIndex = useMemo(() => {
399
- const map = {};
400
- steps.forEach((step, i) => {
401
- step.fields?.forEach((field) => { map[field] = i; });
402
- });
403
- return map;
404
- // eslint-disable-next-line react-hooks/exhaustive-deps
405
- }, [steps.length, typeSpecificStep]);
447
+ /*
448
+ * One nav for BOTH modes. Create used to be a wizard with its own
449
+ * fieldToStepIndex map and a separately-composed wizardShellCtx, while edit
450
+ * rendered the same sections as a single page — two layouts over one set of
451
+ * content, kept in step by hand.
452
+ */
453
+ const { openIds, setOpenIds, goToSection, fieldToSectionIndex, sectionMeta } = useSectionFormNav(sections, draft);
454
+ /** The one context both modes render through. */
455
+ const shellCtx = useMemo(() => ({
456
+ ...baseShellCtx,
457
+ sections: sectionMeta,
458
+ onGoToSection: goToSection,
459
+ fieldToSectionIndex,
460
+ }), [baseShellCtx, sectionMeta, goToSection, fieldToSectionIndex]);
406
461
  // Live validation — re-run on every draft change, not just on submit, so
407
462
  // FormErrorSummary and inline field errors both stay current as the
408
463
  // seller types (per the "even on-change errors must be shown" requirement).
@@ -410,34 +465,15 @@ export function SellerProductShell({ mode, listingType = "standard", initialValu
410
465
  validate(draft);
411
466
  // eslint-disable-next-line react-hooks/exhaustive-deps
412
467
  }, [draft, validate]);
413
- const wizardShellCtx = useMemo(() => ({ ...shellCtx, fieldToStepIndex, goToStep: (n) => setCurrentStep(n) }), [shellCtx, fieldToStepIndex, setCurrentStep]);
414
- const handleNext = useCallback(async () => {
415
- const step = steps[currentStep];
416
- if (step?.validate) {
417
- const err = step.validate(draft);
418
- if (err) {
419
- setStepError(err);
420
- return;
421
- }
422
- }
423
- setStepError(null);
424
- if (currentStep < steps.length - 1) {
425
- setCurrentStep((c) => c + 1);
426
- }
427
- else {
428
- try {
429
- await handlePublish();
430
- }
431
- catch (err) {
432
- void normalizeError(err);
433
- showToast(err instanceof Error ? err.message : "Something went wrong.", "error");
434
- }
435
- }
436
- }, [currentStep, steps, draft, handlePublish, showToast]);
437
- // Step error badges — run each step's validate against current draft
438
- const stepValidationErrors = useMemo(() => steps.map((s) => (s.validate ? Boolean(s.validate(draft)) : false)),
439
- // eslint-disable-next-line react-hooks/exhaustive-deps
440
- [draft]);
468
+ /*
469
+ * `handleNext` and `stepValidationErrors` are gone with the wizard.
470
+ *
471
+ * The next-gate was the thing SectionForm exists to remove: it ran the
472
+ * current step's `validate` and REFUSED to advance, so a seller who could
473
+ * not satisfy step 2 could not reach step 5 to see what else was needed.
474
+ * Every section is now reachable at any time and the shared submit is what
475
+ * checks the whole draft.
476
+ */
441
477
  const breadcrumb = mode === "create" ? `Store / ${listingTypeLabel}s / New` : `Store / ${listingTypeLabel}s / Edit`;
442
478
  const title = mode === "create"
443
479
  ? `New ${listingTypeLabel}`
@@ -452,12 +488,12 @@ export function SellerProductShell({ mode, listingType = "standard", initialValu
452
488
  }) }) }) }));
453
489
  }
454
490
  if (mode === "create") {
455
- return (_jsx(FormShell, { isOpen: true, onClose: handleDiscard, title: title, breadcrumb: breadcrumb, isDirty: isDirty, isLoading: isLoading, previewSlot: previewSlot, splitPreview: !!previewSlot, renderBottomBar: () => (_jsxs(Div, { className: "flex-shrink-0 border-t border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)]", children: [_jsx(StepFormActions, { currentStep: currentStep, totalSteps: steps.length, onNext: () => void handleNext(), onPrev: currentStep > 0 ? () => setCurrentStep((c) => c - 1) : undefined, completeLabel: `Publish ${listingTypeLabel}`, isLoading: isLoading && currentStep === steps.length - 1, disabled: isLoading }), currentStep === 0 && (_jsx(Div, { paddingX: "x-5", padding: "b-sm", children: _jsx(Button, { variant: "ghost", size: "sm", onClick: () => void handleSave(), disabled: isLoading || !draft.title?.trim(), children: "Save as draft & finish later" }) })), stepError && (_jsx(Text, { className: "text-[var(--appkit-color-error)] px-[1.25rem] pb-[0.75rem]", size: "sm", children: stepError })), _jsx(FormShellContext.Provider, { value: wizardShellCtx, children: _jsx(Div, { paddingX: "x-5", padding: "b-sm", children: _jsx(FormErrorSummary, {}) }) })] })), children: _jsx(FormShellContext.Provider, { value: wizardShellCtx, children: _jsx(StepForm, { steps: steps, values: draft, onChange: update, onComplete: handlePublish, completeLabel: `Publish ${listingTypeLabel}`, currentStep: currentStep, onStepChange: setCurrentStep, isLoading: isLoading, hideActions: true, stepErrors: stepValidationErrors }) }) }));
491
+ return (_jsx(FormShell, { isOpen: true, onClose: handleDiscard, title: title, breadcrumb: breadcrumb, isDirty: isDirty, isLoading: isLoading, previewSlot: previewSlot, splitPreview: !!previewSlot, renderBottomBar: () => (_jsxs(Div, { className: "flex-shrink-0 border-t border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)]", children: [_jsx(FormShellContext.Provider, { value: shellCtx, children: _jsx(Div, { paddingX: "x-5", padding: "y-sm", children: _jsx(FormErrorSummary, {}) }) }), _jsx(Div, { paddingX: "x-5", padding: "b-sm", children: _jsxs(Row, { gap: "sm", justify: "between", wrap: true, children: [_jsx(Button, { variant: "ghost", size: "sm", onClick: () => void handleSave(), disabled: isLoading || !draft.title?.trim(), children: "Save as draft & finish later" }), _jsx(Button, { variant: "primary", onClick: () => void handlePublish(), isLoading: isLoading, disabled: isLoading, children: `Publish ${listingTypeLabel}` })] }) }), stepError && (_jsx(Text, { className: "text-[var(--appkit-color-error)] px-[1.25rem] pb-[0.75rem]", size: "sm", children: stepError }))] })), children: _jsx(FormShellContext.Provider, { value: shellCtx, children: _jsx(SectionForm, { sections: sections, values: draft, onChange: update, onSubmit: handlePublish, schema: sellerProductSchema, openIds: openIds, onOpenChange: setOpenIds, isLoading: isLoading, hideActions: true }) }) }));
456
492
  }
457
493
  // Edit mode — FormShell with section nav + full form
458
494
  const editSections = [
459
495
  ...EDIT_SECTIONS,
460
- ...(typeSpecificStep ? [{ id: typeSpecificStep.id, label: typeSpecificStep.label }] : []),
496
+ ...(typeSpecificSection ? [{ id: typeSpecificSection.id, label: typeSpecificSection.label }] : []),
461
497
  ];
462
- return (_jsx(FormShell, { isOpen: true, onClose: handleDiscard, title: title, breadcrumb: breadcrumb, isDirty: isDirty, isLoading: isLoading, sections: editSections, onSaveDraft: handleSave, onPublish: handlePublish, saveLabel: "Save Changes", publishLabel: "Update", previewSlot: previewSlot, footerTopSlot: _jsx(FormShellContext.Provider, { value: shellCtx, children: _jsx(FormErrorSummary, {}) }), children: _jsx(FormShellContext.Provider, { value: shellCtx, children: _jsxs(Stack, { gap: "lg", children: [_jsxs(Section, { id: "basic", children: [_jsx(Heading, { level: 3, className: "mb-4", children: "Basic Info" }), _jsx(StepBasic, { values: draft, onChange: update, renderCategorySelector: renderCategorySelector, renderBrandSelector: renderBrandSelector, renderTemplateSelector: renderTemplateSelector })] }), _jsxs(Section, { id: "media", children: [_jsx(Heading, { level: 3, className: "mb-4", children: "Media" }), _jsx(StepMedia, { values: draft, onChange: update, storeSlug: storeSlug })] }), typeSpecificStep && (_jsxs(Section, { id: typeSpecificStep.id, children: [_jsx(Heading, { level: 3, className: "mb-4", children: typeSpecificStep.sectionHeading ?? typeSpecificStep.label }), typeSpecificStep.render({ values: draft, onChange: update, errors: {} })] })), _jsxs(Section, { id: "pricing", children: [_jsx(Heading, { level: 3, className: "mb-4", children: "Pricing" }), _jsx(StepPricing, { values: draft, onChange: update, listingType: listingType })] }), _jsxs(Section, { id: "shipping", children: [_jsx(Heading, { level: 3, className: "mb-4", children: "Shipping" }), _jsx(StepShipping, { values: draft, onChange: update, renderAddressSelector: renderAddressSelector })] }), _jsxs(Section, { id: "publish", children: [_jsx(Heading, { level: 3, className: "mb-4", children: "Publish" }), _jsx(StepPublish, { values: draft, onChange: update }), onSaveAsTemplate && (_jsxs(Div, { className: "mt-4 border-t border-[var(--appkit-color-border)]", padding: "t-md", children: [_jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: () => onSaveAsTemplate(draft), children: "Save as Template" }), _jsx(Text, { className: "mt-1 text-[var(--appkit-color-secondary-text)]", size: "xs", children: "Save these settings as a reusable template for future listings." })] }))] })] }) }) }));
498
+ return (_jsx(FormShell, { isOpen: true, onClose: handleDiscard, title: title, breadcrumb: breadcrumb, isDirty: isDirty, isLoading: isLoading, sections: editSections, onSaveDraft: handleSave, onPublish: handlePublish, saveLabel: "Save Changes", publishLabel: "Update", previewSlot: previewSlot, footerTopSlot: _jsx(FormShellContext.Provider, { value: shellCtx, children: _jsx(FormErrorSummary, {}) }), children: _jsx(FormShellContext.Provider, { value: shellCtx, children: _jsxs(Stack, { gap: "lg", children: [_jsxs(Section, { id: "basic", children: [_jsx(Heading, { level: 3, className: "mb-4", children: "Basic Info" }), _jsx(StepBasic, { values: draft, onChange: update, renderCategorySelector: renderCategorySelector, renderBrandSelector: renderBrandSelector, renderTemplateSelector: renderTemplateSelector })] }), _jsxs(Section, { id: "media", children: [_jsx(Heading, { level: 3, className: "mb-4", children: "Media" }), _jsx(StepMedia, { values: draft, onChange: update, storeSlug: storeSlug })] }), typeSpecificSection && (_jsxs(Section, { id: typeSpecificSection.id, children: [_jsx(Heading, { level: 3, className: "mb-4", children: typeSpecificSection.sectionHeading ?? typeSpecificSection.label }), typeSpecificSection.render({ values: draft, onChange: update, errors: {} })] })), _jsxs(Section, { id: "pricing", children: [_jsx(Heading, { level: 3, className: "mb-4", children: "Pricing" }), _jsx(StepPricing, { values: draft, onChange: update, listingType: listingType })] }), _jsxs(Section, { id: "shipping", children: [_jsx(Heading, { level: 3, className: "mb-4", children: "Shipping" }), _jsx(StepShipping, { values: draft, onChange: update, renderAddressSelector: renderAddressSelector })] }), _jsxs(Section, { id: "publish", children: [_jsx(Heading, { level: 3, className: "mb-4", children: "Publish" }), _jsx(StepPublish, { values: draft, onChange: update }), onSaveAsTemplate && (_jsxs(Div, { className: "mt-4 border-t border-[var(--appkit-color-border)]", padding: "t-md", children: [_jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: () => onSaveAsTemplate(draft), children: "Save as Template" }), _jsx(Text, { className: "mt-1 text-[var(--appkit-color-secondary-text)]", size: "xs", children: "Save these settings as a reusable template for future listings." })] }))] })] }) }) }));
463
499
  }
@@ -1,12 +1,12 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useCallback, useEffect, useMemo, useState } from "react";
3
+ import { useCallback, useEffect, useState } from "react";
4
4
  import { z } from "zod";
5
5
  import { Alert, Badge, Div, FormField, Heading, Row, Stack, Text } from "../../../ui";
6
6
  import { StackedViewShell } from "../../../ui";
7
7
  import { FormShellContext, useFormShellState, applyZodIssues, FormErrorSummary } from "../../../ui/forms";
8
8
  import { StoreAddressSelectorCreate } from "../../stores/components/StoreAddressSelectorCreate";
9
- import { StepForm } from "../../shell";
9
+ import { SectionForm, useSectionFormNav } from "../../shell";
10
10
  import { SELLER_ENDPOINTS } from "../../../constants/api-endpoints";
11
11
  import { normalizeError } from "../../../errors/normalize";
12
12
  const shippingDraftSchema = z.object({
@@ -35,12 +35,6 @@ export function SellerShippingView({ apiBase = SELLER_ENDPOINTS.SHIPPING }) {
35
35
  const [saving, setSaving] = useState(false);
36
36
  const [error, setError] = useState(null);
37
37
  const [success, setSuccess] = useState(false);
38
- const [currentStep, setCurrentStep] = useState(0);
39
- const { shellCtx, setFieldError, clearErrors, validate } = useFormShellState(shippingDraftSchema);
40
- useEffect(() => {
41
- validate(draft);
42
- // eslint-disable-next-line react-hooks/exhaustive-deps
43
- }, [draft, validate]);
44
38
  useEffect(() => {
45
39
  fetch(apiBase)
46
40
  .then((r) => r.json())
@@ -105,33 +99,42 @@ export function SellerShippingView({ apiBase = SELLER_ENDPOINTS.SHIPPING }) {
105
99
  }
106
100
  };
107
101
  const busy = loading || saving;
108
- const steps = [
102
+ const sections = [
109
103
  {
104
+ id: "carrier",
110
105
  label: "Carrier",
106
+ required: true,
107
+ quick: true,
111
108
  fields: ["customCarrierName", "customShippingPrice"],
112
109
  render: ({ values, onChange }) => (_jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Shipping Carrier" }), current && (_jsx(Row, { className: "mb-2", align: "center", gap: "sm", children: _jsx(Badge, { variant: current.isConfigured ? "success" : "warning", children: current.isConfigured ? "Configured" : "Not configured" }) })), _jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "sm", children: "Set a fixed shipping fee and carrier name for all orders. You enter the carrier and tracking number yourself when you ship each order." }), _jsxs(Stack, { gap: "md", className: "mt-2", children: [_jsx(FormField, { name: "customCarrierName", label: "Carrier Name", type: "text", value: values.customCarrierName, onChange: (v) => onChange({ customCarrierName: v }), placeholder: "e.g. India Post, DTDC, Delhivery", disabled: busy }), _jsx(FormField, { name: "customShippingPrice", label: "Shipping Price (\u20B9)", type: "number", value: values.customShippingPrice, onChange: (v) => onChange({ customShippingPrice: v }), placeholder: "0 for free shipping", helpText: "Charged to buyer at checkout. Enter 0 for free shipping.", disabled: busy })] })] })),
113
110
  },
114
111
  {
112
+ id: "pickup-address",
115
113
  label: "Pickup Address",
116
114
  fields: ["pickupAddressId"],
117
115
  render: ({ values, onChange }) => (_jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Pickup Address" }), current?.pickupAddress && (_jsxs(Alert, { variant: "info", children: ["Current pickup: ", current.pickupAddress.locationName ?? "", current.pickupAddress.city ? `, ${current.pickupAddress.city}` : ""] })), _jsx(StoreAddressSelectorCreate, { value: values.pickupAddressId, onChange: (id) => onChange({ pickupAddressId: id }), label: "Pickup Address", disabled: busy })] })),
118
116
  },
119
117
  {
118
+ id: "rules",
120
119
  label: "Rules",
121
120
  fields: ["freeShippingThreshold", "fragileSurcharge"],
122
121
  render: ({ values, onChange }) => (_jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Shipping Rules" }), _jsx(FormField, { name: "freeShippingThreshold", label: "Free Shipping Threshold (\u20B9)", type: "number", value: values.freeShippingThreshold, onChange: (v) => onChange({ freeShippingThreshold: v }), placeholder: "e.g. 500 \u2014 orders above this get free shipping", helpText: "Leave blank or 0 to disable free shipping offers.", disabled: busy }), _jsx(FormField, { name: "fragileSurcharge", label: "Fragile Item Surcharge (\u20B9)", type: "number", value: values.fragileSurcharge, onChange: (v) => onChange({ fragileSurcharge: v }), placeholder: "e.g. 50 \u2014 added for items marked fragile", helpText: "Leave blank or 0 to disable the fragile surcharge.", disabled: busy })] })),
123
122
  },
124
123
  ];
125
- const fieldToStepIndex = useMemo(() => {
126
- const map = {};
127
- steps.forEach((step, i) => {
128
- step.fields?.forEach((field) => { map[field] = i; });
129
- });
130
- return map;
124
+ /*
125
+ * `useSectionFormNav` replaces the hand-rolled fieldToStepIndex map and the
126
+ * manually-composed wizardShellCtx. The old shape had to be kept in sync by
127
+ * hand — and `useMemo(..., [steps.length])` meant a step whose FIELDS
128
+ * changed without changing the COUNT never recomputed its map, so
129
+ * FormErrorSummary could jump to the wrong step.
130
+ */
131
+ const { openIds, setOpenIds, goToSection, fieldToSectionIndex, sectionMeta } = useSectionFormNav(sections, draft);
132
+ const { shellCtx, setFieldError, clearErrors, validate } = useFormShellState(shippingDraftSchema, { sections: sectionMeta, onGoToSection: goToSection, fieldToSectionIndex });
133
+ useEffect(() => {
134
+ validate(draft);
131
135
  // eslint-disable-next-line react-hooks/exhaustive-deps
132
- }, [steps.length]);
133
- const wizardShellCtx = useMemo(() => ({ ...shellCtx, fieldToStepIndex, goToStep: (n) => setCurrentStep(n) }), [shellCtx, fieldToStepIndex, setCurrentStep]);
136
+ }, [draft, validate]);
134
137
  return (_jsx(StackedViewShell, { portal: "seller", title: "Shipping Configuration", sections: [
135
- _jsxs(Div, { children: [error && _jsx(Alert, { variant: "error", className: "mb-4", children: error }), success && _jsx(Alert, { variant: "success", className: "mb-4", children: "Shipping configuration saved." }), _jsxs(FormShellContext.Provider, { value: wizardShellCtx, children: [_jsx(FormErrorSummary, {}), _jsx(StepForm, { steps: steps, values: draft, onChange: update, onComplete: handleSave, formId: "seller-shipping", currentStep: currentStep, onStepChange: setCurrentStep, completeLabel: "Save Configuration", isLoading: busy })] })] }, "shipping"),
138
+ _jsxs(Div, { children: [error && _jsx(Alert, { variant: "error", className: "mb-4", children: error }), success && _jsx(Alert, { variant: "success", className: "mb-4", children: "Shipping configuration saved." }), _jsxs(FormShellContext.Provider, { value: shellCtx, children: [_jsx(FormErrorSummary, {}), _jsx(SectionForm, { sections: sections, values: draft, onChange: update, onSubmit: handleSave, schema: shippingDraftSchema, openIds: openIds, onOpenChange: setOpenIds, submitLabel: "Save Configuration", isLoading: busy })] })] }, "shipping"),
136
139
  ] }));
137
140
  }