@mohasinac/appkit 4.31.1 → 4.32.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/dist/_internal/server/features/products/list-public.d.ts +18 -0
  2. package/dist/_internal/server/features/products/list-public.js +27 -9
  3. package/dist/client.d.ts +9 -0
  4. package/dist/client.js +12 -0
  5. package/dist/features/account/components/UserBidsView.js +6 -1
  6. package/dist/features/account/components/UserOrdersView.js +8 -1
  7. package/dist/features/account/components/UserReturnsView.js +6 -1
  8. package/dist/features/admin/components/AdminAdsView.js +36 -34
  9. package/dist/features/admin/components/AdminAuditLogView.js +12 -1
  10. package/dist/features/admin/components/AdminBundlesView.js +5 -1
  11. package/dist/features/admin/components/AdminCarouselEditorView.js +78 -10
  12. package/dist/features/admin/components/AdminFaqsView.js +10 -1
  13. package/dist/features/admin/components/AdminNotificationsView.js +7 -1
  14. package/dist/features/admin/components/AdminOrderEditorView.js +55 -24
  15. package/dist/features/admin/components/AdminPrizeDrawsView.js +11 -1
  16. package/dist/features/admin/components/AdminProductEditorView.js +78 -18
  17. package/dist/features/admin/components/AdminSectionsView.js +89 -32
  18. package/dist/features/admin/components/AdminSiteSettingsView.js +96 -44
  19. package/dist/features/admin/components/AdminStoresView.js +13 -1
  20. package/dist/features/admin/components/AdminSupportTicketDetailView.js +58 -43
  21. package/dist/features/admin/components/AdminSupportTicketsView.js +11 -1
  22. package/dist/features/admin/components/AdminTeamView.js +11 -1
  23. package/dist/features/admin/components/AdminTesterChecklistView.js +6 -1
  24. package/dist/features/admin/components/AdminUserEditorView.js +65 -82
  25. package/dist/features/admin/components/analytics/AdminPageViewsReportView.js +6 -1
  26. package/dist/features/admin/constants/admin-page-tabs.d.ts +36 -0
  27. package/dist/features/admin/constants/admin-page-tabs.js +45 -0
  28. package/dist/features/admin/schemas/admin-ops-forms.d.ts +81 -0
  29. package/dist/features/admin/schemas/admin-ops-forms.js +123 -1
  30. package/dist/features/admin/schemas/admin-user-form.d.ts +78 -3
  31. package/dist/features/admin/schemas/admin-user-form.js +96 -7
  32. package/dist/features/admin/schemas/homepage-section-form.d.ts +9 -0
  33. package/dist/features/layout/PageTabs.d.ts +17 -0
  34. package/dist/features/layout/PageTabs.js +45 -0
  35. package/dist/features/products/components/GroupSettingsPanel.js +62 -16
  36. package/dist/features/search/action-index/tab-entries.d.ts +18 -0
  37. package/dist/features/search/action-index/tab-entries.js +128 -0
  38. package/dist/features/seller/components/SellerBundlesView.js +5 -1
  39. package/dist/features/seller/components/SellerGoogleReviewsView.js +31 -19
  40. package/dist/features/seller/components/SellerOrdersView.js +57 -19
  41. package/dist/features/seller/components/SellerProductsView.js +10 -1
  42. package/dist/features/seller/components/SellerReviewsView.d.ts +2 -1
  43. package/dist/features/seller/components/SellerReviewsView.js +54 -19
  44. package/dist/features/seller/constants/store-tabs.d.ts +64 -0
  45. package/dist/features/seller/constants/store-tabs.js +60 -0
  46. package/dist/features/seller/schemas/order-forms.d.ts +33 -0
  47. package/dist/features/seller/schemas/order-forms.js +75 -0
  48. package/dist/features/seller/schemas/review-forms.d.ts +37 -0
  49. package/dist/features/seller/schemas/review-forms.js +82 -0
  50. package/dist/features/shell/index.d.ts +1 -1
  51. package/dist/features/shell/index.js +5 -1
  52. package/dist/features/shipments/components/AdminShipmentLotItemsView.js +44 -15
  53. package/dist/features/shipments/schemas/validation.d.ts +41 -0
  54. package/dist/features/shipments/schemas/validation.js +49 -0
  55. package/dist/features/store-extensions/schemas/google-config-form.d.ts +36 -0
  56. package/dist/features/store-extensions/schemas/google-config-form.js +24 -0
  57. package/dist/features/stores/actions/store-query-actions.js +5 -3
  58. package/dist/features/stores/repository/store.repository.d.ts +3 -1
  59. package/dist/features/stores/repository/store.repository.js +6 -2
  60. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +79 -0
  61. package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.js +53 -22
  62. package/dist/index.d.ts +4 -0
  63. package/dist/index.js +2 -0
  64. package/dist/next/index.d.ts +2 -0
  65. package/dist/next/index.js +1 -0
  66. package/dist/react/hooks/useTabParam.d.ts +19 -0
  67. package/dist/react/hooks/useTabParam.js +65 -0
  68. package/dist/schemas/registry.d.ts +27 -3
  69. package/dist/styles.css +1 -1
  70. package/dist/tailwind-utilities.css +1 -1
  71. package/package.json +1 -1
@@ -2,9 +2,12 @@
2
2
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
3
3
  import React from "react";
4
4
  import { useApiMutation } from "@mohasinac/appkit/client";
5
- import { Alert, Badge, Button, Div, FieldCheckbox, FieldInput, Grid, Heading, Modal, Pagination, Skeleton, Stack, Table, Thead, Tbody, Tr, Th, Td, Text, Textarea, } from "../../../ui";
5
+ import { Alert, Badge, Button, Div, Heading, Modal, Pagination, Skeleton, Stack, Table, Thead, Tbody, Tr, Th, Td, Text, Textarea, } from "../../../ui";
6
6
  import { apiClient } from "../../../http";
7
- import { createShipmentItemSchema } from "../schemas/validation";
7
+ import { createShipmentItemSchema, shipmentItemFormSchema, } from "../schemas/validation";
8
+ import { SectionForm, useSectionFormNav, buildSectionsFromSchema, visibleValues, } from "../../shell";
9
+ import { useFormShellState, FormShellContext, FormErrorSummary } from "../../../ui/forms";
10
+ import { applyZodIssues } from "../../../ui/forms/apply-zod-issues";
8
11
  import { ValidationError } from "../../../errors/validation-error";
9
12
  import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints";
10
13
  import { ACTIONS } from "../../../_internal/shared/actions/action-registry";
