@mohasinac/appkit 4.18.0 → 4.19.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.
@@ -0,0 +1,159 @@
1
+ /*
2
+ * WHY: `AdminSiteSettingsView` is 1826 lines with **21 `<Form>` tags and no
3
+ * schema on any of them**. All 21 are tabs over one document and save
4
+ * through a single `buildFullPayload()` → one PUT, so this is one form
5
+ * wearing twenty-one hats, entirely unvalidated.
6
+ *
7
+ * Live consequences already documented for this exact payload:
8
+ * · `announcementBar` was saved under the key `text` until 2026-08-24 —
9
+ * a key no renderer has ever read — so admin-authored announcements
10
+ * saved successfully and never appeared.
11
+ * · `adSettings`, `integrations`, `platformLimits`, `tagline` and
12
+ * `favicon` were written here while undeclared on
13
+ * `SiteSettingsDocument`, which is how an unmasked ad-credentials
14
+ * blob reached anonymous callers (Recurrent Root Cause #70).
15
+ *
16
+ * WHAT: A schema that validates the fields with real rules and **passes the
17
+ * rest through untouched**.
18
+ *
19
+ * ## 🛑 This one is `.passthrough()`, deliberately, and that is the whole point
20
+ *
21
+ * Everywhere else in this rework an update schema is `.strict()`. Here that
22
+ * would be actively dangerous. `buildFullPayload()` demonstrably writes keys
23
+ * the TypeScript interface does not declare, and a closed schema would SILENTLY
24
+ * STRIP every one of them on the next save — turning a validation improvement
25
+ * into the largest single act of data loss in the codebase. That is the
26
+ * `productBaseSchema` failure, aimed at the site's entire configuration.
27
+ *
28
+ * So: constrain what can be reasoned about (percentages that must be 0–100,
29
+ * money that must be non-negative, a contact email that must be an email), and
30
+ * let the rest pass. Tightening any group is a separate, evidence-led change —
31
+ * per the data-integrity protocol, only after a full-collection round-trip
32
+ * diff proves the schema is a superset of what is stored.
33
+ *
34
+ * EXPORTS: siteSettingsFormSchema, type SiteSettingsFormValues
35
+ *
36
+ * @tag domain:admin
37
+ * @tag layer:schema
38
+ * @tag pattern:none
39
+ * @tag access:isomorphic
40
+ * @tag consumers:AdminSiteSettingsView
41
+ * @tag sideEffects:none
42
+ */
43
+ import { z } from "zod";
44
+ /** A percentage a human types into a settings box. */
45
+ const percent = (label) => z.coerce
46
+ .number({ invalid_type_error: `${label} must be a number.` })
47
+ .min(0, `${label} cannot be negative.`)
48
+ .max(100, `${label} cannot exceed 100%.`)
49
+ .optional();
50
+ /** A non-negative rupee amount. */
51
+ const money = (label) => z.coerce
52
+ .number({ invalid_type_error: `${label} must be a number.` })
53
+ .min(0, `${label} cannot be negative.`)
54
+ .optional();
55
+ /** An optional email box — blank is allowed, malformed is not. */
56
+ const optionalEmail = z
57
+ .string()
58
+ .trim()
59
+ .toLowerCase()
60
+ .email("That doesn't look like an email address.")
61
+ .optional()
62
+ .or(z.literal(""));
63
+ /** An optional URL box — blank is allowed, malformed is not. */
64
+ const optionalUrl = z
65
+ .string()
66
+ .trim()
67
+ .url("Enter a full URL, including https://.")
68
+ .optional()
69
+ .or(z.literal(""));
70
+ export const siteSettingsFormSchema = z
71
+ .object({
72
+ /*
73
+ * 🛑 `message` is the canonical key. Naming it here is what stops the
74
+ * `text` mistake recurring: a payload carrying `text` still passes (the
75
+ * schema is open), but the one key a renderer reads is now declared, and
76
+ * the form is typed against it.
77
+ */
78
+ announcementBar: z
79
+ .object({
80
+ message: z.string().trim().max(300, "Keep the announcement under 300 characters.").optional(),
81
+ enabled: z.boolean().optional(),
82
+ link: optionalUrl,
83
+ })
84
+ .passthrough()
85
+ .optional(),
86
+ contact: z
87
+ .object({
88
+ email: optionalEmail,
89
+ supportEmail: optionalEmail,
90
+ phone: z.string().trim().max(40).optional(),
91
+ })
92
+ .passthrough()
93
+ .optional(),
94
+ /*
95
+ * The commission block. Every one of these is a percentage or an amount
96
+ * that multiplies against real orders, and all were free text — a stray
97
+ * keystroke in `platformFeePercent` changes what every seller is charged.
98
+ */
99
+ commissions: z
100
+ .object({
101
+ platformFeePercent: percent("Platform fee"),
102
+ gatewayFeePercent: percent("Gateway fee"),
103
+ platformFeeMax: money("Platform fee cap"),
104
+ minPayoutAmount: money("Minimum payout"),
105
+ payoutHoldDays: z.coerce.number().int().min(0).max(365).optional(),
106
+ codHandlingFee: money("COD handling fee"),
107
+ codDepositPercent: percent("COD deposit"),
108
+ })
109
+ .passthrough()
110
+ .optional(),
111
+ laborRate: z
112
+ .object({ hourlyRate: money("Hourly rate") })
113
+ .passthrough()
114
+ .optional(),
115
+ emi: z
116
+ .object({
117
+ surchargePercent: percent("EMI surcharge"),
118
+ surchargeSellerSharePercent: percent("Seller share"),
119
+ minOrderAmount: money("Minimum EMI order"),
120
+ })
121
+ .passthrough()
122
+ .optional(),
123
+ gst: z
124
+ .object({
125
+ // 15 characters, the statutory format. A wrong GSTIN appears on every
126
+ // invoice the platform issues.
127
+ gstin: z
128
+ .string()
129
+ .trim()
130
+ .toUpperCase()
131
+ .regex(/^\d{2}[A-Z]{5}\d{4}[A-Z]\d[A-Z\d][A-Z\d]$/, "That is not a valid 15-character GSTIN.")
132
+ .optional()
133
+ .or(z.literal("")),
134
+ ratePercent: percent("GST rate"),
135
+ })
136
+ .passthrough()
137
+ .optional(),
138
+ auctionConfig: z
139
+ .object({
140
+ minBidIncrement: money("Minimum bid increment"),
141
+ extensionMinutes: z.coerce.number().int().min(0).max(1440).optional(),
142
+ })
143
+ .passthrough()
144
+ .optional(),
145
+ seo: z
146
+ .object({
147
+ title: z.string().trim().max(70, "Search engines truncate titles past ~70 characters.").optional(),
148
+ description: z
149
+ .string()
150
+ .trim()
151
+ .max(160, "Search engines truncate descriptions past ~160 characters.")
152
+ .optional(),
153
+ })
154
+ .passthrough()
155
+ .optional(),
156
+ })
157
+ // See the header: OPEN on purpose. `buildFullPayload()` writes keys the
158
+ // document interface does not declare, and closing this would delete them.
159
+ .passthrough();
@@ -13,6 +13,7 @@ import { useLiveAuctionBid } from "../hooks/useLiveAuctionBid";
13
13
  import { AUCTION_BUYOUT_WINDOW_MINUTES } from "../../../_internal/shared/checkout/lanes";
