@mohasinac/appkit 4.31.0 → 4.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/_internal/server/features/products/list-public.d.ts +18 -0
  2. package/dist/_internal/server/features/products/list-public.js +27 -9
  3. package/dist/client.d.ts +9 -0
  4. package/dist/client.js +12 -0
  5. package/dist/features/admin/components/AdminAdsView.js +36 -34
  6. package/dist/features/admin/components/AdminCarouselEditorView.js +78 -10
  7. package/dist/features/admin/components/AdminOrderEditorView.js +55 -24
  8. package/dist/features/admin/components/AdminProductEditorView.js +78 -18
  9. package/dist/features/admin/components/AdminSectionsView.js +89 -32
  10. package/dist/features/admin/components/AdminSiteSettingsView.js +96 -44
  11. package/dist/features/admin/components/AdminSupportTicketDetailView.js +58 -43
  12. package/dist/features/admin/components/AdminUserEditorView.js +65 -82
  13. package/dist/features/admin/constants/admin-page-tabs.d.ts +36 -0
  14. package/dist/features/admin/constants/admin-page-tabs.js +45 -0
  15. package/dist/features/admin/schemas/admin-ops-forms.d.ts +81 -0
  16. package/dist/features/admin/schemas/admin-ops-forms.js +123 -1
  17. package/dist/features/admin/schemas/admin-user-form.d.ts +78 -3
  18. package/dist/features/admin/schemas/admin-user-form.js +96 -7
  19. package/dist/features/admin/schemas/homepage-section-form.d.ts +9 -0
  20. package/dist/features/layout/PageTabs.d.ts +17 -0
  21. package/dist/features/layout/PageTabs.js +45 -0
  22. package/dist/features/products/components/GroupSettingsPanel.js +62 -16
  23. package/dist/features/search/action-index/tab-entries.d.ts +18 -0
  24. package/dist/features/search/action-index/tab-entries.js +128 -0
  25. package/dist/features/seller/components/SellerGoogleReviewsView.js +31 -19
  26. package/dist/features/seller/components/SellerOrdersView.js +57 -19
  27. package/dist/features/seller/components/SellerReviewsView.d.ts +2 -1
  28. package/dist/features/seller/components/SellerReviewsView.js +54 -19
  29. package/dist/features/seller/constants/store-tabs.d.ts +64 -0
  30. package/dist/features/seller/constants/store-tabs.js +60 -0
  31. package/dist/features/seller/schemas/order-forms.d.ts +33 -0
  32. package/dist/features/seller/schemas/order-forms.js +75 -0
  33. package/dist/features/seller/schemas/review-forms.d.ts +37 -0
  34. package/dist/features/seller/schemas/review-forms.js +82 -0
  35. package/dist/features/shell/index.d.ts +1 -1
  36. package/dist/features/shell/index.js +5 -1
  37. package/dist/features/shipments/components/AdminShipmentLotItemsView.js +44 -15
  38. package/dist/features/shipments/schemas/validation.d.ts +41 -0
  39. package/dist/features/shipments/schemas/validation.js +49 -0
  40. package/dist/features/store-extensions/schemas/google-config-form.d.ts +36 -0
  41. package/dist/features/store-extensions/schemas/google-config-form.js +24 -0
  42. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +67 -0
  43. package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.js +53 -22
  44. package/dist/index.d.ts +4 -0
  45. package/dist/index.js +2 -0
  46. package/dist/next/index.d.ts +8 -0
  47. package/dist/next/index.js +37 -0
  48. package/dist/react/hooks/useTabParam.d.ts +19 -0
  49. package/dist/react/hooks/useTabParam.js +65 -0
  50. package/dist/schemas/registry.d.ts +27 -3
  51. package/dist/styles.css +1 -1
  52. package/dist/tailwind-utilities.css +1 -1
  53. package/package.json +1 -1
@@ -40,7 +40,11 @@ export const adminOrderUpdateSchema = z.object({
40
40
  section: "shipping", sectionLabel: "Shipping", order: 1, row: "pair",
41
41
  }),
42
42
  carrier: annotate(z.string().trim().max(80).optional(), {
43
- section: "shipping", order: 2, row: "pair",
43
+ // `kind: "select"` is explicit because the field is a free string in the
44
+ // schema — the route accepts any carrier name — while the ADMIN form offers
45
+ // a fixed list. Without it the inferred control is a text input and the
46
+ // `options` passed by the view would be ignored.
47
+ section: "shipping", order: 2, row: "pair", kind: "select",
44
48
  }),
