@mohasinac/appkit 4.21.0 → 4.22.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 (38) hide show
  1. package/dist/client.d.ts +5 -0
  2. package/dist/client.js +3 -0
  3. package/dist/features/account/components/AddressForm.js +45 -29
  4. package/dist/features/admin/components/AdminBundleEditorView.js +3 -0
  5. package/dist/features/auctions/components/PlaceBidFormClient.js +3 -1
  6. package/dist/features/catalogue/components/CatalogueItemEditorView.js +3 -1
  7. package/dist/features/layout/BottomActions.js +18 -1
  8. package/dist/features/layout/BottomActionsContext.d.ts +19 -0
  9. package/dist/features/layout/BottomActionsContext.js +37 -0
  10. package/dist/features/layout/hooks/useBottomActions.d.ts +10 -0
  11. package/dist/features/layout/hooks/useBottomActions.js +48 -11
  12. package/dist/features/layout/hooks/useFormBottomActions.d.ts +57 -0
  13. package/dist/features/layout/hooks/useFormBottomActions.js +89 -0
  14. package/dist/features/layout/index.d.ts +2 -0
  15. package/dist/features/layout/index.js +1 -0
  16. package/dist/features/products/components/MakeOfferButton.js +5 -3
  17. package/dist/features/shell/QuickFormDrawer.js +15 -1
  18. package/dist/features/shell/SectionForm.d.ts +16 -2
  19. package/dist/features/shell/SectionForm.js +28 -2
  20. package/dist/features/shipments/components/AdminShipmentEditorView.js +3 -1
  21. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +36 -0
  22. package/dist/index.d.ts +3 -0
  23. package/dist/index.js +4 -0
  24. package/dist/ui/components/Form.d.ts +22 -1
  25. package/dist/ui/components/Form.js +30 -2
  26. package/dist/ui/components/Modal.js +4 -3
  27. package/dist/ui/components/SideDrawer.js +5 -4
  28. package/dist/ui/components/overlay-context.d.ts +21 -0
  29. package/dist/ui/components/overlay-context.js +25 -0
  30. package/dist/ui/forms/FormErrorList.d.ts +29 -0
  31. package/dist/ui/forms/FormErrorList.js +36 -0
  32. package/dist/ui/forms/FormErrorSummary.d.ts +8 -0
  33. package/dist/ui/forms/FormErrorSummary.js +15 -8
  34. package/dist/ui/forms/FormShell.d.ts +25 -1
  35. package/dist/ui/forms/FormShell.js +39 -4
  36. package/dist/ui/forms/index.d.ts +2 -0
  37. package/dist/ui/forms/index.js +1 -0
  38. package/package.json +1 -1
package/dist/client.d.ts CHANGED
@@ -164,6 +164,11 @@ export type { ColorPickerFieldProps } from "./ui/forms";
164
164
  export { ColorPickerField } from "./ui/forms";
165
165
  export type { FormErrorSummaryProps } from "./ui/forms";
166
166
  export { FormErrorSummary } from "./ui/forms";
167
+ export type { FormErrorListProps } from "./ui/forms";
168
+ export { FormErrorList } from "./ui/forms";
169
+ export { useFormBottomActions } from "./features/layout/index";
170
+ export type { UseFormBottomActionsOptions } from "./features/layout/index";
171
+ export { OverlayContext, useIsInsideOverlay } from "./ui/components/overlay-context";
167
172
  export { Select } from "./ui/components/Select";
168
173
  export type { SelectOption, SelectProps } from "./ui/components/Select";
169
174
  export { Heading } from "./ui/components/Typography";
package/dist/client.js CHANGED
@@ -222,6 +222,9 @@ export { FieldCheckbox } from "./ui/forms";
222
222
  export { FieldTextarea } from "./ui/forms";
223
223
  export { ColorPickerField } from "./ui/forms";
224
224
  export { FormErrorSummary } from "./ui/forms";
225
+ export { FormErrorList } from "./ui/forms";
226
+ export { useFormBottomActions } from "./features/layout/index";
227
+ export { OverlayContext, useIsInsideOverlay } from "./ui/components/overlay-context";
225
228
  export { Select } from "./ui/components/Select";
226
229
  export { Heading } from "./ui/components/Typography";
227
230
  export { Label, Text, Span, Caption } from "./ui/components/Typography";
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { normalizeError } from "../../../errors/normalize";
4
- import { useEffect, useState } from "react";
4
+ import { useCallback, useEffect, useState } from "react";
5
5
  import { z } from "zod";
6
6
  import { Button, Checkbox, FormField, FormGroup, Row, useToast } from "../../../ui";
7
7
  import { Form } from "../../../ui/components/Form";
@@ -64,7 +64,43 @@ export function AddressForm({ initialData, onSubmit, onCancel, isLoading = false
64
64
  country: initialData?.country || defaultCountry,
65
65
  isDefault: initialData?.isDefault || false,
66
66
  });