@@ -34,10 +37,19 @@ export function AdminShipmentLotItemsView({ shipmentId, lotId }) {
34
37
  const [pageSize, setPageSize] = React.useState(50);
35
38
  const { items, meta, isLoading, refetch } = useShipmentItems(shipmentId, lotId, { page, pageSize });
36
39
  const [showAddItem, setShowAddItem] = React.useState(false);
37
- const [title, setTitle] = React.useState("");
38
- const [quantity, setQuantity] = React.useState("1");
39
- const [priceRupees, setPriceRupees] = React.useState("0");
40
- const [isForSelfUse, setIsForSelfUse] = React.useState(false);
40
+ const [itemDraft, setItemDraft] = React.useState({
41
+ title: "",
42
+ quantity: 1,
43
+ isForSelfUse: false,
44
+ price: 0,
45
+ });
46
+ const itemSections = React.useMemo(() => buildSectionsFromSchema(shipmentItemFormSchema), []);
47
+ const itemNav = useSectionFormNav(itemSections, itemDraft, { scope: "admin:shipment-item" });
48
+ const itemForm = useFormShellState(shipmentItemFormSchema, {
49
+ sections: itemNav.sectionMeta,
50
+ onGoToSection: itemNav.goToSection,
51
+ fieldToSectionIndex: itemNav.fieldToSectionIndex,
52
+ });
41
53
  const [showBulkImport, setShowBulkImport] = React.useState(false);
42
54
  const [bulkText, setBulkText] = React.useState("");
43
55
  const parsedBulkRows = React.useMemo(() => parseBulkText(bulkText), [bulkText]);
@@ -52,11 +64,19 @@ export function AdminShipmentLotItemsView({ shipmentId, lotId }) {
52
64
  * named no field. `Number(priceRupees)` also yields NaN on a typo,
53
65
  * which the schema rejects and the old payload sent.
54
66
  */
67
+ /*
68
+ * Built from `visibleValues`, so a price typed before "for self use" was
69
+ * ticked is dropped rather than submitted — the control is hidden by a
70
+ * `when` predicate, and a hidden control's value must not travel.
71
+ */
72
+ const draft = visibleValues(shipmentItemFormSchema, itemDraft);
55
73
  const parsed = createShipmentItemSchema.safeParse({
56
- title,
57
- quantity: Number(quantity),
58
- isForSelfUse,
59
- price: isForSelfUse ? undefined : Math.round(Number(priceRupees) * 100) / 100,
74
+ title: draft.title,
75
+ quantity: Number(draft.quantity),
76
+ isForSelfUse: draft.isForSelfUse,
77
+ price: draft.isForSelfUse
78
+ ? undefined
79
+ : Math.round(Number(draft.price ?? 0) * 100) / 100,
60
80
  });
61
81
  if (!parsed.success) {
62
82
  // ValidationError carries the issue list; a bare Error flattens it to
@@ -66,10 +86,7 @@ export function AdminShipmentLotItemsView({ shipmentId, lotId }) {
66
86
  return apiClient.post(ADMIN_ENDPOINTS.SHIPMENT_LOT_ITEMS(shipmentId, lotId), parsed.data);
67
87
  },
68
88
  onSuccess: () => {
69
- setTitle("");
70
- setQuantity("1");
71
- setPriceRupees("0");
72
- setIsForSelfUse(false);
89
+ setItemDraft({ title: "", quantity: 1, isForSelfUse: false, price: 0 });
73
90
  setShowAddItem(false);
74
91
  refetch();
75
92
  },
@@ -105,7 +122,19 @@ export function AdminShipmentLotItemsView({ shipmentId, lotId }) {
105
122
  }),
106
123
  onSuccess: () => refetch(),
107
124
  });