45
49
  /*
46
50
  * 🛑 Bounded. This was `parseFloat` + `> 0` with no ceiling, so a slipped
@@ -229,3 +233,121 @@ export const adminAdFormSchema = z
229
233
  }
230
234
  }
231
235
  });
236
+ /**
237
+ * Ad SETTINGS — the provider credentials and the global consent switch.
238
+ *
239
+ * Distinct from `adminAdFormSchema`, which is one ad record. This is the
240
+ * platform-level configuration every ad renders under, and it was validated by
241
+ * a hand-written `localCredentialIssues` array that computed the same two rules
242
+ * on every keystroke and rendered them as a banner headed "Fix settings before
243
+ * saving" — never on the field that was wrong.
244
+ *
245
+ * Both credentials are optional and BLANK MEANS UNCHANGED: the route stores
246
+ * them encrypted and returns only a masked form, so the input starts empty on
247
+ * every load and an empty submit must leave the stored value alone. That is why
248
+ * neither field has a `.min()` — an empty string here is not an omission.
249
+ */
250
+ export const adsConfigSchema = z.object({
251
+ consentRequired: annotate(z.boolean(), {
252
+ section: "consent", sectionLabel: "Consent", sectionRequired: true,
253
+ quick: true, order: 1, row: "full",
254
+ label: "Require consent globally for ad rendering",
255
+ }),
256
+ adsenseClientId: annotate(z
257
+ .string()
258
+ .trim()
259
+ .regex(/^ca-pub-[0-9]{10,20}$/, "An AdSense client id looks like ca-pub-XXXXXXXXXX.")
260
+ .optional()
261
+ .or(z.literal("")), {
262
+ section: "credentials", sectionLabel: "Provider credentials",
263
+ order: 1, row: "pair", label: "AdSense client id",
264
+ help: "Leave blank to keep the stored value.",
265
+ }),
266
+ thirdPartyScriptUrl: annotate(z.string().trim().max(2000).optional().or(z.literal("")), {
267
+ section: "credentials", order: 2, row: "pair",
268
+ label: "Third-party script URL", inputType: "url",
269
+ help: "Must be https. Leave blank to keep the stored value.",
270
+ }),
271
+ })
272
+ .superRefine((v, ctx) => {
273
+ const url = v.thirdPartyScriptUrl?.trim();
274
+ if (!url)
275
+ return;
276
+ let parsed = null;
277
+ try {
278
+ parsed = new URL(url);
279
+ }
280
+ catch (err) {
281
+ void normalizeError(err);
282
+ parsed = null;
283
+ }
284
+ if (!parsed) {
285
+ ctx.addIssue({
286
+ code: z.ZodIssueCode.custom, path: ["thirdPartyScriptUrl"],
287
+ message: "That is not a valid URL.",
288
+ });
289
+ }
290
+ else if (parsed.protocol !== "https:") {
291
+ // A third-party ad script runs in the page's own origin; http would let
292
+ // any network hop rewrite it.
293
+ ctx.addIssue({
294
+ code: z.ZodIssueCode.custom, path: ["thirdPartyScriptUrl"],
295
+ message: "A third-party script URL must use https.",
296
+ });
297
+ }
298
+ });
299
+ /**
300
+ * The support-ticket triage form — what an admin CHANGES about a ticket, as
301
+ * distinct from what they SAY on it (the reply box, which is its own write to
302
+ * a different endpoint and carries the ticket's new status with it).
303
+ *
304
+ * The five linked-party ids are FLAT here and reassembled into
305
+ * `relatedParties` at the payload boundary, the same call the ad creative and
306
+ * the user's public profile make: the stored shape is one nested object, and
307
+ * the form has always offered five discrete inputs.
308
+ */
309
+ export const supportTicketUpdateSchema = z.object({
310
+ status: annotate(z.enum(["open", "in_progress", "waiting_on_user", "resolved", "closed"]), {
311
+ section: "triage", sectionLabel: "Triage", sectionRequired: true,
312
+ quick: true, order: 1, row: "pair", kind: "select",
313
+ }),
314
+ priority: annotate(z.enum(["low", "normal", "high", "urgent"]), {
315
+ section: "triage", quick: true, order: 2, row: "pair", kind: "select",
316
+ }),
317
+ internalNotes: annotate(z.string().trim().max(4000).optional().or(z.literal("")), {
318
+ section: "triage", order: 3, row: "full", kind: "textarea",
319
+ label: "Internal notes (staff only)",
320
+ help: "Visible only to admins and employees.",
321
+ }),
322
+ userId: annotate(z.string().trim().max(200).optional().or(z.literal("")), {
323
+ section: "parties", sectionLabel: "Linked parties", order: 1, row: "pair",
324
+ label: "User slug",
325
+ help: "Tag the buyer, store, order, product or bid this ticket concerns.",
326
+ }),
327
+ storeId: annotate(z.string().trim().max(200).optional().or(z.literal("")), {
328
+ section: "parties", order: 2, row: "pair", label: "Store slug",
329
+ }),
330
+ orderId: annotate(z.string().trim().max(200).optional().or(z.literal("")), {
331
+ section: "parties", order: 3, row: "pair", label: "Order ID",
332
+ }),
333
+ productId: annotate(z.string().trim().max(200).optional().or(z.literal("")), {
334
+ section: "parties", order: 4, row: "pair", label: "Product slug",
335
+ }),
336
+ bidId: annotate(z.string().trim().max(200).optional().or(z.literal("")), {
337
+ section: "parties", order: 5, row: "pair", label: "Bid ID",
338
+ }),
339
+ });
340
+ /**
341
+ * The five flat id fields, back into the nested `relatedParties` the ticket
342
+ * stores. Blank fields are dropped, and an entirely empty set returns
343
+ * `undefined` so the key is omitted rather than written as `{}`.
344
+ */
345
+ export function toRelatedPartiesPayload(v) {
346
+ const out = {};
347
+ for (const key of ["userId", "storeId", "orderId", "productId", "bidId"]) {
348
+ const value = v[key]?.trim();
349
+ if (value)
350
+ out[key] = value;
351
+ }
352
+ return Object.keys(out).length > 0 ? out : undefined;
353
+ }
@@ -1,26 +1,69 @@
1
1
  import { z } from "zod";
2
+ import type { JsonValue } from "../../../schemas/types";
2
3
  export declare const adminUserUpdateSchema: z.ZodObject<{
4
+ role: z.ZodOptional<z.ZodEnum<["user", "seller", "admin", "moderator"]>>;
5
+ emailVerified: z.ZodOptional<z.ZodBoolean>;
3
6
  displayName: z.ZodOptional<z.ZodString>;
4
7
  phoneNumber: z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>;
5
8
  isTester: z.ZodOptional<z.ZodBoolean>;
6
9
  canTestAdmin: z.ZodOptional<z.ZodBoolean>;
7
10
  adminNotes: z.ZodOptional<z.ZodString>;
8
- publicProfile: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodType<import("../../..").FirestoreValue, z.ZodTypeDef, import("../../..").FirestoreValue>>>;
11
+ bio: z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>;
12
+ location: z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>;
13
+ website: z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>;
14
+ twitter: z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>;
15
+ instagram: z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>;
16
+ facebook: z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>;
17
+ linkedin: z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>;
9
18
  }, "strip", z.ZodTypeAny, {
19
+ role?: "user" | "seller" | "admin" | "moderator" | undefined;
10
20
  displayName?: string | undefined;
21
+ location?: string | undefined;
11
22
  adminNotes?: string | undefined;
23
+ bio?: string | undefined;
12
24
  phoneNumber?: string | undefined;
25
+ emailVerified?: boolean | undefined;
26
+ website?: string | undefined;
13
27
  isTester?: boolean | undefined;
14
28
  canTestAdmin?: boolean | undefined;
15
- publicProfile?: Record<string, import("../../..").FirestoreValue> | undefined;
29
+ instagram?: string | undefined;
30
+ facebook?: string | undefined;
31
+ twitter?: string | undefined;
32
+ linkedin?: string | undefined;
16
33
  }, {
34
+ role?: "user" | "seller" | "admin" | "moderator" | undefined;
17
35
  displayName?: string | undefined;
36
+ location?: string | undefined;
18
37
  adminNotes?: string | undefined;
38
+ bio?: string | undefined;
19
39
  phoneNumber?: string | undefined;
40
+ emailVerified?: boolean | undefined;
41
+ website?: string | undefined;
20
42
  isTester?: boolean | undefined;
21
43
  canTestAdmin?: boolean | undefined;
22
- publicProfile?: Record<string, import("../../..").FirestoreValue> | undefined;
44
+ instagram?: string | undefined;
45
+ facebook?: string | undefined;
46
+ twitter?: string | undefined;
47
+ linkedin?: string | undefined;
23
48
  }>;