67
- const { shellCtx, validate } = useFormShellState(addressFormSchema);
67
+ const { shellCtx, validate, setFieldError, clearErrors, markSubmitAttempted } = useFormShellState(addressFormSchema);
68
+ /*
69
+ * ONE submit path, shared by the inline Save button and the pinned mobile
70
+ * bar. Two copies would be two chances for the bar to skip a check the
71
+ * button performs — and the bar is the one a phone user actually presses,
72
+ * since the inline row sits below the fold on a form this long.
73
+ */
74
+ const submitAddress = useCallback(async (h) => {
75
+ const setErr = h?.setFieldError ?? setFieldError;
76
+ const clear = h?.clearErrors ?? clearErrors;
77
+ markSubmitAttempted();
78
+ clear();
79
+ const errs = {};
80
+ if (!formData.fullName.trim())
81
+ errs["fullName"] = "Full name is required";
82
+ if (!formData.phone.trim())
83
+ errs["phone"] = "Phone number is required";
84
+ if (!formData.addressLine1.trim())
85
+ errs["addressLine1"] = "Address is required";
86
+ if (!formData.city.trim())
87
+ errs["city"] = "City is required";
88
+ if (!formData.state.trim())
89
+ errs["state"] = "State is required";
90
+ if (!formData.postalCode.trim())
91
+ errs["postalCode"] = "Postal code is required";
92
+ if (Object.keys(errs).length > 0) {
93
+ Object.entries(errs).forEach(([k, v]) => setErr(k, v));
94
+ return;
95
+ }
96
+ try {
97
+ await onSubmit(formData);
98
+ }
99
+ catch (err) {
100
+ void normalizeError(err);
101
+ showToast(err instanceof Error ? err.message : "Failed to save address", "error");
102
+ }
103
+ }, [formData, onSubmit, showToast, setFieldError, clearErrors, markSubmitAttempted]);
68
104
  useEffect(() => {
69
105
  validate(formData);
70
106
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -72,31 +108,11 @@ export function AddressForm({ initialData, onSubmit, onCancel, isLoading = false
72
108
  const handleChange = (field, value) => {
73
109
  setFormData((prev) => ({ ...prev, [field]: value }));
74
110
  };
75
- return (_jsx(Form, { onSubmit: (e) => e.preventDefault(), spacing: "md", shellCtx: shellCtx, children: ({ setFieldError, clearErrors }) => (_jsxs(_Fragment, { children: [_jsx(FormField, { label: mergedLabels.label, name: "label", type: "text", value: formData.label, onChange: (value) => handleChange("label", value), placeholder: mergedPlaceholders.label, required: true }), _jsx(FormField, { label: mergedLabels.fullName, name: "fullName", type: "text", value: formData.fullName, onChange: (value) => handleChange("fullName", value), placeholder: mergedPlaceholders.fullName, required: true }), _jsx(FormField, { label: mergedLabels.phone, name: "phone", type: "tel", value: formData.phone, onChange: (value) => handleChange("phone", value), placeholder: mergedPlaceholders.phone, required: true }), _jsx(FormField, { label: mergedLabels.addressLine1, name: "addressLine1", type: "text", value: formData.addressLine1, onChange: (value) => handleChange("addressLine1", value), placeholder: mergedPlaceholders.addressLine1, required: true }), _jsx(FormField, { label: mergedLabels.addressLine2, name: "addressLine2", type: "text", value: formData.addressLine2, onChange: (value) => handleChange("addressLine2", value), placeholder: mergedPlaceholders.addressLine2 }), _jsxs(FormGroup, { columns: 3, children: [_jsx(FormField, { label: mergedLabels.city, name: "city", type: "text", value: formData.city, onChange: (value) => handleChange("city", value), placeholder: mergedPlaceholders.city, required: true }), _jsx(FormField, { label: mergedLabels.state, name: "state", type: "text", value: formData.state, onChange: (value) => handleChange("state", value), placeholder: mergedPlaceholders.state, required: true }), _jsx(FormField, { label: mergedLabels.postalCode, name: "postalCode", type: "text", value: formData.postalCode, onChange: (value) => handleChange("postalCode", value), placeholder: mergedPlaceholders.postalCode, required: true })] }), _jsx(FormField, { label: mergedLabels.country, name: "country", type: "text", value: formData.country, onChange: (value) => handleChange("country", value), placeholder: mergedPlaceholders.country, required: true }), _jsx(Checkbox, { checked: formData.isDefault, onChange: (e) => handleChange("isDefault", e.target.checked), label: mergedLabels.setDefault }), _jsx(FormErrorSummary, {}), _jsxs(Row, { padding: "t-xs", align: "center", justify: "start", gap: "xs", children: [_jsx(Button, { type: "button", variant: "outline", onClick: onCancel, disabled: isLoading, children: mergedLabels.cancel }), _jsx(Button, { type: "submit", variant: "primary", disabled: isLoading, isLoading: isLoading, onClick: async () => {
76
- clearErrors();
77
- const errs = {};
78
- if (!formData.fullName.trim())
79
- errs["fullName"] = "Full name is required";
80
- if (!formData.phone.trim())
81
- errs["phone"] = "Phone number is required";
82
- if (!formData.addressLine1.trim())
83
- errs["addressLine1"] = "Address is required";
84
- if (!formData.city.trim())
85
- errs["city"] = "City is required";
86
- if (!formData.state.trim())
87
- errs["state"] = "State is required";
88
- if (!formData.postalCode.trim())
89
- errs["postalCode"] = "Postal code is required";
90
- if (Object.keys(errs).length > 0) {
91
- Object.entries(errs).forEach(([k, v]) => setFieldError(k, v));
92
- return;
93
- }
94
- try {
95
- await onSubmit(formData);
96
- }
97
- catch (err) {
98
- void normalizeError(err);
99
- showToast(err instanceof Error ? err.message : "Failed to save address", "error");
100
- }
101
- }, children: isLoading ? mergedLabels.loading : effectiveSubmitLabel })] })] })) }));
111
+ return (_jsx(Form, { onSubmit: (e) => e.preventDefault(), spacing: "md", shellCtx: shellCtx, bottomBar: {
112
+ onSubmit: submitAddress,
113
+ onCancel,
114
+ submitLabel: effectiveSubmitLabel,
115
+ cancelLabel: mergedLabels.cancel,
116
+ isLoading,
117
+ }, children: ({ setFieldError, clearErrors }) => (_jsxs(_Fragment, { children: [_jsx(FormField, { label: mergedLabels.label, name: "label", type: "text", value: formData.label, onChange: (value) => handleChange("label", value), placeholder: mergedPlaceholders.label, required: true }), _jsx(FormField, { label: mergedLabels.fullName, name: "fullName", type: "text", value: formData.fullName, onChange: (value) => handleChange("fullName", value), placeholder: mergedPlaceholders.fullName, required: true }), _jsx(FormField, { label: mergedLabels.phone, name: "phone", type: "tel", value: formData.phone, onChange: (value) => handleChange("phone", value), placeholder: mergedPlaceholders.phone, required: true }), _jsx(FormField, { label: mergedLabels.addressLine1, name: "addressLine1", type: "text", value: formData.addressLine1, onChange: (value) => handleChange("addressLine1", value), placeholder: mergedPlaceholders.addressLine1, required: true }), _jsx(FormField, { label: mergedLabels.addressLine2, name: "addressLine2", type: "text", value: formData.addressLine2, onChange: (value) => handleChange("addressLine2", value), placeholder: mergedPlaceholders.addressLine2 }), _jsxs(FormGroup, { columns: 3, children: [_jsx(FormField, { label: mergedLabels.city, name: "city", type: "text", value: formData.city, onChange: (value) => handleChange("city", value), placeholder: mergedPlaceholders.city, required: true }), _jsx(FormField, { label: mergedLabels.state, name: "state", type: "text", value: formData.state, onChange: (value) => handleChange("state", value), placeholder: mergedPlaceholders.state, required: true }), _jsx(FormField, { label: mergedLabels.postalCode, name: "postalCode", type: "text", value: formData.postalCode, onChange: (value) => handleChange("postalCode", value), placeholder: mergedPlaceholders.postalCode, required: true })] }), _jsx(FormField, { label: mergedLabels.country, name: "country", type: "text", value: formData.country, onChange: (value) => handleChange("country", value), placeholder: mergedPlaceholders.country, required: true }), _jsx(Checkbox, { checked: formData.isDefault, onChange: (e) => handleChange("isDefault", e.target.checked), label: mergedLabels.setDefault }), _jsx(FormErrorSummary, {}), _jsxs(Row, { padding: "t-xs", align: "center", justify: "start", gap: "xs", children: [_jsx(Button, { type: "button", variant: "outline", onClick: onCancel, disabled: isLoading, children: mergedLabels.cancel }), _jsx(Button, { type: "submit", variant: "primary", disabled: isLoading, isLoading: isLoading, onClick: () => void submitAddress({ setFieldError, clearErrors }), children: isLoading ? mergedLabels.loading : effectiveSubmitLabel })] })] })) }));
102
118
  }
@@ -142,6 +142,9 @@ export function AdminBundleEditorView({ bundleId, onSaved, onDeleted, scope = "a
142
142
  };
143
143
  }, [bundleId, endpoints]);
144
144
  const handleSave = useCallback(async () => {
145
+ // Save is a type="button" onClick — <Form>'s native submit never fires,
146
+ // so the error summary has to be unhidden explicitly.
147
+ formHelpersRef.current?.markSubmitAttempted();
145
148
  clearErrors();
146
149
  setApiError(null);
147
150
  const price = parsePriceRupees(form.priceRupees);
@@ -183,7 +183,7 @@ export function PlaceBidFormClient({ productId, currentBid: ssrCurrentBid, start
183
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) => {
184
184
  // handler wired through render-prop below — keeps setFieldError in scope
185
185
  e.preventDefault();
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
+ }, children: ({ setFieldError, clearErrors, markSubmitAttempted }) => (_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) => {
187
187
  const amount = presetAmount(n);
188
188
  const delta = amount - currentBid;
189
189
  // `delta` is 0 only for the opening preset on a bid-free
@@ -204,6 +204,8 @@ export function PlaceBidFormClient({ productId, currentBid: ssrCurrentBid, start
204
204
  : hasBids
205
205
  ? `Minimum next bid ${formatCurrency(minBid, currency)} (current bid + ${formatCurrency(minBidIncrement, currency)} increment).`
206
206
  : `Minimum opening bid ${formatCurrency(minBid, currency)} — the seller's starting bid. The ${formatCurrency(minBidIncrement, currency)} increment applies from the second bid on.` }), success && (_jsx(Text, { className: "text-success", size: "xs", children: "\u2713 Bid placed successfully!" })), !isEnded && auctionEndDate && (_jsxs(Text, { align: "center", size: "xs", color: "muted", children: ["Ends in ", _jsx(CountdownDisplay, { targetDate: auctionEndDate, format: "auto", expiredLabel: "Ended" })] })), _jsx(Button, { variant: "primary", size: "md", className: "w-full", disabled: isEnded || isPending, isLoading: isPending, type: "button", onClick: () => {
207
+ // type="button" — no native submit, so unhide the summary here.
208
+ markSubmitAttempted();
207
209
  clearErrors();
208
210
  const parsed = schema.safeParse({ bidAmount });
209
211
  if (!parsed.success)
@@ -62,7 +62,9 @@ export function CatalogueItemEditorView({ item, onSaved }) {
62
62
  onSaved?.(saved.id);
63
63
  },
64
64
  });
65
- return (_jsx(Form, { schema: createCatalogueItemSchema, spacing: "md", children: ({ setFieldError }) => (_jsxs(Stack, { gap: "md", children: [item?.listingStatus && item.listingStatus !== "not_listed" && (_jsxs(Alert, { variant: "info", children: ["This item is ", item.listingStatus.replace(/_/g, " "), item.listingStatus === "rejected" && item.rejectionReason ? `: ${item.rejectionReason}` : "."] })), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(FieldInput, { name: "title", label: "Title", required: true, value: title, onChange: setTitle }), _jsx(FieldSelect, { name: "condition", label: "Condition", value: condition, onChange: setCondition, options: CONDITION_OPTIONS }), _jsx(FieldInput, { name: "price", label: "Estimated resale price (\u20B9)", type: "number", min: "0", value: price, onChange: setPrice }), _jsx(FieldInput, { name: "quantity", label: "Quantity", type: "number", min: "1", value: quantity, onChange: setQuantity })] }), _jsx(FieldTextarea, { name: "description", label: "Description", value: description, onChange: setDescription, rows: 3 }), _jsx(MediaUploadList, { label: "Photos", value: galleryFields, onChange: (fields) => setImages(fields.map((f) => f.url)), onUpload: handleUpload, accept: "image/*", maxItems: 8, helperText: "Photos older than 30 days must be refreshed before this item can be listed." }), _jsxs(Row, { gap: "sm", align: "center", children: [_jsx(Toggle, { checked: visibility === "public", onChange: (checked) => setVisibility(checked ? "public" : "private"), size: "sm" }), _jsx(Span, { children: visibility === "public" ? "Public — visible on your profile" : "Private — only you can see it" })] }), _jsx(FormErrorSummary, {}), _jsx(Button, { type: "button", isLoading: saveMutation.isPending, onClick: () => {
65
+ return (_jsx(Form, { schema: createCatalogueItemSchema, spacing: "md", children: ({ setFieldError, markSubmitAttempted }) => (_jsxs(Stack, { gap: "md", children: [item?.listingStatus && item.listingStatus !== "not_listed" && (_jsxs(Alert, { variant: "info", children: ["This item is ", item.listingStatus.replace(/_/g, " "), item.listingStatus === "rejected" && item.rejectionReason ? `: ${item.rejectionReason}` : "."] })), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(FieldInput, { name: "title", label: "Title", required: true, value: title, onChange: setTitle }), _jsx(FieldSelect, { name: "condition", label: "Condition", value: condition, onChange: setCondition, options: CONDITION_OPTIONS }), _jsx(FieldInput, { name: "price", label: "Estimated resale price (\u20B9)", type: "number", min: "0", value: price, onChange: setPrice }), _jsx(FieldInput, { name: "quantity", label: "Quantity", type: "number", min: "1", value: quantity, onChange: setQuantity })] }), _jsx(FieldTextarea, { name: "description", label: "Description", value: description, onChange: setDescription, rows: 3 }), _jsx(MediaUploadList, { label: "Photos", value: galleryFields, onChange: (fields) => setImages(fields.map((f) => f.url)), onUpload: handleUpload, accept: "image/*", maxItems: 8, helperText: "Photos older than 30 days must be refreshed before this item can be listed." }), _jsxs(Row, { gap: "sm", align: "center", children: [_jsx(Toggle, { checked: visibility === "public", onChange: (checked) => setVisibility(checked ? "public" : "private"), size: "sm" }), _jsx(Span, { children: visibility === "public" ? "Public — visible on your profile" : "Private — only you can see it" })] }), _jsx(FormErrorSummary, {}), _jsx(Button, { type: "button", isLoading: saveMutation.isPending, onClick: () => {
66
+ // type="button" — no native submit, so unhide the summary here.
67
+ markSubmitAttempted();
66
68
  const parsed = createCatalogueItemSchema.safeParse({
67
69
  title,
68
70
  images,
@@ -114,7 +114,7 @@ export default function BottomActions() {
114
114
  setPickerOpen(false);
115
115
  setInfoOpen(false);
116
116
  });
117
- const { actions, bulk, infoLabel, secondaryLabel, infoPanel, desktop } = state;
117
+ const { actions, bulk, infoLabel, secondaryLabel, infoPanel, infoOpenSignal, desktop } = state;
118
118
  // Desktop reveal-on-scroll. Kept as a plain passive listener to match BackToTop —
119
119
  // there is no shared scroll-position hook in the codebase and one consumer doesn't
120
120
  // justify inventing one. Only armed when a page actually opts into "after-scroll".
@@ -144,6 +144,23 @@ export default function BottomActions() {
144
144
  if (!hasInfoPanel)
145
145
  setInfoOpen(false);
146
146
  }, [hasInfoPanel]);
147
+ /*
148
+ * Force-open on a bumped signal — a failed form submit, today. Depends on
149
+ * the counter alone, so a re-render with the same value never re-opens what
150
+ * the user has since collapsed. `hasInfoPanel` is read but deliberately NOT
151
+ * a dependency: publishing the panel and bumping the signal are two separate
152
+ * setState calls, so re-running on `hasInfoPanel` would open the panel again
153
+ * one render after the user closed it.
154
+ */
155
+ const lastSignalRef = useRef(undefined);
156
+ useEffect(() => {
157
+ if (infoOpenSignal == null)
158
+ return;
159
+ if (lastSignalRef.current === infoOpenSignal)
160
+ return;
161
+ lastSignalRef.current = infoOpenSignal;
162
+ setInfoOpen(true);
163
+ }, [infoOpenSignal]);
147
164
  // Keep selectedActionId in sync with available bulk actions
148
165
  useEffect(() => {
149
166
  if (!isBulkMode) {
@@ -109,6 +109,19 @@ export interface BottomActionsState {
109
109
  * bulk mode, which owns the same space for its action picker.
110
110
  */
111
111
  infoPanel?: React.ReactNode;
112
+ /**
113
+ * A COUNTER, not a boolean — bump it to force `infoPanel` open once.
114
+ *
115
+ * `BottomActions` owns `infoOpen` in local state so the user can collapse
116
+ * the panel at will. A boolean `forceOpen` would fight that: it stays true,
117
+ * so every subsequent render re-opens what they just closed. A counter
118
+ * expresses the real intent — "this is a NEW event, show it" — and fires
119
+ * exactly once per increment.
120
+ *
121
+ * Used by a form's failed submit: the error list should appear without a
122
+ * tap, and stay dismissible afterwards.
123
+ */
124
+ infoOpenSignal?: number;
112
125
  }
113
126
  interface BottomActionsContextValue {
114
127
  state: BottomActionsState;
@@ -131,10 +144,16 @@ interface BottomActionsContextValue {
131
144
  setSecondaryLabel: (label: string | undefined) => void;
132
145
  /** Set or clear the expandable panel revealed above the bar. */
133
146
  setInfoPanel: (panel: React.ReactNode | undefined) => void;
147
+ /** Set the force-open counter. See BottomActionsState.infoOpenSignal. */
148
+ setInfoOpenSignal: (signal: number | undefined) => void;
134
149
  /** Set the desktop visibility policy. */
135
150
  setDesktopMode: (mode: BottomActionsDesktopMode | undefined) => void;
136
151
  /** Clear all state (called on feature unmount). */
137
152
  clearAll: () => void;
153
+ claim: (id: string) => void;
154
+ release: (id: string) => void;
155
+ /** The id currently allowed to publish. `useBottomActions` gates on this. */
156
+ topClaimId: string | null;
138
157
  }
139
158
  export declare function BottomActionsProvider({ children, }: {
140
159
  children: React.ReactNode;
@@ -47,6 +47,32 @@ export function BottomActionsProvider({ children, }) {
47
47
  const actionCallbacksRef = useRef(new Map());
48
48
  const bulkCallbacksRef = useRef(new Map());
49
49
  const bulkClearRef = useRef(undefined);
50
+ // Stack of claimant ids, oldest first. The last entry owns the bar.
51
+ const claimsRef = useRef([]);
52
+ const [topClaimId, setTopClaimId] = useState(null);
53
+ const claim = useCallback((id) => {
54
+ if (claimsRef.current.includes(id))
55
+ return;
56
+ claimsRef.current = [...claimsRef.current, id];
57
+ setTopClaimId(id);
58
+ }, []);
59
+ const release = useCallback((id) => {
60
+ const next = claimsRef.current.filter((c) => c !== id);
61
+ if (next.length === claimsRef.current.length)
62
+ return;
63
+ claimsRef.current = next;
64
+ const newTop = next.length > 0 ? next[next.length - 1] : null;
65
+ setTopClaimId(newTop);
66
+ // Only blank the bar when nobody is left. Otherwise the newly-top
67
+ // consumer republishes on the very next effect pass, so clearing here
68
+ // would be a visible flicker of an empty bar.
69
+ if (newTop === null) {
70
+ actionCallbacksRef.current = new Map();
71
+ bulkCallbacksRef.current = new Map();
72
+ bulkClearRef.current = undefined;
73
+ setState(EMPTY);
74
+ }
75
+ }, []);
50
76
  const setActions = useCallback((actions) => {
51
77
  // Update callback map (always latest)
52
78
  actionCallbacksRef.current = new Map(actions.map((a) => [a.id, a.onClick]));
@@ -90,6 +116,9 @@ export function BottomActionsProvider({ children, }) {
90
116
  const setInfoPanel = useCallback((infoPanel) => {
91
117
  setState((prev) => ({ ...prev, infoPanel }));
92
118
  }, []);
119
+ const setInfoOpenSignal = useCallback((infoOpenSignal) => {
120
+ setState((prev) => (prev.infoOpenSignal === infoOpenSignal ? prev : { ...prev, infoOpenSignal }));
121
+ }, []);
93
122
  const setDesktopMode = useCallback((desktop) => {
94
123
  setState((prev) => (prev.desktop === desktop ? prev : { ...prev, desktop }));
95
124
  }, []);
@@ -109,8 +138,12 @@ export function BottomActionsProvider({ children, }) {
109
138
  setInfoLabel,
110
139
  setSecondaryLabel,
111
140
  setInfoPanel,
141
+ setInfoOpenSignal,
112
142
  setDesktopMode,
113
143
  clearAll,
144
+ claim,
145
+ release,
146
+ topClaimId,
114
147
  }), [
115
148
  state,
116
149
  setActions,
@@ -118,8 +151,12 @@ export function BottomActionsProvider({ children, }) {
118
151
  setInfoLabel,
119
152
  setSecondaryLabel,
120
153
  setInfoPanel,
154
+ setInfoOpenSignal,
121
155
  setDesktopMode,
122
156
  clearAll,
157
+ claim,
158
+ release,
159
+ topClaimId,
123
160
  ]);
124
161
  return (_jsx(BottomActionsContext.Provider, { value: value, children: children }));
125
162
  }
@@ -11,10 +11,20 @@ export interface UseBottomActionsOptions {
11
11
  * See BottomActionsState.infoPanel.
12
12
  */
13
13
  infoPanel?: React.ReactNode;
14
+ /** Bump to force `infoPanel` open once. See BottomActionsState.infoOpenSignal. */
15
+ infoOpenSignal?: number;
14
16
  /**
15
17
  * Whether the bar may also show on desktop. Defaults to "hidden" (mobile/tablet only),
16
18
  * so pages that don't opt in behave exactly as before.
17
19
  */
18
20
  desktop?: BottomActionsDesktopMode;
21
+ /**
22
+ * Set false to opt out entirely — no claim is taken, so this consumer is
23
+ * invisible to the stack and cannot displace whoever currently owns the
24
+ * bar. Load-bearing: `useFormBottomActions` is called unconditionally from
25
+ * `<Form>`, which mounts on ~75 surfaces, and a claim from each of those
26
+ * would push a listing's bulk bar off the stack for nothing.
27
+ */
28
+ enabled?: boolean;
19
29
  }
20
30
  export declare function useBottomActions(options?: UseBottomActionsOptions): void;
@@ -1,8 +1,28 @@
1
1
  "use client";
2
- import { useEffect, useRef } from "react";
2
+ import { useEffect, useId, useRef } from "react";
3
3
  import { useBottomActionsContext, } from "../BottomActionsContext";
4
4
  export function useBottomActions(options = {}) {
5
- const { setActions, setBulkConfig, setInfoLabel, setSecondaryLabel, setInfoPanel, setDesktopMode, clearAll, } = useBottomActionsContext();
5
+ const { setActions, setBulkConfig, setInfoLabel, setSecondaryLabel, setInfoPanel, setInfoOpenSignal, setDesktopMode, claim, release, topClaimId, } = useBottomActionsContext();
6
+ /*
7
+ * One bar, many potential claimants. `DataListingView` already holds it on
8
+ * ~70 admin screens, so a form opened in a drawer over a listing used to
9
+ * overwrite the listing's bar and blank it on close.
10
+ *
11
+ * Claim on mount, release on unmount. `isTop` gates every publish below, so
12
+ * a backgrounded consumer stops writing — and because `isTop` is a
13
+ * dependency of each effect, regaining the top re-publishes everything
14
+ * automatically. That is what restores the listing's bar when the form
15
+ * closes, with no cooperation needed from either side.
16
+ */
17
+ const enabled = options.enabled !== false;
18
+ const claimId = useId();
19
+ useEffect(() => {
20
+ if (!enabled)
21
+ return;
22
+ claim(claimId);
23
+ return () => release(claimId);
24
+ }, [claim, release, claimId, enabled]);
25
+ const isTop = enabled && topClaimId === claimId;
6
26
  const actionsRef = useRef(options.actions ?? []);
7
27
  actionsRef.current = options.actions ?? [];
8
28
  const bulkRef = useRef(options.bulk);
@@ -14,17 +34,25 @@ export function useBottomActions(options = {}) {
14
34
  ? `${options.bulk.selectedCount}|${options.bulk.actions.map((a) => `${a.id}|${a.label}|${a.variant}|${a.badge}|${a.disabled}|${a.loading}|${a.grow}`).join(",")}`
15
35
  : "";
16
36
  useEffect(() => {
37
+ if (!isTop)
38
+ return;
17
39
  setActions(actionsRef.current);
18
- }, [setActions, actionKey]);
40
+ }, [setActions, actionKey, isTop]);
19
41
  useEffect(() => {
42
+ if (!isTop)
43
+ return;
20
44
  setBulkConfig(bulkRef.current);
21
- }, [setBulkConfig, bulkCountKey]);
45
+ }, [setBulkConfig, bulkCountKey, isTop]);
22
46
  useEffect(() => {
47
+ if (!isTop)
48
+ return;
23
49
  setInfoLabel(options.infoLabel);
24
- }, [setInfoLabel, options.infoLabel]);
50
+ }, [setInfoLabel, options.infoLabel, isTop]);
25
51
  useEffect(() => {
52
+ if (!isTop)
53
+ return;
26
54
  setSecondaryLabel(options.secondaryLabel);
27
- }, [setSecondaryLabel, options.secondaryLabel]);
55
+ }, [setSecondaryLabel, options.secondaryLabel, isTop]);
28
56
  // A ReactNode is a fresh object on every render, so it can't be a dependency
29
57
  // without looping. Mirror it through a ref and re-publish whenever anything
30
58
  // the panel actually derives from changes — callers pass `infoLabel`, which
@@ -33,12 +61,21 @@ export function useBottomActions(options = {}) {
33
61
  infoPanelRef.current = options.infoPanel;
34
62
  const hasInfoPanel = options.infoPanel != null;
35
63
  useEffect(() => {
64
+ if (!isTop)
65
+ return;
36
66
  setInfoPanel(infoPanelRef.current);
37
- }, [setInfoPanel, hasInfoPanel, options.infoLabel, options.secondaryLabel]);
67
+ }, [setInfoPanel, hasInfoPanel, options.infoLabel, options.secondaryLabel, isTop]);
38
68
  useEffect(() => {
39
- setDesktopMode(options.desktop);
40
- }, [setDesktopMode, options.desktop]);
69
+ if (!isTop)
70
+ return;
71
+ setInfoOpenSignal(options.infoOpenSignal);
72
+ }, [setInfoOpenSignal, options.infoOpenSignal, isTop]);
41
73
  useEffect(() => {
42
- return () => clearAll();
43
- }, [clearAll]);
74
+ if (!isTop)
75
+ return;
76
+ setDesktopMode(options.desktop);
77
+ }, [setDesktopMode, options.desktop, isTop]);
78
+ // NOTE: no clearAll() on unmount any more — `release()` above owns that, and
79
+ // only blanks the bar when the stack empties. Clearing here would flash an
80
+ // empty bar between a drawer closing and the listing behind it republishing.
44
81
  }
@@ -0,0 +1,57 @@
1
+ import { type FormShellContextValue } from "../../../ui/forms/FormShell";
2
+ export interface UseFormBottomActionsOptions {
3
+ /** Runs when the pinned primary button is pressed. */
4
+ onSubmit: () => void | Promise<void>;
5
+ /** Omit to render no Cancel button — a settings page with nowhere to go back to. */
6
+ onCancel?: () => void;
7
+ submitLabel?: string;
8
+ cancelLabel?: string;
9
+ isLoading?: boolean;
10
+ disabled?: boolean;
11
+ /** Set false to leave the bar to someone else on this route. */
12
+ enabled?: boolean;
13
+ /**
14
+ * Read the form state from here instead of the ambient context. `<Form>`
15
+ * needs it: the hook runs in `Form`'s body, which is ABOVE the provider
16
+ * `Form` itself renders, so the ambient lookup would find the parent's
17
+ * context or none at all.
18
+ */
19
+ ctx?: FormShellContextValue;
20
+ }
21
+ /**
22
+ * Pin a form's Save/Cancel row to the bottom of a phone screen, and turn its
23
+ * validation errors into a pull-up sheet above it.
24
+ *
25
+ * ## Nothing here is new chrome
26
+ *
27
+ * This is `useBottomActions` with a form-shaped payload. The cart's bottom bar
28
+ * — "Total ₹777 · Details ⌃" over a pinned CTA — is 100% that primitive's
29
+ * output already, so the sheet mechanics, the collapse rule
30
+ * (`grid-rows-[1fr] ↔ [0fr]`, background on the innermost div),
31
+ * click-outside-to-close, the `ResizeObserver` that publishes
32
+ * `--bottom-chrome-height`, and all three bottom-edge offsets come for free.
33
+ * Re-deriving any of that would be a second, worse copy.
34
+ *
35
+ * ## Three details that will bite anyone editing this
36
+ *
37
+ * 1. **The label must encode the count.** `useBottomActions` re-publishes
38
+ * `infoPanel` only when `hasInfoPanel`, `infoLabel` or `secondaryLabel`
39
+ * change — a ReactNode is a fresh object every render and cannot be a
40
+ * dependency without looping. "Fix 6 issues" → "Fix 5 issues" is
41
+ * therefore what pushes the updated list through. A generic label like
42
+ * "Errors" would freeze the sheet's contents at whatever they were the
43
+ * first time it opened.
44
+ *
45
+ * 2. **The panel is rendered outside the form's provider.** `BottomActions`
46
+ * lives in the layout shell, so the detached node cannot reach
47
+ * `FormShellContext` by itself — hence `<FormErrorList ctx={ctx}>` with
48
+ * the context passed explicitly.
49
+ *
50
+ * 3. **Suppressed inside a Modal or SideDrawer.** An overlay owns its own
51
+ * footer, and a viewport-fixed bar would render *behind* the backdrop,
52
+ * below the dialog it belongs to. `useIsInsideOverlay()` is how the
53
+ * overlay tells us, because neither portal leaves a detectable trace in
54
+ * the DOM at the form's position.
55
+ */
56
+ export declare function useFormBottomActions({ onSubmit, onCancel, submitLabel, cancelLabel, isLoading, disabled, enabled, ctx: ctxProp, }: UseFormBottomActionsOptions): void;
57
+ export default useFormBottomActions;
@@ -0,0 +1,89 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { useContext, useMemo } from "react";
4
+ import { FormShellContext } from "../../../ui/forms/FormShell";
5
+ import { FormErrorList } from "../../../ui/forms/FormErrorList";
6
+ import { useIsInsideOverlay } from "../../../ui/components/overlay-context";
7
+ import { useBottomActions } from "./useBottomActions";
8
+ /**
9
+ * Pin a form's Save/Cancel row to the bottom of a phone screen, and turn its
10
+ * validation errors into a pull-up sheet above it.
11
+ *
12
+ * ## Nothing here is new chrome
13
+ *
14
+ * This is `useBottomActions` with a form-shaped payload. The cart's bottom bar
15
+ * — "Total ₹777 · Details ⌃" over a pinned CTA — is 100% that primitive's
16
+ * output already, so the sheet mechanics, the collapse rule
17
+ * (`grid-rows-[1fr] ↔ [0fr]`, background on the innermost div),
18
+ * click-outside-to-close, the `ResizeObserver` that publishes
19
+ * `--bottom-chrome-height`, and all three bottom-edge offsets come for free.
20
+ * Re-deriving any of that would be a second, worse copy.
21
+ *
22
+ * ## Three details that will bite anyone editing this
23
+ *
24
+ * 1. **The label must encode the count.** `useBottomActions` re-publishes
25
+ * `infoPanel` only when `hasInfoPanel`, `infoLabel` or `secondaryLabel`
26
+ * change — a ReactNode is a fresh object every render and cannot be a
27
+ * dependency without looping. "Fix 6 issues" → "Fix 5 issues" is
28
+ * therefore what pushes the updated list through. A generic label like
29
+ * "Errors" would freeze the sheet's contents at whatever they were the
30
+ * first time it opened.
31
+ *
32
+ * 2. **The panel is rendered outside the form's provider.** `BottomActions`
33
+ * lives in the layout shell, so the detached node cannot reach
34
+ * `FormShellContext` by itself — hence `<FormErrorList ctx={ctx}>` with
35
+ * the context passed explicitly.
36
+ *
37
+ * 3. **Suppressed inside a Modal or SideDrawer.** An overlay owns its own
38
+ * footer, and a viewport-fixed bar would render *behind* the backdrop,
39
+ * below the dialog it belongs to. `useIsInsideOverlay()` is how the
40
+ * overlay tells us, because neither portal leaves a detectable trace in
41
+ * the DOM at the form's position.
42
+ */
43
+ export function useFormBottomActions({ onSubmit, onCancel, submitLabel = "Save", cancelLabel = "Cancel", isLoading = false, disabled = false, enabled = true, ctx: ctxProp, }) {
44
+ const ambient = useContext(FormShellContext);
45
+ const ctx = ctxProp ?? ambient;
46
+ const insideOverlay = useIsInsideOverlay();
47
+ const active = enabled && !insideOverlay && ctx != null;
48
+ const errorCount = ctx ? Object.keys(ctx.errors).length : 0;
49
+ const showErrors = !!ctx?.submitAttempted && errorCount > 0;
50
+ const actions = useMemo(() => {
51
+ if (!active)
52
+ return [];
53
+ const list = [];
54
+ if (onCancel) {
55
+ list.push({
56
+ id: "form-cancel",
57
+ label: cancelLabel,
58
+ variant: "outline",
59
+ grow: false,
60
+ onClick: onCancel,
61
+ disabled: isLoading,
62
+ });
63
+ }
64
+ list.push({
65
+ id: "form-submit",
66
+ label: submitLabel,
67
+ variant: "primary",
68
+ grow: true,
69
+ loading: isLoading,
70
+ disabled: disabled || isLoading,
71
+ onClick: () => void onSubmit(),
72
+ });
73
+ return list;
74
+ }, [active, onCancel, cancelLabel, submitLabel, isLoading, disabled, onSubmit]);
75
+ useBottomActions({
76
+ actions,
77
+ // Encodes the count on purpose — see (1) in the header.
78
+ infoLabel: showErrors
79
+ ? `Fix ${errorCount} ${errorCount === 1 ? "issue" : "issues"}`
80
+ : undefined,
81
+ infoPanel: showErrors && ctx ? _jsx(FormErrorList, { ctx: ctx }) : undefined,
82
+ // Bumped by every submit attempt, so a second failed Save re-opens a
83
+ // sheet the user collapsed — while an ordinary re-render does not.
84
+ infoOpenSignal: showErrors ? ctx?.submitAttemptCount : undefined,
85
+ // No claim at all when inactive — see UseBottomActionsOptions.enabled.
86
+ enabled: active,
87
+ });
88
+ }
89
+ export default useFormBottomActions;
@@ -30,6 +30,8 @@ export { BottomActionsProvider, useBottomActionsContext, } from "./BottomActions
30
30
  export type { BottomAction, BottomBulkConfig, BottomActionsState, BottomActionsDesktopMode, } from "./BottomActionsContext";
31
31
  export { useBottomActions } from "./hooks/useBottomActions";
32
32
  export type { UseBottomActionsOptions } from "./hooks/useBottomActions";
33
+ export { useFormBottomActions } from "./hooks/useFormBottomActions";
34
+ export type { UseFormBottomActionsOptions } from "./hooks/useFormBottomActions";
33
35
  export { default as BottomActions } from "./BottomActions";
34
36
  export { BottomChrome } from "./BottomChrome";
35
37
  export type { BottomChromeProps } from "./BottomChrome";
@@ -16,6 +16,7 @@ export { LayoutClient } from "./LayoutClient";
16
16
  export { AppLayoutShell } from "./AppLayoutShell";
17
17
  export { BottomActionsProvider, useBottomActionsContext, } from "./BottomActionsContext";
18
18
  export { useBottomActions } from "./hooks/useBottomActions";
19
+ export { useFormBottomActions } from "./hooks/useFormBottomActions";
19
20
  export { default as BottomActions } from "./BottomActions";
20
21
  export { BottomChrome } from "./BottomChrome";
21
22
  export { BottomNavbar } from "./BottomNavbar";
@@ -38,7 +38,9 @@ export function MakeOfferButton({ productId, listedPrice, currency, minOfferPerc
38
38
  const schema = makeOfferFormSchema({ listedPrice, minOffer, formatAmount: fmt });
39
39
  const [offerAmount, setOfferAmount] = useState(String(Math.max(minOfferAmount(listedPrice, 90), minOffer)));
40
40
  const [buyerNote, setBuyerNote] = useState("");
41
- function submit(setFieldError, clearErrors) {
41
+ function submit(setFieldError, clearErrors, markSubmitAttempted) {
42
+ // type="button" — no native submit, so unhide the summary here.
43
+ markSubmitAttempted();
42
44
  clearErrors();
43
45
  const parsed = schema.safeParse({ offerAmount, buyerNote: buyerNote || undefined });
44
46
  if (!parsed.success) {
@@ -75,8 +77,8 @@ export function MakeOfferButton({ productId, listedPrice, currency, minOfferPerc
75
77
  if (state === "pending") {
76
78
  return (_jsxs(Div, { className: `${CLS_PENDING_BOX} ${className}`, children: [_jsx(Span, { size: "lg", children: "\u23F3" }), _jsx(Text, { className: CLS_PENDING_TITLE, children: "Offer Pending" }), _jsx(Text, { className: CLS_PENDING_BODY, children: "You already have an offer on this item. Check My Offers for updates." })] }));
77
79
  }
78
- return (_jsxs(_Fragment, { children: [_jsx(LoginRequiredModal, { isOpen: showLoginModal, onClose: () => setShowLoginModal(false), message: "You need to be signed in to make an offer. Please log in or create an account to continue." }), _jsx(Button, { variant: "ghost", size: "md", border: "strong", className: `w-full ${className}`, onClick: () => setState("open"), children: "Make Offer" }), _jsx(Modal, { isOpen: state === "open", onClose: () => setState("idle"), size: "md", title: "Make an offer", children: _jsx(Form, { schema: schema, onSubmit: (e) => e.preventDefault(), children: ({ setFieldError, clearErrors }) => (_jsxs(Stack, { gap: "md", children: [_jsxs(Text, { size: "xs", color: "muted", children: ["Listed at ", fmt(listedPrice), " \u00B7 Minimum offer: ", fmt(minOffer)] }), _jsx(FieldInput, { name: "offerAmount", type: "number", label: "Your offer amount", required: true, value: offerAmount, onChange: (value) => {
80
+ return (_jsxs(_Fragment, { children: [_jsx(LoginRequiredModal, { isOpen: showLoginModal, onClose: () => setShowLoginModal(false), message: "You need to be signed in to make an offer. Please log in or create an account to continue." }), _jsx(Button, { variant: "ghost", size: "md", border: "strong", className: `w-full ${className}`, onClick: () => setState("open"), children: "Make Offer" }), _jsx(Modal, { isOpen: state === "open", onClose: () => setState("idle"), size: "md", title: "Make an offer", children: _jsx(Form, { schema: schema, onSubmit: (e) => e.preventDefault(), children: ({ setFieldError, clearErrors, markSubmitAttempted }) => (_jsxs(Stack, { gap: "md", children: [_jsxs(Text, { size: "xs", color: "muted", children: ["Listed at ", fmt(listedPrice), " \u00B7 Minimum offer: ", fmt(minOffer)] }), _jsx(FieldInput, { name: "offerAmount", type: "number", label: "Your offer amount", required: true, value: offerAmount, onChange: (value) => {
79
81
  setOfferAmount(value);
80
82
  clearErrors();
81
- }, min: minOffer, max: listedPrice - 1, step: 1, "aria-label": "Offer amount" }), _jsxs(Text, { size: "xs", color: "faint", children: ["Must be between ", fmt(minOffer), " and ", fmt(listedPrice - 1)] }), _jsx(FieldInput, { name: "buyerNote", type: "text", label: "Note to seller (optional)", value: buyerNote, onChange: setBuyerNote, placeholder: "E.g. Bundle deal, long-time fan\u2026", maxLength: 300, "aria-label": "Note to seller" }), _jsx(Text, { size: "xs", color: "muted", children: "The seller will accept, decline, or suggest a counter price." }), _jsx(FormErrorSummary, {}), _jsxs(Stack, { gap: "xs", children: [_jsx(Button, { action: ACTIONS.PRODUCT["make-offer"], variant: "primary", size: "sm", type: "button", isLoading: isPending, disabled: isPending, onClick: () => submit(setFieldError, clearErrors), children: isPending ? "Sending…" : `Send offer of ${fmt(Number(offerAmount) || 0)}` }), _jsx(Button, { variant: "ghost", size: "sm", type: "button", onClick: () => setState("idle"), disabled: isPending, children: "Cancel" })] })] })) }) })] }));
83
+ }, min: minOffer, max: listedPrice - 1, step: 1, "aria-label": "Offer amount" }), _jsxs(Text, { size: "xs", color: "faint", children: ["Must be between ", fmt(minOffer), " and ", fmt(listedPrice - 1)] }), _jsx(FieldInput, { name: "buyerNote", type: "text", label: "Note to seller (optional)", value: buyerNote, onChange: setBuyerNote, placeholder: "E.g. Bundle deal, long-time fan\u2026", maxLength: 300, "aria-label": "Note to seller" }), _jsx(Text, { size: "xs", color: "muted", children: "The seller will accept, decline, or suggest a counter price." }), _jsx(FormErrorSummary, {}), _jsxs(Stack, { gap: "xs", children: [_jsx(Button, { action: ACTIONS.PRODUCT["make-offer"], variant: "primary", size: "sm", type: "button", isLoading: isPending, disabled: isPending, onClick: () => submit(setFieldError, clearErrors, markSubmitAttempted), children: isPending ? "Sending…" : `Send offer of ${fmt(Number(offerAmount) || 0)}` }), _jsx(Button, { variant: "ghost", size: "sm", type: "button", onClick: () => setState("idle"), disabled: isPending, children: "Cancel" })] })] })) }) })] }));
82
84
  }