14
14
  import { BID_PRESET_MULTIPLIERS, isBuyNowAvailable, resolveMinBid, resolveMinBidIncrement, } from "../../../_internal/shared/features/auctions/config";
15
15
  import { normalizeError } from "../../../errors/normalize";
16
+ import { FormErrorSummary } from "../../../ui/forms/FormErrorSummary";
16
17
  const __P = {
17
18
  p5: "p-[var(--appkit-space-5)]",
18
19
  };
@@ -179,10 +180,10 @@ export function PlaceBidFormClient({ productId, currentBid: ssrCurrentBid, start
179
180
  }
180
181
  });
181
182
  }
182
- return (_jsxs(Stack, { className: `${__P.p5}`, border: "subtle", gap: "md", rounded: "xl", surface: "muted", children: [_jsxs(Stack, { gap: "xs", children: [_jsxs(Row, { justify: "between", align: "center", children: [_jsx(Text, { size: "xs", color: "muted", children: hasBids ? "Current bid" : "Starting bid" }), _jsx(Text, { size: "xs", color: "muted", children: hasBids ? "Starting bid" : "Minimum bid" })] }), _jsxs(Row, { justify: "between", align: "baseline", children: [_jsx(Span, { size: "xl", weight: "bold", className: "text-primary-600 dark:text-primary-400", children: formatCurrency(currentBid, currency) }), _jsx(Span, { size: "sm", color: "muted", children: formatCurrency(hasBids ? startingBid : minBid, currency) })] }), _jsxs(Text, { size: "xs", color: "faint", children: [bidCount, " ", bidCount === 1 ? "bid" : "bids", " \u00B7 min increment", " ", formatCurrency(minBidIncrement, currency)] })] }), _jsx(LoginRequiredModal, { isOpen: showLoginModal, onClose: () => setShowLoginModal(false), message: "You need to be signed in to place a bid. Please log in or create an account to continue." }), _jsx(Form, { onSubmit: (e) => {
183
+ return (_jsxs(Stack, { className: `${__P.p5}`, border: "subtle", gap: "md", rounded: "xl", surface: "muted", children: [_jsxs(Stack, { gap: "xs", children: [_jsxs(Row, { justify: "between", align: "center", children: [_jsx(Text, { size: "xs", color: "muted", children: hasBids ? "Current bid" : "Starting bid" }), _jsx(Text, { size: "xs", color: "muted", children: hasBids ? "Starting bid" : "Minimum bid" })] }), _jsxs(Row, { justify: "between", align: "baseline", children: [_jsx(Span, { size: "xl", weight: "bold", className: "text-primary-600 dark:text-primary-400", children: formatCurrency(currentBid, currency) }), _jsx(Span, { size: "sm", color: "muted", children: formatCurrency(hasBids ? startingBid : minBid, currency) })] }), _jsxs(Text, { size: "xs", color: "faint", children: [bidCount, " ", bidCount === 1 ? "bid" : "bids", " \u00B7 min increment", " ", formatCurrency(minBidIncrement, currency)] })] }), _jsx(LoginRequiredModal, { isOpen: showLoginModal, onClose: () => setShowLoginModal(false), message: "You need to be signed in to place a bid. Please log in or create an account to continue." }), _jsx(Form, { schema: schema, onSubmit: (e) => {
183
184
  // handler wired through render-prop below — keeps setFieldError in scope
184
185
  e.preventDefault();
185
- }, children: ({ setFieldError, clearErrors }) => (_jsxs(Stack, { gap: "sm", children: [_jsxs(Row, { gap: "xs", wrap: true, role: "radiogroup", "aria-label": "Bid amount preset", children: [BID_PRESET_MULTIPLIERS.map((n) => {
186
+ }, children: ({ setFieldError, clearErrors }) => (_jsxs(Stack, { gap: "sm", children: [_jsx(FormErrorSummary, {}), _jsxs(Row, { gap: "xs", wrap: true, role: "radiogroup", "aria-label": "Bid amount preset", children: [BID_PRESET_MULTIPLIERS.map((n) => {
186
187
  const amount = presetAmount(n);
187
188
  const delta = amount - currentBid;
188
189
  // `delta` is 0 only for the opening preset on a bid-free
@@ -16,6 +16,8 @@ import { apiClient } from "../../../http";
16
16
  import { WHATSAPP_SELLER_ENDPOINTS } from "../../../constants/api-endpoints";
17
17
  import { ACTIONS } from "../../../_internal/shared/actions/action-registry";
18
18
  import { buildPurchaseAnnouncementMessage } from "../helpers/whatsapp";
19
+ import { whatsappSettingsSchema } from "../../admin/schemas/admin-user-form";
20
+ import { FormErrorSummary } from "../../../ui/forms/FormErrorSummary";
19
21
  // ---------------------------------------------------------------------------
20
22
  // Sub-components
21
23
  // ---------------------------------------------------------------------------
@@ -187,10 +189,10 @@ export function SellerWhatsAppSettingsView({ hasCapability }) {
187
189
  ? cfg?.connected === true
188
190
  : Boolean(cfg?.[step.checkKey]);
189
191
  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));
190
- }) })] }), _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, { onSubmit: (e) => {
192
+ }) })] }), _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) => {
191
193
  e.preventDefault();
192
194
  saveMutation.mutate();
193
- }, spacing: "md", children: [_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: setSyncEnabled, disabled: !cfg?.connected })] }), 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({
195
+ }, 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: setSyncEnabled, disabled: !cfg?.connected })] }), 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({
194
196
  buyerName: "Ravi K.",
195
197
  firstItemName: "Charizard PSA 9",
196
198
  additionalItemCount: 2,
package/dist/index.d.ts CHANGED
@@ -3291,3 +3291,6 @@ export { scamReportFormSchema } from "./features/scams/schemas/report-form";
3291
3291
  export { sublistingCategoryFormSchema } from "./features/store-extensions/schemas/sublisting-category-form";
3292
3292
  export { adminAddressFormSchema, adminStoreUpdateSchema, announcementBarSchema, } from "./features/admin/schemas/admin-editor-forms";
3293
3293
  export { adminOrderUpdateSchema, employeeInviteSchema, hardBanReasonSchema, featureFlagsUpdateSchema, adminAdFormSchema, } from "./features/admin/schemas/admin-ops-forms";
3294
+ export { changeEmailSchema } from "./features/account/schemas/index";
3295
+ export { adminUserUpdateSchema, whatsappSettingsSchema, lotteryPullSchema, } from "./features/admin/schemas/admin-user-form";
3296
+ export { siteSettingsFormSchema } from "./features/admin/schemas/site-settings-form";
package/dist/index.js CHANGED
@@ -5700,3 +5700,6 @@ export { scamReportFormSchema } from "./features/scams/schemas/report-form";
5700
5700
  export { sublistingCategoryFormSchema } from "./features/store-extensions/schemas/sublisting-category-form";
5701
5701
  export { adminAddressFormSchema, adminStoreUpdateSchema, announcementBarSchema, } from "./features/admin/schemas/admin-editor-forms";
5702
5702
  export { adminOrderUpdateSchema, employeeInviteSchema, hardBanReasonSchema, featureFlagsUpdateSchema, adminAdFormSchema, } from "./features/admin/schemas/admin-ops-forms";
5703
+ export { changeEmailSchema } from "./features/account/schemas/index";
5704
+ export { adminUserUpdateSchema, whatsappSettingsSchema, lotteryPullSchema, } from "./features/admin/schemas/admin-user-form";
5705
+ export { siteSettingsFormSchema } from "./features/admin/schemas/site-settings-form";