49
+ /**
50
+ * The flat social/profile fields, reassembled into the nested shape the PATCH
51
+ * route stores.
52
+ *
53
+ * Empty strings are dropped rather than written: the drawer sends a partial
54
+ * PATCH so an untouched field must leave the stored subkey alone, and writing
55
+ * `""` would erase it. Returns `undefined` when nothing was filled in, so the
56
+ * key is omitted from the payload entirely.
57
+ */
58
+ export declare function toPublicProfilePayload(v: {
59
+ bio?: string;
60
+ location?: string;
61
+ website?: string;
62
+ twitter?: string;
63
+ instagram?: string;
64
+ facebook?: string;
65
+ linkedin?: string;
66
+ }): Record<string, JsonValue> | undefined;
24
67
  export type AdminUserUpdateValues = z.infer<typeof adminUserUpdateSchema>;
25
68
  /**
26
69
  * A store's WhatsApp Business connection.
@@ -51,6 +94,38 @@ export declare const whatsappSettingsSchema: z.ZodObject<{
51
94
  catalogId?: string | undefined;
52
95
  }>;
53
96
  export type WhatsappSettingsValues = z.infer<typeof whatsappSettingsSchema>;
97
+ /**
98
+ * The CONNECTION form's fields — credentials only.
99
+ *
100
+ * `catalogSyncEnabled` is deliberately absent. Its toggle lives in a different
101
+ * card and owns its own write (`syncToggleMutation`), because the credentials
102
+ * form used to send it unconditionally from two cards away: a seller flipped
103
+ * the switch, saw it move, and nothing persisted it. A field in this schema is
104
+ * a field this form's Save button owns.
105
+ *
106
+ * 🛑 `.omit()` rather than a second literal, so the two cannot drift — it
107
+ * carries the SAME field instances into the new shape, which is what keeps the
108
+ * `annotate()` WeakMap entries alive. (A wrapper such as `.superRefine()` is
109
+ * transparent for the same reason; a field-level re-wrap is not.)
110
+ */
111
+ export declare const whatsappConnectionSchema: z.ZodObject<Omit<{
112
+ catalogSyncEnabled: z.ZodBoolean;
113
+ phoneNumber: z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>;
114
+ wabaId: z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>;
115
+ catalogId: z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>;
116
+ accessToken: z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>;
117
+ }, "catalogSyncEnabled">, "strip", z.ZodTypeAny, {
118
+ phoneNumber?: string | undefined;
119
+ accessToken?: string | undefined;
120
+ wabaId?: string | undefined;
121
+ catalogId?: string | undefined;
122
+ }, {
123
+ phoneNumber?: string | undefined;
124
+ accessToken?: string | undefined;
125
+ wabaId?: string | undefined;
126
+ catalogId?: string | undefined;
127
+ }>;
128
+ export type WhatsappConnectionValues = z.infer<typeof whatsappConnectionSchema>;
54
129
  /**
55
130
  * A buyer's lottery entry.
56
131
  *
@@ -30,13 +30,26 @@
30
30
  */
31
31
  import { z } from "zod";
32
32
  import { annotate } from "../../shell/field-ui-meta";
33
- import { firestoreValueSchema } from "../../../schemas/firestore-value";
34
33
  const PHONE_RE = /^(\+?91[-\s]?)?[6-9]\d{9}$/;
35
34
  export const adminUserUpdateSchema = z.object({
35
+ /*
36
+ * Role and email-verification lead, because they are what an admin opens this
37
+ * drawer for. They were controls with no schema entry at all — the form
38
+ * declared a schema covering six of its twelve fields, so `Role` and
39
+ * `Email verified` were validated by nothing.
40
+ */
41
+ role: annotate(z.enum(["user", "seller", "admin", "moderator"]).optional(), {
42
+ section: "profile", sectionLabel: "Profile", sectionRequired: true, quick: true,
43
+ order: 1, row: "pair", kind: "select",
44
+ }),
45
+ emailVerified: annotate(z.boolean().optional(), {
46
+ section: "profile", quick: true, order: 2, row: "quarter",
47
+ label: "Email verified",
48
+ }),
36
49
  displayName: annotate(z.string().trim().min(1).max(120).optional(), {
37
- section: "profile", sectionLabel: "Profile", sectionRequired: true, quick: true, order: 1, row: "pair",
50
+ section: "profile", quick: true, order: 3, row: "pair",
38
51
  }),
39
- phoneNumber: annotate(z.string().trim().regex(PHONE_RE, "Enter a 10-digit Indian mobile number.").optional().or(z.literal("")), { section: "profile", order: 2, row: "pair" }),
52
+ phoneNumber: annotate(z.string().trim().regex(PHONE_RE, "Enter a 10-digit Indian mobile number.").optional().or(z.literal("")), { section: "profile", order: 4, row: "pair" }),
40
53
  /*
41
54
  * The tester flags. Kept OPTIONAL rather than defaulted, deliberately: a
42
55
  * PATCH that omits them must leave them alone. Defaulting either to `false`
@@ -48,17 +61,76 @@ export const adminUserUpdateSchema = z.object({
48
61
  }),
49
62
  canTestAdmin: annotate(z.boolean().optional(), {
50
63
  section: "access", order: 2, row: "quarter",
64
+ // The drawer already hid this behind `isTester`; stating it here is what
65
+ // makes `visibleValues()` drop a stale `true` when the flag is switched off.
66
+ when: (v) => Boolean(v.isTester),
51
67
  }),
52
68
  adminNotes: annotate(z.string().trim().max(2000).optional(), {
53
69
  section: "access", order: 3, row: "full", kind: "textarea",
54
70
  help: "Internal only — never shown to the user.",
55
71
  }),
56
- // Open: the public profile carries per-user social links and bio fields that
57
- // are added over time, and a closed shape would strip a newly-added one.
58
- publicProfile: annotate(z.record(firestoreValueSchema).optional(), {
59
- section: "access", order: 4, row: "full", kind: "list",
72
+ /*
73
+ * The public profile, FLAT — the same call the ad form makes.
74
+ *
75
+ * The STORED shape is an open record (`publicProfileSchema` on the route),
76
+ * because per-user profile keys are added over time and a closed shape would
77
+ * strip a newly-added one. That was the right conclusion for the document and
78
+ * the wrong one for the FORM: as one `kind: "list"` field it renders as a
79
+ * disabled placeholder, while the drawer has always offered seven discrete
80
+ * inputs — bio, location, website and four social handles — validated by
81
+ * nothing. The nested shape is reassembled at the payload boundary, where the
82
+ * API wants it, by `toPublicProfilePayload()` below.
83
+ */
84
+ bio: annotate(z.string().trim().max(1000).optional().or(z.literal("")), {
85
+ section: "public", sectionLabel: "Public profile", order: 1, row: "full",
86
+ kind: "textarea", help: "Shown on the user's public profile page.",
87
+ }),
88
+ location: annotate(z.string().trim().max(120).optional().or(z.literal("")), {
89
+ section: "public", order: 2, row: "pair",
90
+ }),
91
+ website: annotate(z.string().trim().max(2000).optional().or(z.literal("")), {
92
+ section: "public", order: 3, row: "pair", inputType: "url",
93
+ }),
94
+ twitter: annotate(z.string().trim().max(200).optional().or(z.literal("")), {
95
+ section: "social", sectionLabel: "Social links", order: 1, row: "pair",
96
+ label: "Twitter / X",
97
+ }),
98
+ instagram: annotate(z.string().trim().max(200).optional().or(z.literal("")), {
99
+ section: "social", order: 2, row: "pair",
100
+ }),
101
+ facebook: annotate(z.string().trim().max(200).optional().or(z.literal("")), {
102
+ section: "social", order: 3, row: "pair",
103
+ }),
104
+ linkedin: annotate(z.string().trim().max(200).optional().or(z.literal("")), {
105
+ section: "social", order: 4, row: "pair", label: "LinkedIn",
60
106
  }),
61
107
  });
