@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.
- package/dist/client.d.ts +5 -0
- package/dist/client.js +3 -0
- package/dist/features/account/components/AddressForm.js +45 -29
- package/dist/features/admin/components/AdminBundleEditorView.js +3 -0
- package/dist/features/auctions/components/PlaceBidFormClient.js +3 -1
- package/dist/features/catalogue/components/CatalogueItemEditorView.js +3 -1
- package/dist/features/layout/BottomActions.js +18 -1
- package/dist/features/layout/BottomActionsContext.d.ts +19 -0
- package/dist/features/layout/BottomActionsContext.js +37 -0
- package/dist/features/layout/hooks/useBottomActions.d.ts +10 -0
- package/dist/features/layout/hooks/useBottomActions.js +48 -11
- package/dist/features/layout/hooks/useFormBottomActions.d.ts +57 -0
- package/dist/features/layout/hooks/useFormBottomActions.js +89 -0
- package/dist/features/layout/index.d.ts +2 -0
- package/dist/features/layout/index.js +1 -0
- package/dist/features/products/components/MakeOfferButton.js +5 -3
- package/dist/features/shell/QuickFormDrawer.js +15 -1
- package/dist/features/shell/SectionForm.d.ts +16 -2
- package/dist/features/shell/SectionForm.js +28 -2
- package/dist/features/shipments/components/AdminShipmentEditorView.js +3 -1
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +36 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +4 -0
- package/dist/ui/components/Form.d.ts +22 -1
- package/dist/ui/components/Form.js +30 -2
- package/dist/ui/components/Modal.js +4 -3
- package/dist/ui/components/SideDrawer.js +5 -4
- package/dist/ui/components/overlay-context.d.ts +21 -0
- package/dist/ui/components/overlay-context.js +25 -0
- package/dist/ui/forms/FormErrorList.d.ts +29 -0
- package/dist/ui/forms/FormErrorList.js +36 -0
- package/dist/ui/forms/FormErrorSummary.d.ts +8 -0
- package/dist/ui/forms/FormErrorSummary.js +15 -8
- package/dist/ui/forms/FormShell.d.ts +25 -1
- package/dist/ui/forms/FormShell.js +39 -4
- package/dist/ui/forms/index.d.ts +2 -0
- package/dist/ui/forms/index.js +1 -0
- 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,
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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,
|
|
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
|
-
|
|
40
|
-
|
|
69
|
+
if (!isTop)
|
|
70
|
+
return;
|
|
71
|
+
setInfoOpenSignal(options.infoOpenSignal);
|
|
72
|
+
}, [setInfoOpenSignal, options.infoOpenSignal, isTop]);
|
|
41
73
|
useEffect(() => {
|
|
42
|
-
|
|
43
|
-
|
|
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
|
}
|