108
- return (_jsxs(Stack, { gap: "md", children: [_jsxs(Div, { layout: "flex", align: "center", justify: "between", children: [_jsxs(Heading, { level: 3, children: ["Lot Items (", meta?.total ?? items.length, "/500)"] }), _jsxs(Div, { className: "flex gap-[var(--appkit-space-2)]", children: [_jsx(Button, { size: "sm", variant: "secondary", onClick: () => setShowAddItem((v) => !v), children: showAddItem ? "Cancel" : "+ Add Item" }), _jsx(Button, { size: "sm", variant: "secondary", onClick: () => setShowBulkImport(true), children: "Bulk Import" })] })] }), _jsx(Alert, { variant: "info", children: "Only individually notable \"main\" items need a row here \u2014 everything else in the lot can be captured as a single remainder estimate on the lot itself. Linking an item to a product does not sync future edits either way." }), showAddItem && (_jsxs(Grid, { cols: 4, gap: "sm", children: [_jsx(FieldInput, { name: "title", label: "Title", required: true, value: title, onChange: setTitle }), _jsx(FieldInput, { name: "quantity", label: "Quantity", type: "number", min: "1", value: quantity, onChange: setQuantity }), _jsx(FieldInput, { name: "price", label: "Projected Sale Price (\u20B9)", type: "number", min: "0", value: priceRupees, onChange: setPriceRupees, disabled: isForSelfUse }), _jsx(FieldCheckbox, { name: "isForSelfUse", label: "For self use (no resale)", checked: isForSelfUse, onChange: setIsForSelfUse }), _jsx(Button, { size: "sm", isLoading: addItemMutation.isPending, disabled: !title || (!isForSelfUse && Number(priceRupees) <= 0), onClick: () => addItemMutation.mutate(), children: "Save Item" })] })), isLoading ? (_jsxs(Stack, { gap: "sm", children: [_jsx(Skeleton, { variant: "rectangular", height: "32px" }), _jsx(Skeleton, { variant: "rectangular", height: "32px" })] })) : items.length === 0 ? (_jsx(Text, { variant: "secondary", children: "No items tracked yet." })) : (_jsxs(Table, { children: [_jsx(Thead, { children: _jsxs(Tr, { children: [_jsx(Th, { children: "Title" }), _jsx(Th, { children: "Qty" }), _jsx(Th, { children: "Price" }), _jsx(Th, { children: "Status" }), _jsx(Th, {})] }) }), _jsx(Tbody, { children: items.map((item) => (_jsxs(Tr, { children: [_jsx(Td, { children: item.title }), _jsx(Td, { children: item.quantity }), _jsx(Td, { children: item.isForSelfUse ? "—" : formatCurrency(item.price ?? 0) }), _jsx(Td, { children: item.isForSelfUse ? (_jsx(Badge, { variant: "secondary", children: "Self use" })) : item.linkedProductId ? (_jsx(Badge, { variant: "success", children: "Linked" })) : (_jsx(Badge, { variant: "default", children: "Unlinked" })) }), _jsxs(Td, { className: "flex gap-[var(--appkit-space-2)]", children: [!item.isForSelfUse &&
125
+ return (_jsxs(Stack, { gap: "md", children: [_jsxs(Div, { layout: "flex", align: "center", justify: "between", children: [_jsxs(Heading, { level: 3, children: ["Lot Items (", meta?.total ?? items.length, "/500)"] }), _jsxs(Div, { className: "flex gap-[var(--appkit-space-2)]", children: [_jsx(Button, { size: "sm", variant: "secondary", onClick: () => setShowAddItem((v) => !v), children: showAddItem ? "Cancel" : "+ Add Item" }), _jsx(Button, { size: "sm", variant: "secondary", onClick: () => setShowBulkImport(true), children: "Bulk Import" })] })] }), _jsx(Alert, { variant: "info", children: "Only individually notable \"main\" items need a row here \u2014 everything else in the lot can be captured as a single remainder estimate on the lot itself. Linking an item to a product does not sync future edits either way." }), showAddItem && (_jsxs(FormShellContext.Provider, { value: itemForm.shellCtx, children: [_jsx(FormErrorSummary, {}), _jsx(SectionForm, { sections: itemSections, values: itemDraft, onChange: (partial) => setItemDraft((d) => ({ ...d, ...partial })), onSubmit: () => {
126
+ /*
127
+ * The guard lives here: SectionForm surfaces errors and scrolls to
128
+ * them, then calls onSubmit unconditionally.
129
+ */
130
+ itemForm.clearErrors();
131
+ const parsed = shipmentItemFormSchema.safeParse(visibleValues(shipmentItemFormSchema, itemDraft));
132
+ if (!parsed.success) {
133
+ applyZodIssues(parsed.error.issues, itemForm.setFieldError);
134
+ return;
135
+ }
136
+ addItemMutation.mutate();
137
+ }, onValidationChange: () => itemForm.validate(itemDraft), schema: shipmentItemFormSchema, openIds: itemNav.openIds, onOpenChange: itemNav.setOpenIds, submitLabel: "Save Item", cancelLabel: "Cancel", onCancel: () => setShowAddItem(false), isLoading: addItemMutation.isPending })] })), isLoading ? (_jsxs(Stack, { gap: "sm", children: [_jsx(Skeleton, { variant: "rectangular", height: "32px" }), _jsx(Skeleton, { variant: "rectangular", height: "32px" })] })) : items.length === 0 ? (_jsx(Text, { variant: "secondary", children: "No items tracked yet." })) : (_jsxs(Table, { children: [_jsx(Thead, { children: _jsxs(Tr, { children: [_jsx(Th, { children: "Title" }), _jsx(Th, { children: "Qty" }), _jsx(Th, { children: "Price" }), _jsx(Th, { children: "Status" }), _jsx(Th, {})] }) }), _jsx(Tbody, { children: items.map((item) => (_jsxs(Tr, { children: [_jsx(Td, { children: item.title }), _jsx(Td, { children: item.quantity }), _jsx(Td, { children: item.isForSelfUse ? "—" : formatCurrency(item.price ?? 0) }), _jsx(Td, { children: item.isForSelfUse ? (_jsx(Badge, { variant: "secondary", children: "Self use" })) : item.linkedProductId ? (_jsx(Badge, { variant: "success", children: "Linked" })) : (_jsx(Badge, { variant: "default", children: "Unlinked" })) }), _jsxs(Td, { className: "flex gap-[var(--appkit-space-2)]", children: [!item.isForSelfUse &&
109
138
  (item.linkedProductId ? (_jsx(Button, { size: "sm", variant: "ghost", isLoading: unlinkItemMutation.isPending, onClick: () => unlinkItemMutation.mutate(item.id), children: "Unlink" })) : (_jsx(Button, { size: "sm", variant: "ghost", onClick: () => setLinkTarget({ itemId: item.id, title: item.title }), children: "Create pre-order link" }))), _jsx(Button, { size: "sm", variant: "ghost", action: ACTIONS.SHIPMENT["delete"], isLoading: deleteItemMutation.isPending, onClick: () => deleteItemMutation.mutate(item.id), children: "Delete" })] })] }, item.id))) })] })), meta && meta.totalPages > 1 && (_jsx(Pagination, { currentPage: page, totalPages: meta.totalPages, onPageChange: setPage, pageSize: pageSize, onPageSizeChange: (size) => {
110
139
  setPageSize(size);
111
140
  setPage(1);
@@ -262,6 +262,47 @@ export declare const createShipmentItemSchema: z.ZodEffects<z.ZodObject<{
262
262
  categorySlugs?: string[] | undefined;
263
263
  condition?: "new" | "used" | "refurbished" | "like_new" | "good" | "fair" | "poor" | "broken" | "graded" | undefined;
264
264
  }>;
265
+ /**
266
+ * The four fields the add-item form actually offers, annotated for
267
+ * `buildSectionsFromSchema`.
268
+ *
269
+ * 🛑 A separate object rather than `shipmentItemBaseSchema.pick(...)`, for two
270
+ * reasons. `.pick()` returns NEW field instances and the annotation registry is
271
+ * a WeakMap keyed by instance, so the meta would not survive. And deriving the
272
+ * form from the full base schema would render `categorySlugs` (an array) and
273
+ * `condition` as controls the add-item row has never had — an array has no
274
+ * `FieldKind`, so it would appear as a disabled placeholder telling the user to
275
+ * supply a renderer.
276
+ *
277
+ * The base schema stays the API contract; this is the form's subset of it.
278
+ */
279
+ export declare const shipmentItemFormSchema: z.ZodEffects<z.ZodObject<{
280
+ title: z.ZodString;
281
+ quantity: z.ZodNumber;
282
+ isForSelfUse: z.ZodBoolean;
283
+ price: z.ZodOptional<z.ZodNumber>;
284
+ }, "strip", z.ZodTypeAny, {
285
+ title: string;
286
+ quantity: number;
287
+ isForSelfUse: boolean;
288
+ price?: number | undefined;
289
+ }, {
290
+ title: string;
291
+ quantity: number;
292
+ isForSelfUse: boolean;
293
+ price?: number | undefined;
294
+ }>, {
295
+ title: string;
296
+ quantity: number;
297
+ isForSelfUse: boolean;
298
+ price?: number | undefined;
299
+ }, {
300
+ title: string;
301
+ quantity: number;
302
+ isForSelfUse: boolean;
303
+ price?: number | undefined;
304
+ }>;
305
+ export type ShipmentItemFormValues = z.infer<typeof shipmentItemFormSchema>;
265
306
  export declare const updateShipmentItemSchema: z.ZodObject<{
266
307
  title: z.ZodOptional<z.ZodString>;
267
308
  quantity: z.ZodOptional<z.ZodNumber>;
@@ -103,6 +103,55 @@ const shipmentItemBaseSchema = z.object({
103
103
  notes: z.string().optional(),
104
104
  });
105
105
  export const createShipmentItemSchema = shipmentItemBaseSchema.refine((item) => item.isForSelfUse || typeof item.price === "number", { message: "Projected sale price is required unless the item is for self use", path: ["price"] });
106
+ /**
107
+ * The four fields the add-item form actually offers, annotated for
108
+ * `buildSectionsFromSchema`.
109
+ *
110
+ * 🛑 A separate object rather than `shipmentItemBaseSchema.pick(...)`, for two
111
+ * reasons. `.pick()` returns NEW field instances and the annotation registry is
112
+ * a WeakMap keyed by instance, so the meta would not survive. And deriving the
113
+ * form from the full base schema would render `categorySlugs` (an array) and
114
+ * `condition` as controls the add-item row has never had — an array has no
115
+ * `FieldKind`, so it would appear as a disabled placeholder telling the user to
116
+ * supply a renderer.
117
+ *
118
+ * The base schema stays the API contract; this is the form's subset of it.
119
+ */
120
+ export const shipmentItemFormSchema = z
121
+ .object({
122
+ title: annotate(z.string().min(1, "Item title is required"), {
123
+ section: "item", sectionLabel: "Add item", sectionRequired: true,
124
+ order: 1, row: "pair", label: "Title",
125
+ }),
126
+ quantity: annotate(z.coerce.number().int().min(1, "At least one"), {
127
+ section: "item", order: 2, row: "quarter", kind: "number", label: "Quantity",
128
+ }),
129
+ isForSelfUse: annotate(z.boolean(), {
130
+ section: "item", order: 3, row: "quarter", kind: "toggle",
131
+ label: "For self use (no resale)",
132
+ }),
133
+ /*
134
+ * Hidden — not disabled — when the item is for self use. A self-use item has
135
+ * no projected sale price by definition, and `visibleValues()` then drops
136
+ * the value so a price typed before ticking the box cannot be submitted.
137
+ */
138
+ price: annotate(z.coerce.number().min(0).optional(), {
139
+ section: "item", order: 4, row: "pair", kind: "number",
140
+ label: "Projected sale price (₹)",
141
+ when: (v) => !v.isForSelfUse,
142
+ }),
143
+ })
144
+ .superRefine((item, ctx) => {
145
+ // Mirrors `createShipmentItemSchema`'s refine. `when` hides the control; it
146
+ // does NOT relax the rule, so the rule has to tolerate the hidden case.
147
+ if (!item.isForSelfUse && typeof item.price !== "number") {
148
+ ctx.addIssue({
149
+ code: z.ZodIssueCode.custom,
150
+ path: ["price"],
151
+ message: "Projected sale price is required unless the item is for self use",
152
+ });
153
+ }
154
+ });
106
155
  export const updateShipmentItemSchema = shipmentItemBaseSchema.partial().extend({
107
156
  // Link/unlink fields — written by the link route and by the unlink action
108
157
  // (PATCH with `null` to clear). Not part of item creation, so kept out of
@@ -18,6 +18,42 @@ export declare const storeGoogleConfigUpdateSchema: z.ZodObject<{
18
18
  businessName?: string | undefined;
19
19
  }>;
20
20
  export type StoreGoogleConfigFormValues = z.infer<typeof storeGoogleConfigUpdateSchema>;
21
+ /**
22
+ * The seller-facing form schema — the same annotated fields, plus the one rule
23
+ * the server does not need.
24
+ *
25
+ * 🛑 `.superRefine()` wraps and returns a NEW schema instance, which is exactly
26
+ * why the annotations live on the FIELDS above rather than being applied here:
27
+ * the registry is a WeakMap keyed by instance, so `annotate()` must be the
28
+ * outermost call on each field, and any wrapper around the whole object is
29
+ * transparent to it. `objectShape()` sees through `.superRefine()` when
30
+ * `buildSectionsFromSchema` resolves the fields.
31
+ *
32
+ * The rule itself is a form-level one: a Place ID is only required once the
33
+ * seller has actually switched the feature on. Requiring it unconditionally
34
+ * would make an untouched, disconnected settings page fail validation on load.
35
+ */
36
+ export declare const sellerGoogleReviewsFormSchema: z.ZodEffects<z.ZodObject<{
37
+ isConnected: z.ZodOptional<z.ZodBoolean>;
38
+ placeId: z.ZodOptional<z.ZodString>;
39
+ businessName: z.ZodOptional<z.ZodString>;
40
+ }, "strict", z.ZodTypeAny, {
41
+ placeId?: string | undefined;
42
+ isConnected?: boolean | undefined;
43
+ businessName?: string | undefined;
44
+ }, {
45
+ placeId?: string | undefined;
46
+ isConnected?: boolean | undefined;
47
+ businessName?: string | undefined;
48
+ }>, {
49
+ placeId?: string | undefined;
50
+ isConnected?: boolean | undefined;
51
+ businessName?: string | undefined;
52
+ }, {
53
+ placeId?: string | undefined;
54
+ isConnected?: boolean | undefined;
55
+ businessName?: string | undefined;
56
+ }>;
21
57
  /**
22
58
  * What the seller's own settings page may see.
23
59
  *
@@ -69,6 +69,30 @@ export const storeGoogleConfigUpdateSchema = z
69
69
  }),
70
70
  })
71
71
  .strict();
72
+ /**
73
+ * The seller-facing form schema — the same annotated fields, plus the one rule
74
+ * the server does not need.
75
+ *
76
+ * 🛑 `.superRefine()` wraps and returns a NEW schema instance, which is exactly
77
+ * why the annotations live on the FIELDS above rather than being applied here:
78
+ * the registry is a WeakMap keyed by instance, so `annotate()` must be the
79
+ * outermost call on each field, and any wrapper around the whole object is
80
+ * transparent to it. `objectShape()` sees through `.superRefine()` when
81
+ * `buildSectionsFromSchema` resolves the fields.
82
+ *
83
+ * The rule itself is a form-level one: a Place ID is only required once the
84
+ * seller has actually switched the feature on. Requiring it unconditionally
85
+ * would make an untouched, disconnected settings page fail validation on load.
86
+ */
87
+ export const sellerGoogleReviewsFormSchema = storeGoogleConfigUpdateSchema.superRefine((v, ctx) => {
88
+ if (v.isConnected && !(v.placeId ?? "").trim()) {
89
+ ctx.addIssue({
90
+ code: z.ZodIssueCode.custom,
91
+ path: ["placeId"],
92
+ message: "A Place ID is required to show Google reviews on your store page",
93
+ });
94
+ }
95
+ });
72
96
  /**
73
97
  * What the seller's own settings page may see.
74
98
  *
@@ -19,13 +19,15 @@ import { PRODUCT_FIELDS } from "../../../constants/field-names";
19
19
  export async function listStores(params = {}, viewer) {
20
20
  const { filters, sorts = "-createdAt", page = 1, pageSize = 24, q } = params;
21
21
  const model = { filters, sorts, page, pageSize };
22
+ // The same fix as the admin route: `q` travels as the searchTxt term, not as
23
+ // `storeName_=`, which was a prefix match on the name alone.
22
24
  const filtersArr = [];
23
- if (q)
24
- filtersArr.push(`storeName_=${q}`);
25
25
  if (filters)
26
26
  filtersArr.push(filters);
27
27
  model.filters = filtersArr.join(",") || undefined;
28
- const result = await storeRepository.listStores(model);
28
+ const result = await storeRepository.listStores(model, true, {
29
+ search: q || undefined,
30
+ });
29
31
  return { ...result, items: filterTestDataForViewer(result.items, viewer) };
30
32
  }
31
33
  /**
@@ -98,7 +98,9 @@ export declare class StoreRepository extends BaseRepository<StoreDocument> {
98
98
  /**
99
99
  * Admin: list all stores regardless of status.
100
100
  */
101
- listAllStores(model: SieveModel): Promise<FirebaseSieveResult<StoreDocument>>;
101
+ listAllStores(model: SieveModel, opts?: {
102
+ search?: string;
103
+ }): Promise<FirebaseSieveResult<StoreDocument>>;
102
104
  /**
103
105
  * Cloud Functions: atomically increment `stats.totalProducts` counter.
104
106
  */
@@ -230,8 +230,12 @@ export class StoreRepository extends BaseRepository {
230
230
  /**
231
231
  * Admin: list all stores regardless of status.
232
232
  */
233
- async listAllStores(model) {
234
- return this.listStores(model, false);
233
+ async listAllStores(model, opts) {
234
+ // 🛑 `opts` is forwarded. It was not, so the admin store list had no way to
235
+ // reach the `searchTxt` path `listStores` already implements — and both its
236
+ // caller and the public one fell back to `storeName_=`, a PREFIX match on
237
+ // the name alone. "Beyblade Arena" was unfindable by "arena".
238
+ return this.listStores(model, false, opts);
235
239
  }
236
240
  /**
237
241
  * Cloud Functions: atomically increment `stats.totalProducts` counter.
@@ -396,6 +396,12 @@ const rawTesterChecklistItems = [
396
396
  href: "/products",
397
397
  cases: [
398
398
  { key: "standard-detail", label: "Standard product detail page loads correctly", href: "/products/product-tester-standard-1" },
399
+ {
400
+ key: "tester-fixtures-hidden-from-the-public",
401
+ label: "SIGNED OUT, no product on /products has an id containing \"tester\" — the sandbox is invisible to the public",
402
+ description: "Fixed 2026-09-01. BEFORE: the public products endpoint had no viewer concept, so an ANONYMOUS caller got 25 tester fixtures in its first 40 rows — stickers-tester-sandbox-1, prizedraw-tester-sandbox-closed, live-tester-sandbox-1 — and they filled the public marketplace. AFTER: open /products in a private window and scan the grid; nothing should be named \"Tester …\". Then sign in AS A TESTER and reload — the sandbox items must come back. Both halves matter: hiding them from everyone would break the tester programme, and a fixture that never reappears is the opposite bug.",
403
+ href: "/products",
404
+ },
399
405
  {
400
406
  key: "seed-images-are-labelled-tiles",
401
407
  label: "Every seeded product image is a readable tile showing the item's own name — NOT a broken-image icon and NOT an empty grey box",
@@ -2053,6 +2059,79 @@ const rawTesterChecklistItems = [
2053
2059
  { key: "shipping-configs-crud", label: "Creating, editing, and listing shipping configs works", href: "/store/shipping-configs" },
2054
2060
  { key: "payout-methods-crud", label: "Adding, editing, and listing payout methods works", href: "/store/payout-methods" },
2055
2061
  { key: "payout-settings-page", label: "Payout settings page saves correctly", href: "/store/payout-settings" },
2062
+ {
2063
+ key: "consolidated-tabs-old-urls",
2064
+ label: "Each absorbed URL still lands on the right TAB, not just the right page",
2065
+ description: "W8 C2 folded six sibling pages into tabs. BEFORE: /store/payout-methods, /store/payout-settings, /store/shipping-configs, /store/print-center, /store/slug, /admin/permissions and /admin/settings/navigation were each their own page with their own sidebar entry. AFTER: each redirects to its host with ?tab= set, and the sidebar entry is gone. Visit all seven old URLs. Each must land on the host page with the CORRECT tab already selected — a landing on the host's FIRST tab is the failure, and it looks like success unless you check which tab is active.",
2066
+ href: "/store/payout-methods",
2067
+ },
2068
+ {
2069
+ key: "consolidated-tabs-url-writeback",
2070
+ label: "Clicking a tab updates ?tab= in the address bar",
2071
+ description: "BEFORE: the settings page read ?tab= once on load and never wrote it back, so copying the URL after clicking a tab shared the DEFAULT tab. AFTER: every tabbed page writes the tab into the URL on click. On /store/payouts click through all three tabs, watch ?tab= change, then copy the address and open it in a new tab — it must reopen on the same panel. Back must leave the page rather than walking back through the tabs.",
2072
+ href: "/store/payouts",
2073
+ },
2074
+ {
2075
+ key: "print-center-selection-deeplink",
2076
+ label: "Print labels for a selection still carries the selection through the redirect",
2077
+ description: "/store/print-center is now a redirect, and it is deep-linked from the orders and products lists as ?type=order&ids=…&autoprint=1. Select two orders on /store/orders, use the print action, and confirm the Print Centre tab opens with those two orders loaded. Arriving at an EMPTY Print Centre is the failure — it looks like a normal empty state, which is why this case names the before.",
2078
+ href: "/store/orders",
2079
+ },
2080
+ {
2081
+ key: "search-box-promises-match-corpus",
2082
+ label: "Every dashboard search box matches what its placeholder promises",
2083
+ description: "BEFORE: four boxes promised a match the data could not make. /admin/prize-draws, /admin/deals and /admin/featured each read \"or seller\" while the product search index carries no store name at all, and /admin/stores read \"slugs, or owner names\" while the route ran a STARTS-WITH match on the store name alone. Each returned an empty list that reads as \"there are none\", not as \"this box cannot search that\". AFTER: type a seller's name into /admin/prize-draws — the placeholder no longer offers it. On /admin/stores type a word from the MIDDLE of a store's name (\"arena\", for Beyblade Arena) and a word from its description: both must now return it.",
2084
+ href: "/admin/stores",
2085
+ },
2086
+ {
2087
+ key: "exact-search-boxes-commit-on-enter",
2088
+ label: "The two exact-match boxes say so and do not search while you type",
2089
+ description: "/admin/audit-log matches `actorUid` and /admin/notifications matches `userId`, both by EQUALITY — a partial id matches nothing. Both placeholders now say \"(exact)\". Type half a uid: the list must not churn per keystroke, and must not show \"no results\" until you press Enter. Each intermediate query would be a billed read that cannot match.",
2090
+ href: "/admin/audit-log",
2091
+ },
2092
+ {
2093
+ key: "consolidated-tabs-command-palette",
2094
+ label: "The absorbed surfaces are still findable by name in search",
2095
+ description: "Their sidebar entries were deleted, and the action index is derived from the sidebar — so without a deliberate entry they would vanish from search on the day they became easier to reach. Search for \"upi\", \"threshold\", \"barcode\", \"slug\" and \"permissions catalogue\". Each must return a result that opens the right TAB.",
2096
+ href: "/store/payouts",
2097
+ },
2098
+ ],
2099
+ },
2100
+ {
2101
+ pageKey: "sectionised-forms",
2102
+ pageLabel: "Forms — sections, error summary and the mobile bar",
2103
+ href: "/admin/orders",
2104
+ cases: [
2105
+ {
2106
+ key: "form-sections-save-unchanged",
2107
+ label: "Every migrated form still saves exactly the fields it saved before",
2108
+ description: "W8 C3 moved 16 hand-rolled forms onto <SectionForm>. The risk is a field that renders but no longer reaches the payload. On each of these, change EVERY field, save, reload, and confirm each value came back: the admin order editor (status, tracking number, carrier, notes), the admin user editor (role, email verified, display name, phone, tester flags, admin notes, bio, location, website, four social links), WhatsApp settings, the seller order panel, and the ad settings panel. A field that silently reverts is the failure, and nothing errors when it happens.",
2109
+ href: "/admin/orders",
2110
+ },
2111
+ {
2112
+ key: "form-error-summary-jumps",
2113
+ label: "A validation error names its section and jumps to it",
2114
+ description: "BEFORE: an invalid field produced a banner listing the problem with no way to reach it, and on the site-settings page the summary was mounted outside every form context and so displayed NOTHING at all. AFTER: submit a form with a bad value (a negative refund on an order, a non-https third-party ad URL, an AdSense id that is not ca-pub-…) and the summary must list it, tag it with its section, collapse open that section when clicked, and show a count badge on the section header.",
2115
+ href: "/admin/ads",
2116
+ },
2117
+ {
2118
+ key: "form-mobile-action-bar",
2119
+ label: "Save and Cancel appear in the pinned mobile bar, once",
2120
+ description: "On a phone-width viewport, a page-level form publishes its actions into the bottom chrome tier. Open /admin/products/new and confirm exactly ONE Save row is visible — not one in the page and one pinned. Inside a drawer or modal (the admin order editor, the review reply modal) the pinned bar must NOT appear: an overlay owns its own footer, and a viewport-fixed bar renders behind the backdrop.",
2121
+ href: "/admin/products/new",
2122
+ },
2123
+ {
2124
+ key: "form-conditional-fields-drop-values",
2125
+ label: "A field hidden by a condition does not submit a stale value",
2126
+ description: "BEFORE: hiding a control left its last value in the payload. AFTER: on the admin user editor, switch \"Is Tester\" ON, tick \"Can test admin areas\", switch \"Is Tester\" back OFF, save, reload — the admin-test flag must be false. Same shape on a shipment item: enter a price, tick \"for self use\", save; the price must not be stored.",
2127
+ href: "/admin/users",
2128
+ },
2129
+ {
2130
+ key: "site-settings-save-still-writes-everything",
2131
+ label: "Site settings still saves every tab's fields in one Save",
2132
+ description: "Its 20 tabs write ONE document through one payload, and its schema is deliberately passthrough because that payload carries keys the TypeScript interface does not declare. A closed schema here would silently strip them. Change one field on each of three different tabs, press \"Save all changes\" once, reload, and confirm all three persisted — and that fields on tabs you did NOT touch are unchanged.",
2133
+ href: "/admin/site",
2134
+ },
2056
2135
  ],
2057
2136
  },
2058
2137
  {
@@ -12,12 +12,15 @@ const __O = {
12
12
  };
13
13
  const CLS_SECTION_CARD = "border border-[var(--appkit-color-border)] rounded-xl p-[var(--appkit-space-5)]";
14
14
  import { useQuery, useQueryClient } from "@tanstack/react-query";
15
- import { Alert, Badge, Button, Div, Form, FormActions, Heading, Input, Row, Section, Span, Stack, Text, Toggle, useToast } from "../../../ui";
15
+ import { Alert, Badge, Button, Div, Heading, Input, Row, Section, Span, Stack, Text, Toggle, useToast } from "../../../ui";
16
16
  import { apiClient } from "../../../http";
17
17
  import { WHATSAPP_SELLER_ENDPOINTS } from "../../../constants/api-endpoints";
18
18
  import { ACTIONS } from "../../../_internal/shared/actions/action-registry";
19
19
  import { buildPurchaseAnnouncementMessage } from "../helpers/whatsapp";
20
- import { whatsappSettingsSchema } from "../../admin/schemas/admin-user-form";
20
+ import { whatsappConnectionSchema, } from "../../admin/schemas/admin-user-form";
21
+ import { SectionForm, useSectionFormNav, buildSectionsFromSchema, visibleValues, } from "../../shell";
22
+ import { useFormShellState, FormShellContext } from "../../../ui/forms";
23
+ import { applyZodIssues } from "../../../ui/forms/apply-zod-issues";
21
24
  import { FormErrorSummary } from "../../../ui/forms/FormErrorSummary";
22
25
  // ---------------------------------------------------------------------------
23
26
  // Sub-components
@@ -101,20 +104,40 @@ export function SellerWhatsAppSettingsView({ hasCapability }) {
101
104
  });
102
105
  const cfg = data ?? null;
103
106
  // Connection form state
104
- const [phoneNumber, setPhoneNumber] = React.useState("");
105
- const [wabaId, setWabaId] = React.useState("");
106
- const [catalogId, setCatalogId] = React.useState("");
107
- const [accessToken, setAccessToken] = React.useState("");
107
+ const [draft, setDraft] = React.useState({
108
+ phoneNumber: "",
109
+ wabaId: "",
110
+ catalogId: "",
111
+ accessToken: "",
112
+ });
108
113
  const [syncEnabled, setSyncEnabled] = React.useState(false);
109
114
  React.useEffect(() => {
110
115
  if (!cfg)
111
116
  return;
112
- setPhoneNumber(cfg.phoneNumber ?? "");
113
- setWabaId(cfg.wabaId ?? "");
114
- setCatalogId(cfg.catalogId ?? "");
115
- setAccessToken(cfg.accessToken ?? "");
117
+ setDraft({
118
+ phoneNumber: cfg.phoneNumber ?? "",
119
+ wabaId: cfg.wabaId ?? "",
120
+ catalogId: cfg.catalogId ?? "",
121
+ accessToken: cfg.accessToken ?? "",
122
+ });
116
123
  setSyncEnabled(cfg.catalogSyncEnabled ?? false);
117
124
  }, [cfg]);
125
+ /*
126
+ * The access token needs a renderer: it is a `text` field to the schema, and
127
+ * the control is a masked input with a reveal that must stay suppressed while
128
+ * the field still holds the server's sentinel.
129
+ */
130
+ const sections = React.useMemo(() => buildSectionsFromSchema(whatsappConnectionSchema, {
131
+ renderers: {
132
+ accessToken: ({ values, onChange }) => (_jsx(MaskedTokenInput, { label: "System User Access Token", value: values.accessToken ?? "", onChange: (v) => onChange({ accessToken: v }), placeholder: "EAAxxxxxxxx\u2026", helperText: "Long-lived token with WhatsApp Business permissions" })),
133
+ },
134
+ }), []);
135
+ const nav = useSectionFormNav(sections, draft, { scope: "store:whatsapp" });
136
+ const form = useFormShellState(whatsappConnectionSchema, {
137
+ sections: nav.sectionMeta,
138
+ onGoToSection: nav.goToSection,
139
+ fieldToSectionIndex: nav.fieldToSectionIndex,
140
+ });
118
141
  /*
119
142
  * The CONNECTION form. Credentials only.
120
143
  *
@@ -131,16 +154,17 @@ export function SellerWhatsAppSettingsView({ hasCapability }) {
131
154
  const saveMutation = useApiMutation({
132
155
  errorMessage: "Failed to save settings.",
133
156
  mutationFn: async () => {
157
+ const v = visibleValues(whatsappConnectionSchema, draft);
134
158
  const payload = {};
135
- if (phoneNumber)
136
- payload.phoneNumber = phoneNumber;
137
- if (wabaId)
138
- payload.wabaId = wabaId;
139
- if (catalogId)
140
- payload.catalogId = catalogId;
159
+ if (v.phoneNumber)
160
+ payload.phoneNumber = v.phoneNumber;
161
+ if (v.wabaId)
162
+ payload.wabaId = v.wabaId;
163
+ if (v.catalogId)
164
+ payload.catalogId = v.catalogId;
141
165
  // Only send accessToken if user has typed a new value (not the masked placeholder)
142
- if (accessToken && accessToken !== MASKED_TOKEN)
143
- payload.accessToken = accessToken;
166
+ if (v.accessToken && v.accessToken !== MASKED_TOKEN)
167
+ payload.accessToken = v.accessToken;
144
168
  return apiClient.put(WHATSAPP_SELLER_ENDPOINTS.SETTINGS, payload);
145
169
  },
146
170
  onSuccess: () => {
@@ -224,10 +248,17 @@ export function SellerWhatsAppSettingsView({ hasCapability }) {
224
248
  ? cfg?.connected === true
225
249
  : Boolean(cfg?.[step.checkKey]);
226
250
  return (_jsxs(Row, { gap: "sm", align: "start", children: [_jsx(Row, { textWeight: "bold", textSize: "xs", className: `flex-shrink-0 w-6 h-6 mt-0.5 ${done ? "bg-[var(--appkit-color-success)] text-white" : "bg-[var(--appkit-color-surface-elevated)] text-[var(--appkit-color-text-muted)] dark:text-[var(--appkit-color-text-faint)]"}`, align: "center", justify: "center", rounded: "full", children: done ? "✓" : step.n }), _jsxs(Div, { children: [_jsx(Text, { size: "sm", weight: "medium", color: "primary", children: step.title }), _jsx(Text, { className: "mt-0.5", color: "muted", size: "xs", children: step.body })] })] }, step.n));
227
- }) })] }), _jsxs(Section, { className: CLS_SECTION_CARD, children: [_jsxs(Row, { justify: "between", align: "center", className: "mb-4", children: [_jsx(Heading, { level: 2, size: "sm", weight: "semibold", color: "primary", children: "Connection" }), isLoading ? null : cfg?.connected ? (_jsx(Badge, { variant: "success", children: "Connected" })) : (_jsx(Badge, { variant: "default", children: "Not configured" }))] }), _jsxs(Form, { schema: whatsappSettingsSchema, onSubmit: (e) => {
228
- e.preventDefault();
229
- saveMutation.mutate();
230
- }, spacing: "md", children: [_jsx(FormErrorSummary, {}), _jsx(Input, { label: "WhatsApp Business Phone Number", value: phoneNumber, onChange: (e) => setPhoneNumber(e.target.value), placeholder: "919876543210", helperText: "Digits only, include country code (e.g. 91 for India)" }), _jsx(Input, { label: "WABA ID (WhatsApp Business Account ID)", value: wabaId, onChange: (e) => setWabaId(e.target.value), placeholder: "123456789012345", helperText: "From Meta Business Manager \u2192 WhatsApp Accounts" }), _jsx(Input, { label: "Catalog ID", value: catalogId, onChange: (e) => setCatalogId(e.target.value), placeholder: "987654321098765", helperText: "From Meta Commerce Manager \u2192 Catalogs" }), _jsx(MaskedTokenInput, { label: "System User Access Token", value: accessToken, onChange: setAccessToken, placeholder: "EAAxxxxxxxx\u2026", helperText: "Long-lived token with WhatsApp Business permissions" }), _jsx(FormActions, { align: "right", children: _jsx(Button, { type: "submit", isLoading: saveMutation.isPending, disabled: saveMutation.isPending, children: saveMutation.isPending ? "Saving…" : ACTIONS.STORE["whatsapp-connect"].label }) })] })] }), _jsxs(Section, { className: CLS_SECTION_CARD, children: [_jsx(Heading, { level: 2, className: "mb-4", color: "primary", size: "sm", weight: "semibold", children: "Catalog Sync" }), _jsxs(Row, { justify: "between", align: "center", className: "mb-4", children: [_jsxs(Div, { children: [_jsx(Text, { size: "sm", color: "muted", children: "Enable catalog sync" }), _jsx(Text, { size: "xs", color: "muted", children: "When enabled, your published standard products can be synced to WhatsApp." })] }), _jsx(Toggle, { checked: syncEnabled, onChange: handleSyncToggle, disabled: !cfg?.connected || syncToggleMutation.isPending })] }), cfg?.lastCatalogSyncAt && (_jsxs(Row, { color: "muted", textSize: "xs", gap: "sm", align: "center", className: "mb-3", children: [_jsxs(Text, { children: ["Last sync: ", new Date(cfg.lastCatalogSyncAt).toLocaleString("en-IN")] }), cfg.lastSyncCount !== undefined && _jsxs(Text, { children: ["\u00B7 ", cfg.lastSyncCount, " products"] }), cfg.lastSyncStatus && (_jsx(Badge, { variant: STATUS_VARIANT[cfg.lastSyncStatus] ?? "default", children: cfg.lastSyncStatus }))] })), _jsx(Text, { className: "mb-3", color: "faint", size: "xs", children: "Only published standard products are synced. Auctions and pre-orders are excluded. Batches of up to 50 products per call." }), _jsxs(Row, { gap: "3", children: [_jsx(Button, { onClick: () => syncMutation.mutate(), isLoading: syncMutation.isPending, disabled: !cfg?.connected || !syncEnabled || syncMutation.isPending, variant: "secondary", children: syncMutation.isPending ? "Syncing…" : ACTIONS.STORE["whatsapp-catalog-sync"].label }), _jsx(Button, { onClick: () => void startImport(), isLoading: importing, disabled: !cfg?.connected || !syncEnabled || importing, variant: "secondary", children: importing ? "Importing…" : ACTIONS.STORE["whatsapp-catalog-import"].label })] }), _jsxs(Text, { className: "mt-3", color: "faint", size: "xs", children: [_jsx(Span, { weight: "bold", children: "Push" }), " sends your published standard products to WhatsApp.", " ", _jsx(Span, { weight: "bold", children: "Import" }), " creates draft products from your WhatsApp catalog. Products are matched by slug in the description field."] })] }), _jsxs(Section, { className: CLS_SECTION_CARD, children: [_jsx(Heading, { level: 2, className: "mb-2", color: "primary", size: "sm", weight: "semibold", children: "Purchase Announcement Preview" }), _jsx(Text, { className: "mb-3", color: "muted", size: "xs", children: "This message is sent automatically to your phone and the platform admin when a new order is placed." }), _jsx(Div, { textSize: "sm", className: "font-mono", color: "primary", surface: "muted", padding: "inline", rounded: "lg", children: buildPurchaseAnnouncementMessage({
251
+ }) })] }), _jsxs(Section, { className: CLS_SECTION_CARD, children: [_jsxs(Row, { justify: "between", align: "center", className: "mb-4", children: [_jsx(Heading, { level: 2, size: "sm", weight: "semibold", color: "primary", children: "Connection" }), isLoading ? null : cfg?.connected ? (_jsx(Badge, { variant: "success", children: "Connected" })) : (_jsx(Badge, { variant: "default", children: "Not configured" }))] }), _jsxs(FormShellContext.Provider, { value: form.shellCtx, children: [_jsx(FormErrorSummary, {}), _jsx(SectionForm, { sections: sections, values: draft, onChange: (partial) => setDraft((d) => ({ ...d, ...partial })), onSubmit: () => {
252
+ // SectionForm scrolls to the first bad section and then submits
253
+ // regardless, so the guard lives here.
254
+ form.clearErrors();
255
+ const parsed = whatsappConnectionSchema.safeParse(visibleValues(whatsappConnectionSchema, draft));
256
+ if (!parsed.success) {
257
+ applyZodIssues(parsed.error.issues, form.setFieldError);
258
+ return;
259
+ }
260
+ saveMutation.mutate();
261
+ }, onValidationChange: () => form.validate(draft), schema: whatsappConnectionSchema, openIds: nav.openIds, onOpenChange: nav.setOpenIds, submitLabel: ACTIONS.STORE["whatsapp-connect"].label, isLoading: saveMutation.isPending })] })] }), _jsxs(Section, { className: CLS_SECTION_CARD, children: [_jsx(Heading, { level: 2, className: "mb-4", color: "primary", size: "sm", weight: "semibold", children: "Catalog Sync" }), _jsxs(Row, { justify: "between", align: "center", className: "mb-4", children: [_jsxs(Div, { children: [_jsx(Text, { size: "sm", color: "muted", children: "Enable catalog sync" }), _jsx(Text, { size: "xs", color: "muted", children: "When enabled, your published standard products can be synced to WhatsApp." })] }), _jsx(Toggle, { checked: syncEnabled, onChange: handleSyncToggle, disabled: !cfg?.connected || syncToggleMutation.isPending })] }), cfg?.lastCatalogSyncAt && (_jsxs(Row, { color: "muted", textSize: "xs", gap: "sm", align: "center", className: "mb-3", children: [_jsxs(Text, { children: ["Last sync: ", new Date(cfg.lastCatalogSyncAt).toLocaleString("en-IN")] }), cfg.lastSyncCount !== undefined && _jsxs(Text, { children: ["\u00B7 ", cfg.lastSyncCount, " products"] }), cfg.lastSyncStatus && (_jsx(Badge, { variant: STATUS_VARIANT[cfg.lastSyncStatus] ?? "default", children: cfg.lastSyncStatus }))] })), _jsx(Text, { className: "mb-3", color: "faint", size: "xs", children: "Only published standard products are synced. Auctions and pre-orders are excluded. Batches of up to 50 products per call." }), _jsxs(Row, { gap: "3", children: [_jsx(Button, { onClick: () => syncMutation.mutate(), isLoading: syncMutation.isPending, disabled: !cfg?.connected || !syncEnabled || syncMutation.isPending, variant: "secondary", children: syncMutation.isPending ? "Syncing…" : ACTIONS.STORE["whatsapp-catalog-sync"].label }), _jsx(Button, { onClick: () => void startImport(), isLoading: importing, disabled: !cfg?.connected || !syncEnabled || importing, variant: "secondary", children: importing ? "Importing…" : ACTIONS.STORE["whatsapp-catalog-import"].label })] }), _jsxs(Text, { className: "mt-3", color: "faint", size: "xs", children: [_jsx(Span, { weight: "bold", children: "Push" }), " sends your published standard products to WhatsApp.", " ", _jsx(Span, { weight: "bold", children: "Import" }), " creates draft products from your WhatsApp catalog. Products are matched by slug in the description field."] })] }), _jsxs(Section, { className: CLS_SECTION_CARD, children: [_jsx(Heading, { level: 2, className: "mb-2", color: "primary", size: "sm", weight: "semibold", children: "Purchase Announcement Preview" }), _jsx(Text, { className: "mb-3", color: "muted", size: "xs", children: "This message is sent automatically to your phone and the platform admin when a new order is placed." }), _jsx(Div, { textSize: "sm", className: "font-mono", color: "primary", surface: "muted", padding: "inline", rounded: "lg", children: buildPurchaseAnnouncementMessage({
231
262
  buyerName: "Ravi K.",
232
263
  firstItemName: "Charizard PSA 9",
233
264
  additionalItemCount: 2,
package/dist/index.d.ts CHANGED
@@ -3220,5 +3220,9 @@ export { SITE_SETTINGS_ENTRIES, deriveSettingsEntries, } from "./features/search
3220
3220
  export type { SettingsIndexSource } from "./features/search/action-index/settings-entries";
3221
3221
  export { SITE_SETTINGS_TABS, SITE_SETTINGS_TAB_IDS, isSiteSettingsTabId, } from "./features/admin/constants/site-settings-tabs";
3222
3222
  export type { SiteSettingsTab, SiteSettingsTabId } from "./features/admin/constants/site-settings-tabs";
3223
+ export { PAYOUTS_TABS, isPayoutsTabId, SHIPPING_TABS, isShippingTabId, FULFILLMENT_TABS, isFulfillmentTabId, STOREFRONT_TABS, isStorefrontTabId, } from "./features/seller/constants/store-tabs";
3224
+ export type { StoreDashboardTab, PayoutsTabId, ShippingTabId, FulfillmentTabId, StorefrontTabId, } from "./features/seller/constants/store-tabs";
3225
+ export { ROLES_TABS, isRolesTabId, ADMIN_SETTINGS_TABS, isAdminSettingsTabId, } from "./features/admin/constants/admin-page-tabs";
3226
+ export type { AdminPageTab, RolesTabId, AdminSettingsTabId, } from "./features/admin/constants/admin-page-tabs";
3223
3227
  export { validateActionIndexEntry, normaliseRoute, } from "./features/search/action-index/validate-entry";
3224
3228
  export type { ActionIndexValidationContext, ActionIndexValidationIssue, } from "./features/search/action-index/validate-entry";
package/dist/index.js CHANGED
@@ -5626,4 +5626,6 @@ export { deriveNavEntries, deriveQuickActionEntries, buildActionIndexBase, } fro
5626
5626
  export { matchesNavQuery } from "./_internal/shared/features/layout/matchesNavQuery";
5627
5627
  export { SITE_SETTINGS_ENTRIES, deriveSettingsEntries, } from "./features/search/action-index/settings-entries";
5628
5628
  export { SITE_SETTINGS_TABS, SITE_SETTINGS_TAB_IDS, isSiteSettingsTabId, } from "./features/admin/constants/site-settings-tabs";
5629
+ export { PAYOUTS_TABS, isPayoutsTabId, SHIPPING_TABS, isShippingTabId, FULFILLMENT_TABS, isFulfillmentTabId, STOREFRONT_TABS, isStorefrontTabId, } from "./features/seller/constants/store-tabs";
5630
+ export { ROLES_TABS, isRolesTabId, ADMIN_SETTINGS_TABS, isAdminSettingsTabId, } from "./features/admin/constants/admin-page-tabs";
5629
5631
  export { validateActionIndexEntry, normaliseRoute, } from "./features/search/action-index/validate-entry";
@@ -36,5 +36,7 @@ export { navItemId } from "../_internal/shared/features/layout/types";
36
36
  export { matchesNavQuery } from "../_internal/shared/features/layout/matchesNavQuery";
37
37
  export { buildActionIndexBase, deriveNavEntries, deriveQuickActionEntries, } from "../features/search/action-index/derive";
38
38
  export { deriveSettingsEntries } from "../features/search/action-index/settings-entries";
39
+ export { deriveConsolidatedTabEntries, CONSOLIDATED_TAB_SURFACES, } from "../features/search/action-index/tab-entries";
40
+ export type { ConsolidatedTabSurface, ConsolidatedTabHrefs, } from "../features/search/action-index/tab-entries";
39
41
  export { mergeActionIndex, projectActionIndexForViewer, } from "../features/search/action-index/types";
40
42
  export type { ActionIndexEntry, ActionIndexControl, ActionIndexOverride, } from "../features/search/action-index/types";
@@ -62,4 +62,5 @@ export { matchesNavQuery } from "../_internal/shared/features/layout/matchesNavQ
62
62
  */
63
63
  export { buildActionIndexBase, deriveNavEntries, deriveQuickActionEntries, } from "../features/search/action-index/derive";
64
64
  export { deriveSettingsEntries } from "../features/search/action-index/settings-entries";
65
+ export { deriveConsolidatedTabEntries, CONSOLIDATED_TAB_SURFACES, } from "../features/search/action-index/tab-entries";
65
66
  export { mergeActionIndex, projectActionIndexForViewer, } from "../features/search/action-index/types";
@@ -0,0 +1,19 @@
1
+ export interface UseTabParamOptions {
2
+ /**
3
+ * A locale-aware router, when the caller has one. Without it the plain
4
+ * `next/navigation` router drops the locale prefix on replace.
5
+ */
6
+ router?: {
7
+ replace: (url: string) => void;
8
+ };
9
+ pathname?: string;
10
+ }
11
+ /**
12
+ * The active tab, kept in `?tab=`.
13
+ *
14
+ * `isValid` is the page's own type guard — `isSiteSettingsTabId`,
15
+ * `isPayoutsTabId`, and so on — so an unknown value in the URL is IGNORED
16
+ * rather than written into state. That is what makes a stale bookmark open the
17
+ * default tab instead of rendering an empty panel.
18
+ */
19
+ export declare function useTabParam<T extends string>(isValid: (value: string | null | undefined) => value is T, fallback: T, options?: UseTabParamOptions): [T, (next: string) => void];