108
+ /**
109
+ * The flat social/profile fields, reassembled into the nested shape the PATCH
110
+ * route stores.
111
+ *
112
+ * Empty strings are dropped rather than written: the drawer sends a partial
113
+ * PATCH so an untouched field must leave the stored subkey alone, and writing
114
+ * `""` would erase it. Returns `undefined` when nothing was filled in, so the
115
+ * key is omitted from the payload entirely.
116
+ */
117
+ export function toPublicProfilePayload(v) {
118
+ const socialLinks = {};
119
+ for (const key of ["twitter", "instagram", "facebook", "linkedin"]) {
120
+ const value = v[key]?.trim();
121
+ if (value)
122
+ socialLinks[key] = value;
123
+ }
124
+ const publicProfile = {};
125
+ for (const key of ["bio", "location", "website"]) {
126
+ const value = v[key]?.trim();
127
+ if (value)
128
+ publicProfile[key] = value;
129
+ }
130
+ if (Object.keys(socialLinks).length > 0)
131
+ publicProfile.socialLinks = socialLinks;
132
+ return Object.keys(publicProfile).length > 0 ? publicProfile : undefined;
133
+ }
62
134
  /**
63
135
  * A store's WhatsApp Business connection.
64
136
  *
@@ -79,6 +151,23 @@ export const whatsappSettingsSchema = z.object({
79
151
  catalogId: annotate(z.string().trim().regex(/^\d{5,32}$/, "A catalog ID is a numeric id, not a URL.").optional().or(z.literal("")), { section: "connection", order: 3, row: "pair" }),
80
152
  accessToken: annotate(z.string().trim().min(20, "That access token looks truncated.").optional().or(z.literal("")), { section: "connection", order: 4, row: "full", help: "Leave blank to keep the current token." }),
81
153
  });
154
+ /**
155
+ * The CONNECTION form's fields — credentials only.
156
+ *
157
+ * `catalogSyncEnabled` is deliberately absent. Its toggle lives in a different
158
+ * card and owns its own write (`syncToggleMutation`), because the credentials
159
+ * form used to send it unconditionally from two cards away: a seller flipped
160
+ * the switch, saw it move, and nothing persisted it. A field in this schema is
161
+ * a field this form's Save button owns.
162
+ *
163
+ * 🛑 `.omit()` rather than a second literal, so the two cannot drift — it
164
+ * carries the SAME field instances into the new shape, which is what keeps the
165
+ * `annotate()` WeakMap entries alive. (A wrapper such as `.superRefine()` is
166
+ * transparent for the same reason; a field-level re-wrap is not.)
167
+ */
168
+ export const whatsappConnectionSchema = whatsappSettingsSchema.omit({
169
+ catalogSyncEnabled: true,
170
+ });
82
171
  /**
83
172
  * A buyer's lottery entry.
84
173
  *
@@ -20,3 +20,12 @@ export declare const homepageSectionFormSchema: z.ZodObject<{
20
20
  order?: number | undefined;
21
21
  }>;
22
22
  export type HomepageSectionFormValues = z.infer<typeof homepageSectionFormSchema>;
23
+ /**
24
+ * What the FORM holds, as distinct from what the schema produces.
25
+ *
26
+ * `order` is a `z.coerce.number()`, so the parsed value is a number while the
27
+ * control that feeds it is a text input holding a string — including the empty
28
+ * string, which means "auto-place". The draft is the INPUT side; only the save
29
+ * path sees the output.
30
+ */
31
+ export type HomepageSectionFormInput = z.input<typeof homepageSectionFormSchema>;
@@ -0,0 +1,17 @@
1
+ import React from "react";
2
+ export interface PageTabDef {
3
+ id: string;
4
+ label: string;
5
+ }
6
+ export interface PageTabsProps {
7
+ /** The tabs, in order. The first is the default when `?tab=` is absent or unknown. */
8
+ tabs: readonly PageTabDef[];
9
+ /** One node per tab id. A missing entry renders an empty panel, not a crash. */
10
+ panels: Record<string, React.ReactNode>;
11
+ /**
12
+ * Override the default tab. Omit to use the first — which is right whenever
13
+ * the host page's own content is the first tab, as it is on all six.
14
+ */
15
+ defaultTabId?: string;
16
+ }
17
+ export declare function PageTabs({ tabs, panels, defaultTabId }: PageTabsProps): React.JSX.Element;
@@ -0,0 +1,45 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ /*
4
+ * WHY: W8's C2 folds sibling pages that are one task into one tabbed page, and
5
+ * four of the six hosts are SERVER components that fetch their panels'
6
+ * data. Tabs need client state, so something has to sit between.
7
+ *
8
+ * WHAT: The tab strip and the panel switch, URL-synced via `?tab=`.
9
+ *
10
+ * ## 🛑 Why the tab list is DATA and the guard is derived
11
+ *
12
+ * A Server Component cannot hand a function to a Client Component — it is not
13
+ * serialisable, and the failure is a build error at best and a React #441 at
14
+ * worst (Root Cause #76). So this takes the tab ARRAY and derives the
15
+ * "is this a real tab id" test from it, rather than taking an `isValid`
16
+ * predicate the way `useTabParam` does for callers that are already clients.
17
+ *
18
+ * Panels are passed as a record of nodes, which IS serialisable: a Server
19
+ * Component renders both panels and hands over the results.
20
+ *
21
+ * ## Every panel is rendered, always
22
+ *
23
+ * `TabsContent` mounts all of them and shows one. That is deliberate for these
24
+ * six pages: the absorbed panels are small, their data was already fetched
25
+ * server-side by the same request, and mounting on first view would make a tab
26
+ * click cost a spinner for content that is already in the payload.
27
+ *
28
+ * EXPORTS: PageTabs, type PageTabDef
29
+ *
30
+ * @tag domain:layout
31
+ * @tag layer:component
32
+ * @tag pattern:none
33
+ * @tag access:client
34
+ * @tag consumers:store payouts/shipping/fulfillment/storefront,admin roles/settings
35
+ * @tag sideEffects:router
36
+ */
37
+ import React from "react";
38
+ import { Tabs, TabsList, TabsTrigger, TabsContent } from "../../ui";
39
+ import { useTabParam } from "../../react/hooks/useTabParam";
40
+ export function PageTabs({ tabs, panels, defaultTabId }) {
41
+ const ids = React.useMemo(() => tabs.map((t) => t.id), [tabs]);
42
+ const isValid = React.useCallback((value) => !!value && ids.includes(value), [ids]);
43
+ const [activeTab, setActiveTab] = useTabParam(isValid, defaultTabId ?? tabs[0]?.id ?? "");
44
+ return (_jsxs(Tabs, { value: activeTab, onChange: setActiveTab, children: [_jsx(TabsList, { children: tabs.map((tab) => (_jsx(TabsTrigger, { value: tab.id, children: tab.label }, tab.id))) }), tabs.map((tab) => (_jsx(TabsContent, { value: tab.id, children: panels[tab.id] ?? null }, tab.id)))] }));
45
+ }
@@ -1,9 +1,9 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { normalizeError } from "../../../errors/normalize";
4
- import { useEffect, useState } from "react";
4
+ import React, { useEffect, useState } from "react";
5
5
  import { z } from "zod";
6
- import { Button, Code, ConfirmDeleteModal, Div, FormField, Heading, Modal, Row, Select, SideDrawer, Span, Stack, Tabs, TabsContent, TabsList, TabsTrigger, Text, useToast } from "../../../ui";
6
+ import { Button, Code, ConfirmDeleteModal, Div, Heading, Modal, Row, SideDrawer, Span, Stack, Tabs, TabsContent, TabsList, TabsTrigger, Text, useToast } from "../../../ui";
7
7
  import { FormShellContext, useFormShellState, FormErrorSummary } from "../../../ui/forms";
8
8
  import { MediaImage } from "../../media/MediaImage";
9
9
  import { apiClient } from "../../../http";
@@ -11,13 +11,33 @@ import { PRODUCT_ENDPOINTS } from "../../../constants/api-endpoints";
11
11
  import { ProductInlineSelect } from "../../seller/components/ProductInlineSelect";
12
12
  import { SIEVE_OP, sieveFilter } from "../../../utils/sieve-builder";
13
13
  import { PRODUCT_FIELDS } from "../../../constants/field-names";
14
+ import { annotate } from "../../shell/field-ui-meta";
15
+ import { SectionForm, useSectionFormNav, buildSectionsFromSchema, visibleValues, } from "../../shell";
16
+ import { applyZodIssues } from "../../../ui/forms/apply-zod-issues";
14
17
  const groupTitleSchema = z.object({
15
- groupTitle: z.string().min(1, "Group title is required"),
18
+ groupTitle: annotate(z.string().min(1, "Group title is required"), {
19
+ section: "group", sectionLabel: "Group", sectionRequired: true, quick: true,
20
+ order: 1, row: "full", label: "Group title",
21
+ help: "Shown above the set on the parent listing.",
22
+ }),
16
23
  });
24
+ /*
25
+ * `price` stays a STRING rather than `z.coerce.number()`, deliberately: the
26
+ * POST body sends `parseFloat(price)` and an empty numeric input coerces to
27
+ * `NaN`, which passes `.optional()` and lands in the payload as `null`. As a
28
+ * string the "enter a valid price" rule can be stated once and reported on the
29
+ * field.
30
+ */
17
31
  const createChildSchema = z.object({
18
- title: z.string().min(1, "Title is required"),
19
- price: z.string().refine((v) => v.trim() !== "" && !Number.isNaN(parseFloat(v)) && parseFloat(v) > 0, "Enter a valid price"),
20
- condition: z.string(),
32
+ title: annotate(z.string().min(1, "Title is required"), {
33
+ section: "child", sectionLabel: "New child listing", sectionRequired: true,
34
+ quick: true, order: 1, row: "pair", label: "Title",
35
+ }),
36
+ price: annotate(z.string().refine((v) => v.trim() !== "" && !Number.isNaN(parseFloat(v)) && parseFloat(v) > 0, "Enter a valid price"), { section: "child", quick: true, order: 2, row: "pair", kind: "number", label: "Price (INR)" }),
37
+ condition: annotate(z.string(), {
38
+ section: "child", order: 3, row: "pair", kind: "select", label: "Condition",
39
+ help: "Category, brand, shipping and return policy are inherited from the parent listing.",
40
+ }),
21
41
  });
22
42
  const __P = {
23
43
  p4: "p-[var(--appkit-space-4)]",
@@ -34,7 +54,7 @@ export function GroupSettingsPanel({ productId, productSlug, groupId, isGroupPar
34
54
  const { showToast } = useToast();
35
55
  const [open, setOpen] = useState(false);
36
56
  const [loading, setLoading] = useState(false);
37
- const [editTitle, setEditTitle] = useState(groupTitle ?? "");
57
+ const [titleDraft, setTitleDraft] = useState({ groupTitle: groupTitle ?? "" });
38
58
  const [showAddModal, setShowAddModal] = useState(false);
39
59
  const [addTab, setAddTab] = useState("create");
40
60
  const [createForm, setCreateForm] = useState({
@@ -45,11 +65,18 @@ export function GroupSettingsPanel({ productId, productSlug, groupId, isGroupPar
45
65
  const [linkTargets, setLinkTargets] = useState([]);
46
66
  const [children, setChildren] = useState(null);
47
67
  const [confirmAction, setConfirmAction] = useState(null);
48
- const { shellCtx: titleShellCtx, validate: validateTitle } = useFormShellState(groupTitleSchema);
68
+ const titleSections = React.useMemo(() => buildSectionsFromSchema(groupTitleSchema), []);
69
+ const titleNav = useSectionFormNav(titleSections, titleDraft, { scope: "store:group-title" });
70
+ const titleForm = useFormShellState(groupTitleSchema, {
71
+ sections: titleNav.sectionMeta,
72
+ onGoToSection: titleNav.goToSection,
73
+ fieldToSectionIndex: titleNav.fieldToSectionIndex,
74
+ });
75
+ const titleShellCtx = titleForm.shellCtx;
49
76
  useEffect(() => {
50
- validateTitle({ groupTitle: editTitle });
77
+ titleForm.validate(titleDraft);
51
78
  // eslint-disable-next-line react-hooks/exhaustive-deps
52
- }, [editTitle, validateTitle]);
79
+ }, [titleDraft]);
53
80
  if (isAuction)
54
81
  return null;
55
82
  const groupEndpoint = `${storeProductsEndpoint}/${productId}/group`;
@@ -90,7 +117,7 @@ export function GroupSettingsPanel({ productId, productSlug, groupId, isGroupPar
90
117
  return;
91
118
  setLoading(true);
92
119
  try {
93
- await apiClient.patch(groupEndpoint, { groupTitle: editTitle });
120
+ await apiClient.patch(groupEndpoint, visibleValues(groupTitleSchema, titleDraft));
94
121
  showToast("Group title saved.", "success");
95
122
  onGroupChanged();
96
123
  }
@@ -237,17 +264,36 @@ export function GroupSettingsPanel({ productId, productSlug, groupId, isGroupPar
237
264
  setOpen(next);
238
265
  if (next && isGroupParent && children === null)
239
266
  loadChildren();
240
- }, paddingX: "none", paddingY: "xs", justify: "between", className: "w-full text-left group", "aria-expanded": open, children: [_jsx(Heading, { level: 3, size: "sm", weight: "semibold", color: "muted", children: "Group Settings" }), _jsx(Span, { size: "xs", className: "group-hover:text-[var(--appkit-color-text-muted)] dark:group-hover:text-[var(--appkit-color-text-faint)] transition-colors", color: "faint", children: open ? "▲" : "▼" })] }), open && (_jsxs(Stack, { gap: "sm", className: `mt-3 ${__P.p4}`, surface: "muted", rounded: "lg", border: "default", children: [!groupId && !groupParentSlug && (_jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "xs", color: "muted", children: "Group related listings together \u2014 e.g. a set, bundle, or multi-part item. Parts can be sold individually but shown together." }), _jsx(Button, { type: "button", variant: "secondary", size: "sm", onClick: startGroup, isLoading: loading, children: "Start a group" })] })), isGroupParent && groupId && (_jsxs(Stack, { gap: "md", children: [_jsxs(FormShellContext.Provider, { value: titleShellCtx, children: [_jsxs(Row, { align: "start", gap: "sm", wrap: true, children: [_jsx(Div, { className: "flex-1 min-w-[200px]", children: _jsx(FormField, { name: "groupTitle", label: "Group title", type: "text", value: editTitle, onChange: setEditTitle, placeholder: "e.g. Human Toy Complete Set" }) }), _jsx(Div, { padding: "t-lg", children: _jsx(Button, { type: "button", variant: "secondary", size: "sm", onClick: saveTitle, isLoading: loading, children: "Save title" }) })] }), _jsx(FormErrorSummary, {})] }), _jsxs(Div, { children: [_jsxs(Text, { className: "mb-2", color: "muted", size: "xs", weight: "semibold", children: ["Members (", childSlugsCount + 1, " \u2014 including this listing)"] }), loading && !children ? (_jsx(Text, { size: "xs", color: "faint", children: "Loading\u2026" })) : (_jsxs(Div, { className: "divide-y divide-zinc-100 divide-[var(--appkit-color-border-subtle)]", children: [_jsxs(Row, { align: "center", gap: "sm", padding: "y-xs", children: [_jsx(Span, { padding: "pill-2xs", weight: "semibold", className: "bg-[var(--appkit-color-primary)]/10 text-[var(--appkit-color-primary)] text-[10px]", rounded: "default", children: "Parent" }), _jsx(Text, { className: "flex-1", color: "primary", size: "sm", children: productSlug })] }), (children ?? []).filter((c) => c.id !== productId).map((child) => (_jsxs(Row, { align: "center", gap: "sm", padding: "y-xs", children: [child.images?.[0] ? (_jsx(Div, { className: "relative w-8 h-8 flex-shrink-0 border border-[var(--appkit-color-border)]", rounded: "full", overflow: "hidden", children: _jsx(MediaImage, { src: child.images[0], alt: child.title, size: "thumbnail" }) })) : (_jsx(Div, { className: "w-8 h-8", surface: "subtle", rounded: "full" })), _jsx(Text, { className: "flex-1 truncate", color: "primary", size: "sm", children: child.title }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => requestUnlinkChild(child.id), isLoading: loading, className: `${CLS_DELETE_LINK} text-[length:var(--appkit-text-xs)]`, children: "Unlink" })] }, child.id)))] }))] }), _jsxs(Row, { gap: "sm", wrap: true, children: [_jsx(Button, { type: "button", variant: "secondary", size: "sm", onClick: () => setShowAddModal(true), children: "Add child listing" }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: requestDissolveGroup, isLoading: loading, className: CLS_DELETE_LINK, children: "Dissolve group" })] })] })), !isGroupParent && groupParentSlug && (_jsxs(Stack, { gap: "xs", children: [_jsxs(Text, { size: "sm", color: "muted", children: ["Part of:", " ", _jsx(Span, { weight: "medium", color: "primary", children: groupTitle ?? groupParentSlug })] }), _jsxs(Text, { size: "xs", color: "muted", children: ["Parent listing: ", _jsx(Code, { className: "font-mono", children: groupParentSlug })] }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: requestLeaveGroup, isLoading: loading, className: `${CLS_DELETE_LINK} w-fit`, children: "Leave group" })] }))] })), (() => {
267
+ }, paddingX: "none", paddingY: "xs", justify: "between", className: "w-full text-left group", "aria-expanded": open, children: [_jsx(Heading, { level: 3, size: "sm", weight: "semibold", color: "muted", children: "Group Settings" }), _jsx(Span, { size: "xs", className: "group-hover:text-[var(--appkit-color-text-muted)] dark:group-hover:text-[var(--appkit-color-text-faint)] transition-colors", color: "faint", children: open ? "▲" : "▼" })] }), open && (_jsxs(Stack, { gap: "sm", className: `mt-3 ${__P.p4}`, surface: "muted", rounded: "lg", border: "default", children: [!groupId && !groupParentSlug && (_jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "xs", color: "muted", children: "Group related listings together \u2014 e.g. a set, bundle, or multi-part item. Parts can be sold individually but shown together." }), _jsx(Button, { type: "button", variant: "secondary", size: "sm", onClick: startGroup, isLoading: loading, children: "Start a group" })] })), isGroupParent && groupId && (_jsxs(Stack, { gap: "md", children: [_jsxs(FormShellContext.Provider, { value: titleShellCtx, children: [_jsx(FormErrorSummary, {}), _jsx(SectionForm, { sections: titleSections, values: titleDraft, onChange: (partial) => setTitleDraft((d) => ({ ...d, ...partial })), onSubmit: () => void saveTitle(), onValidationChange: () => titleForm.validate(titleDraft), schema: groupTitleSchema, openIds: titleNav.openIds, onOpenChange: titleNav.setOpenIds, submitLabel: "Save title", isLoading: loading })] }), _jsxs(Div, { children: [_jsxs(Text, { className: "mb-2", color: "muted", size: "xs", weight: "semibold", children: ["Members (", childSlugsCount + 1, " \u2014 including this listing)"] }), loading && !children ? (_jsx(Text, { size: "xs", color: "faint", children: "Loading\u2026" })) : (_jsxs(Div, { className: "divide-y divide-zinc-100 divide-[var(--appkit-color-border-subtle)]", children: [_jsxs(Row, { align: "center", gap: "sm", padding: "y-xs", children: [_jsx(Span, { padding: "pill-2xs", weight: "semibold", className: "bg-[var(--appkit-color-primary)]/10 text-[var(--appkit-color-primary)] text-[10px]", rounded: "default", children: "Parent" }), _jsx(Text, { className: "flex-1", color: "primary", size: "sm", children: productSlug })] }), (children ?? []).filter((c) => c.id !== productId).map((child) => (_jsxs(Row, { align: "center", gap: "sm", padding: "y-xs", children: [child.images?.[0] ? (_jsx(Div, { className: "relative w-8 h-8 flex-shrink-0 border border-[var(--appkit-color-border)]", rounded: "full", overflow: "hidden", children: _jsx(MediaImage, { src: child.images[0], alt: child.title, size: "thumbnail" }) })) : (_jsx(Div, { className: "w-8 h-8", surface: "subtle", rounded: "full" })), _jsx(Text, { className: "flex-1 truncate", color: "primary", size: "sm", children: child.title }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => requestUnlinkChild(child.id), isLoading: loading, className: `${CLS_DELETE_LINK} text-[length:var(--appkit-text-xs)]`, children: "Unlink" })] }, child.id)))] }))] }), _jsxs(Row, { gap: "sm", wrap: true, children: [_jsx(Button, { type: "button", variant: "secondary", size: "sm", onClick: () => setShowAddModal(true), children: "Add child listing" }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: requestDissolveGroup, isLoading: loading, className: CLS_DELETE_LINK, children: "Dissolve group" })] })] })), !isGroupParent && groupParentSlug && (_jsxs(Stack, { gap: "xs", children: [_jsxs(Text, { size: "sm", color: "muted", children: ["Part of:", " ", _jsx(Span, { weight: "medium", color: "primary", children: groupTitle ?? groupParentSlug })] }), _jsxs(Text, { size: "xs", color: "muted", children: ["Parent listing: ", _jsx(Code, { className: "font-mono", children: groupParentSlug })] }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: requestLeaveGroup, isLoading: loading, className: `${CLS_DELETE_LINK} w-fit`, children: "Leave group" })] }))] })), (() => {
241
268
  const content = (_jsx(AddChildContent, { addTab: addTab, setAddTab: setAddTab, createForm: createForm, setCreateForm: setCreateForm, productSlug: productSlug, storeProductsEndpoint: storeProductsEndpoint, linkTargets: linkTargets, setLinkTargets: setLinkTargets, onAddCreate: addCreateChild, onAddLink: addLinkChild, loading: loading }));
242
269
  const close = () => setShowAddModal(false);
243
270
  return (groupChildSlugs?.length ?? 0) >= 4 ? (_jsx(SideDrawer, { isOpen: showAddModal, onClose: close, title: "Add child listing", children: content })) : (_jsx(Modal, { open: showAddModal, onClose: close, title: "Add child listing", size: "lg", children: content }));
244
271
  })(), confirmAction && (_jsx(ConfirmDeleteModal, { isOpen: true, title: confirmAction.title, message: confirmAction.message, onConfirm: confirmAction.onConfirm, onClose: () => setConfirmAction(null), isDeleting: loading }))] }));
245
272
  }
246
273
  function AddChildContent({ addTab, setAddTab, createForm, setCreateForm, productSlug, storeProductsEndpoint, linkTargets, setLinkTargets, onAddCreate, onAddLink, loading, }) {
247
- const { shellCtx, validate } = useFormShellState(createChildSchema);
274
+ const sections = React.useMemo(() => buildSectionsFromSchema(createChildSchema, {
275
+ options: { condition: CONDITION_OPTIONS },
276
+ }), []);
277
+ const nav = useSectionFormNav(sections, createForm, { scope: "store:group-add-child" });
278
+ const form = useFormShellState(createChildSchema, {
279
+ sections: nav.sectionMeta,
280
+ onGoToSection: nav.goToSection,
281
+ fieldToSectionIndex: nav.fieldToSectionIndex,
282
+ });
283
+ const shellCtx = form.shellCtx;
248
284
  useEffect(() => {
249
- validate(createForm);
285
+ form.validate(createForm);
250
286
  // eslint-disable-next-line react-hooks/exhaustive-deps
251
- }, [createForm, validate]);
252
- return (_jsx(Stack, { gap: "md", children: _jsxs(Tabs, { value: addTab, onChange: (v) => setAddTab(v), children: [_jsxs(TabsList, { children: [_jsx(TabsTrigger, { value: "create", children: "Create new child" }), _jsx(TabsTrigger, { value: "link", children: "Link existing" })] }), _jsx(TabsContent, { value: "create", children: _jsx(FormShellContext.Provider, { value: shellCtx, children: _jsxs(Stack, { gap: "sm", className: "mt-4", children: [_jsx(FormField, { name: "title", label: "Title *", type: "text", value: createForm.title, onChange: (v) => setCreateForm({ ...createForm, title: v }), placeholder: `${productSlug}-part` }), _jsx(FormField, { name: "price", label: "Price (\u20B9) *", type: "number", value: createForm.price, onChange: (v) => setCreateForm({ ...createForm, price: v }), placeholder: "0" }), _jsx(Select, { label: "Condition", value: createForm.condition, onValueChange: (v) => setCreateForm({ ...createForm, condition: v }), options: CONDITION_OPTIONS }), _jsx(Text, { className: "dark:text-[var(--appkit-color-text-faint)]/80", color: "faint", size: "xs", children: "Other fields (category, brand, shipping, return policy) are inherited from this parent listing. Need more control? Edit the full listing after saving." }), _jsx(FormErrorSummary, {}), _jsx(Button, { type: "button", onClick: onAddCreate, isLoading: loading, disabled: !createForm.title || !createForm.price, children: "Create and link child" })] }) }) }), _jsx(TabsContent, { value: "link", children: _jsxs(Stack, { gap: "sm", className: "mt-4", children: [_jsx(Text, { size: "xs", color: "muted", children: "Search your existing products or pre-orders. Auctions cannot be linked. Select multiple to bulk-link." }), _jsx(ProductInlineSelect, { scope: "store", endpoint: storeProductsEndpoint, filters: sieveFilter(PRODUCT_FIELDS.LISTING_TYPE, SIEVE_OP.EQ, "standard"), multiple: true, value: linkTargets, onChange: setLinkTargets, placeholder: "Search listings\u2026" }), _jsx(Button, { type: "button", onClick: onAddLink, isLoading: loading, disabled: linkTargets.length === 0, children: linkTargets.length > 1 ? `Link ${linkTargets.length} to group` : "Link to group" })] }) })] }) }));
287
+ }, [createForm]);
288
+ return (_jsx(Stack, { gap: "md", children: _jsxs(Tabs, { value: addTab, onChange: (v) => setAddTab(v), children: [_jsxs(TabsList, { children: [_jsx(TabsTrigger, { value: "create", children: "Create new child" }), _jsx(TabsTrigger, { value: "link", children: "Link existing" })] }), _jsx(TabsContent, { value: "create", children: _jsx(FormShellContext.Provider, { value: shellCtx, children: _jsxs(Stack, { gap: "sm", className: "mt-4", children: [_jsx(FormErrorSummary, {}), _jsx(SectionForm, { sections: sections, values: createForm, onChange: (partial) => setCreateForm({ ...createForm, ...partial }), onSubmit: () => {
289
+ // SectionForm scrolls to the first erroring section and then
290
+ // submits regardless, so the guard lives here.
291
+ form.clearErrors();
292
+ const parsed = createChildSchema.safeParse(visibleValues(createChildSchema, createForm));
293
+ if (!parsed.success) {
294
+ applyZodIssues(parsed.error.issues, form.setFieldError);
295
+ return;
296
+ }
297
+ onAddCreate();
298
+ }, onValidationChange: () => form.validate(createForm), schema: createChildSchema, openIds: nav.openIds, onOpenChange: nav.setOpenIds, submitLabel: "Create and link child", isLoading: loading }), _jsx(Text, { className: "dark:text-[var(--appkit-color-text-faint)]/80", color: "faint", size: "xs", children: "Need more control? Edit the full listing after saving." })] }) }) }), _jsx(TabsContent, { value: "link", children: _jsxs(Stack, { gap: "sm", className: "mt-4", children: [_jsx(Text, { size: "xs", color: "muted", children: "Search your existing products or pre-orders. Auctions cannot be linked. Select multiple to bulk-link." }), _jsx(ProductInlineSelect, { scope: "store", endpoint: storeProductsEndpoint, filters: sieveFilter(PRODUCT_FIELDS.LISTING_TYPE, SIEVE_OP.EQ, "standard"), multiple: true, value: linkTargets, onChange: setLinkTargets, placeholder: "Search listings\u2026" }), _jsx(Button, { type: "button", onClick: onAddLink, isLoading: loading, disabled: linkTargets.length === 0, children: linkTargets.length > 1 ? `Link ${linkTargets.length} to group` : "Link to group" })] }) })] }) }));
253
299
  }
@@ -0,0 +1,18 @@
1
+ import type { ActionIndexEntry } from "./types";
2
+ export interface ConsolidatedTabSurface {
3
+ /** Stable id suffix — `admin:tab:<slug>`. */
4
+ slug: string;
5
+ label: string;
6
+ description: string;
7
+ keywords: string[];
8
+ /** The host page's route key, resolved by the caller. */
9
+ route: "storePayouts" | "storeShipping" | "storeFulfillment" | "storeStorefront" | "adminRoles" | "adminSettings";
10
+ tab: string;
11
+ portal: "admin" | "store";
12
+ sectionPath: string;
13
+ requiredPermission?: string;
14
+ }
15
+ export declare const CONSOLIDATED_TAB_SURFACES: readonly ConsolidatedTabSurface[];
16
+ /** The host page each surface now lives on. */
17
+ export type ConsolidatedTabHrefs = Record<ConsolidatedTabSurface["route"], string>;
18
+ export declare function deriveConsolidatedTabEntries(hrefs: ConsolidatedTabHrefs): ActionIndexEntry[];