@12-apps/payments-frontend 3.34.3 → 3.35.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/payments-frontend",
3
- "version": "3.34.3",
3
+ "version": "3.35.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Browser half of the vendor-agnostic payments platform: plug-and-play MUI components for the per-provider settings page (credential form from each provider's schema, masked hints, verify/enable) and the checkout page (PIX QR + polling, card tokenization, hosted-checkout redirect), plus the headless hooks and fetch clients they build on. Talks only to the host's payments HTTP surface — never to a provider directly. Microfrontend-ready: no app coupling, host injects theme and auth.",
@@ -8,6 +8,7 @@ import {
8
8
  type CardTokenizationConfig,
9
9
  } from "../../card";
10
10
 
11
+ import { useRefreshOnCartChanged } from "./cart-changed";
11
12
  import { useCheckoutCopy } from "./copy-context";
12
13
  import { UNRESOLVED_CODE } from "./failure-codes";
13
14
  import { StalledWait } from "./stalled-wait";
@@ -192,11 +193,11 @@ export function CardView({
192
193
  freshInstrument,
193
194
  { tenantSlug, basket },
194
195
  );
195
- // A charge NOBODY can confirm yet is not a decline (FUT-563). Some provider
196
- // may be holding the buyer's money, so it gets its own presentation: the
197
- // danger heading "Não foi possível pagar" contradicts the body's "não pague
198
- // de novo" and pushes the buyer toward exactly the retry it forbids.
196
+ // A charge NOBODY can confirm yet is not a decline (FUT-563): a provider may
197
+ // hold the money, and "Não foi possível pagar" over "não pague de novo"
198
+ // pushes the buyer toward exactly the retry it forbids.
199
199
  const unresolved = cc.errorCode === UNRESOLVED_CODE;
200
+ useRefreshOnCartChanged(cc.errorCode); // re-priced by the host (FUT-1139)
200
201
 
201
202
  if (cc.submitted) {
202
203
  return <SubmittedState card={cc} />;
@@ -0,0 +1,273 @@
1
+ import { Box } from "@mui/material";
2
+ import {
3
+ createContext,
4
+ useCallback,
5
+ useContext,
6
+ useEffect,
7
+ useRef,
8
+ useState,
9
+ type JSX,
10
+ type ReactNode,
11
+ } from "react";
12
+
13
+ import type { CheckoutBasketIdentity } from "./basket";
14
+ import { useCheckoutCopy } from "./copy-context";
15
+ import type {
16
+ BuyerInfo,
17
+ CheckoutOrder,
18
+ CreateOrderRequest,
19
+ CreateOrderResult,
20
+ PaymentMethod,
21
+ } from "./types";
22
+ import { useCheckoutComponents } from "./ui";
23
+
24
+ /**
25
+ * THE CART MOVED UNDER A PREPARED PAYMENT (FUT-1139).
26
+ *
27
+ * An order is raised the moment the buyer picks a method, and it prices the
28
+ * cart as it stood THEN. Another tab can add a line, a store can move a price,
29
+ * a product can stop selling — and the card charge or the PIX code on screen
30
+ * still asks for the old amount. Paying it settles the old order and the
31
+ * server closes the cart, so whatever changed was neither charged nor kept.
32
+ *
33
+ * Two halves, both here:
34
+ *
35
+ * - the SERVER refuses a card charge whose order no longer matches the cart,
36
+ * with {@link CART_CHANGED_CODE}, after re-pricing the order to the cart as it
37
+ * stands. The card form then re-reads that order, so the pay bar shows the
38
+ * amount that will actually be charged before the buyer presses it again;
39
+ * - the SCREEN notices on its own that the basket it was raised from is not the
40
+ * basket in front of it (the host's signature, `./basket.ts`), and says so
41
+ * with one control that brings the payment up to date. That is the only
42
+ * warning a PIX buyer can get: a QR code is paid outside the app.
43
+ */
44
+
45
+ /** The refusal a host answers when the order no longer prices the cart. */
46
+ export const CART_CHANGED_CODE = "CART_CHANGED";
47
+
48
+ interface RaisedFrom {
49
+ order: CheckoutOrder;
50
+ signature: string | null;
51
+ }
52
+
53
+ /**
54
+ * Whether the basket in front of the checkout is no longer the one `order` was
55
+ * raised from.
56
+ *
57
+ * The basket is recorded when the ORDER changes — a re-raise or a refresh
58
+ * hands back a new object, so it is re-recorded and the warning clears — and
59
+ * only once the host's cart has answered (`ready`): a loading cart reports the
60
+ * EMPTY signature, and recording that would read every real cart as changed.
61
+ * A host that passes no basket never sees the warning, which is the behaviour
62
+ * before this existed.
63
+ */
64
+ export function useBasketChanged(
65
+ order: CheckoutOrder | null,
66
+ basket: CheckoutBasketIdentity | undefined,
67
+ ): boolean {
68
+ const [raised, setRaised] = useState<RaisedFrom | null>(null);
69
+ const ready = basket?.ready === true;
70
+ const signature = basket?.signature ?? null;
71
+ useEffect(() => {
72
+ setRaised((prev) => {
73
+ if (order === null) return null;
74
+ if (prev?.order === order || !ready) return prev;
75
+ return { order, signature };
76
+ });
77
+ // A cart change re-runs this and keeps `prev`: only a NEW order records, so
78
+ // the raised basket never follows the cart it is compared with.
79
+ }, [order, ready, signature]);
80
+ if (order === null || raised === null || raised.order !== order || !ready) return false;
81
+ return raised.signature !== signature;
82
+ }
83
+
84
+ /** The step's "bring this payment up to date", with what the buyer must see of it. */
85
+ export interface OrderRefresh {
86
+ run: () => void;
87
+ /** A refresh is on its way — the control waits rather than firing twice. */
88
+ pending: boolean;
89
+ /** The last refresh failed, in the host's words; null otherwise. */
90
+ error: string | null;
91
+ }
92
+
93
+ /**
94
+ * Re-price the order IN PLACE: ask the host for the order again and swap it in
95
+ * without dropping the one on screen first.
96
+ *
97
+ * Unlike raising a payment (`./start-payment.ts`), nothing is cleared before
98
+ * the answer: the card form keeps what the buyer typed, and the refusal that
99
+ * sent us here stays on screen while the amount under it corrects itself. The
100
+ * answer is routed exactly as a raise is (`route`, which is
101
+ * `routeRaisedOrder`), so a re-priced order that is already PAID or must finish
102
+ * on the provider's page goes where a raised one would.
103
+ *
104
+ * Three guards, each against something a review found:
105
+ *
106
+ * - one refresh at a time — a second tap while one is out does nothing;
107
+ * - an answer for an order the screen has moved on from (the buyer switched
108
+ * method, or raised the payment again) is DROPPED rather than resurrected;
109
+ * - a failure is KEPT here and shown by {@link CartChangedBoundary}, because the
110
+ * step's own create-error slot is hidden whenever an order is on screen.
111
+ */
112
+ export function useOrderRefresh(input: {
113
+ order: CheckoutOrder | null;
114
+ buyer: BuyerInfo;
115
+ saveProfile: boolean;
116
+ createOrder: (request: CreateOrderRequest) => Promise<CreateOrderResult>;
117
+ route: (order: CheckoutOrder) => void;
118
+ }): OrderRefresh {
119
+ const { order, buyer, saveProfile, createOrder, route } = input;
120
+ const offline = useCheckoutCopy().screens.transport.offline;
121
+ const [pending, setPending] = useState(false);
122
+ const [error, setError] = useState<string | null>(null);
123
+ const current = useRef(order);
124
+ const inFlight = useRef(false);
125
+ useEffect(() => {
126
+ current.current = order;
127
+ }, [order]);
128
+ // A failure is about the order it was raised for: once another is on screen
129
+ // (a method switch, a fresh raise) it is no longer true, and must not stay up.
130
+ const orderId = order?.orderId;
131
+ const method = order?.method;
132
+ useEffect(() => {
133
+ setError(null);
134
+ }, [orderId, method]);
135
+ const run = useCallback(() => {
136
+ if (!order || inFlight.current) return;
137
+ inFlight.current = true;
138
+ setPending(true);
139
+ setError(null);
140
+ void createOrder({ method: order.method, buyer, saveProfile })
141
+ .catch((): CreateOrderResult => ({ ok: false, error: { code: "NETWORK", message: offline, field: null } }))
142
+ .then((result) => {
143
+ inFlight.current = false;
144
+ setPending(false);
145
+ // The same order still on screen — by id and method, since a re-priced
146
+ // order comes back as a new object for the same id.
147
+ const now = current.current;
148
+ if (!now || now.orderId !== order.orderId || now.method !== order.method) return;
149
+ if (!result.ok) setError(result.error.message);
150
+ else route(result.data);
151
+ });
152
+ }, [order, buyer, saveProfile, createOrder, route, offline]);
153
+ return { run, pending, error };
154
+ }
155
+
156
+ /**
157
+ * How this step brings a payment up to date with the cart (FUT-1139): a PIX
158
+ * code is minted again — the amount is IN the code — and a card order is
159
+ * re-priced in place so the form keeps what was typed.
160
+ */
161
+ export function paymentRefresh(
162
+ order: CheckoutOrder | null,
163
+ onGenerate: (method: PaymentMethod) => void,
164
+ onRefreshOrder: (() => void) | undefined,
165
+ ): (() => void) | null {
166
+ if (!order) return null;
167
+ if (order.method === "CARD" && onRefreshOrder) return onRefreshOrder;
168
+ return () => onGenerate(order.method);
169
+ }
170
+
171
+ const PaymentRefreshContext = createContext<(() => void) | null>(null);
172
+
173
+ /** Hands the payment panes the step's "bring this payment up to date". */
174
+ export function PaymentRefreshProvider({
175
+ refresh,
176
+ children,
177
+ }: {
178
+ refresh: (() => void) | null;
179
+ children: ReactNode;
180
+ }): JSX.Element {
181
+ return <PaymentRefreshContext.Provider value={refresh}>{children}</PaymentRefreshContext.Provider>;
182
+ }
183
+
184
+ /**
185
+ * The card pane's half: a charge the host refused with
186
+ * {@link CART_CHANGED_CODE} re-reads the order, once per refusal.
187
+ */
188
+ export function useRefreshOnCartChanged(errorCode: string | null): void {
189
+ const refresh = useContext(PaymentRefreshContext);
190
+ // Held in a ref: the step hands down a fresh closure on every render, and
191
+ // keying the effect on it would re-fire the refresh after each re-price for
192
+ // as long as the refusal stays on screen.
193
+ const latest = useRef(refresh);
194
+ useEffect(() => {
195
+ latest.current = refresh;
196
+ }, [refresh]);
197
+ useEffect(() => {
198
+ if (errorCode === CART_CHANGED_CODE) latest.current?.();
199
+ }, [errorCode]);
200
+ }
201
+
202
+ /**
203
+ * The payment pane, with the step's warning above it when the cart moved, and
204
+ * the refresh handed down to the card form. Nothing changes on screen while the
205
+ * basket is the one the order was raised from.
206
+ */
207
+ export function CartChangedBoundary({
208
+ order,
209
+ basket,
210
+ creating,
211
+ onGenerate,
212
+ refresh,
213
+ children,
214
+ }: {
215
+ order: CheckoutOrder | null;
216
+ /** Absent for a settlement: it pays a balance, not the cart, so its basket is no signal. */
217
+ basket: CheckoutBasketIdentity | undefined;
218
+ creating: boolean;
219
+ onGenerate: (method: PaymentMethod) => void;
220
+ refresh: OrderRefresh | undefined;
221
+ children: ReactNode;
222
+ }): JSX.Element {
223
+ const changed = useBasketChanged(order, basket) && !creating;
224
+ const run = paymentRefresh(order, onGenerate, refresh?.run);
225
+ const pending = refresh?.pending ?? false;
226
+ const error = refresh?.error ?? null;
227
+ // ONE tree shape whether or not the warning shows: wrapping the pane only
228
+ // while it does would remount the card form the moment the refresh lands,
229
+ // dropping what the buyer typed and the refusal that explains the new total.
230
+ return (
231
+ <Box sx={{ display: "flex", flexDirection: "column", gap: 3 }}>
232
+ {(changed || error) && run ? <CartChangedNotice onRefresh={run} pending={pending} error={error} /> : null}
233
+ <PaymentRefreshProvider refresh={run}>{children}</PaymentRefreshProvider>
234
+ </Box>
235
+ );
236
+ }
237
+
238
+ /** "Seu carrinho mudou" — what the step says, and the one thing to do about it. */
239
+ function CartChangedNotice({
240
+ onRefresh,
241
+ pending = false,
242
+ error = null,
243
+ }: {
244
+ onRefresh: () => void;
245
+ pending?: boolean;
246
+ /** A refresh that failed, in the host's words: said, with the same control to try again. */
247
+ error?: string | null;
248
+ }): JSX.Element {
249
+ const { Alert, Button } = useCheckoutComponents();
250
+ const copy = useCheckoutCopy().screens.cartChanged;
251
+ return (
252
+ <Box data-testid="checkout-cart-changed" sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
253
+ {error ? (
254
+ <Alert variant="danger" title={copy.refreshFailed} description={error} showIcon data-testid="checkout-cart-changed-error" />
255
+ ) : (
256
+ <Alert variant="warning" title={copy.heading} description={copy.support} showIcon />
257
+ )}
258
+ <Box>
259
+ <Button
260
+ variant="solid"
261
+ color="primary"
262
+ size="md"
263
+ onClick={onRefresh}
264
+ loading={pending}
265
+ disabled={pending}
266
+ dataTestId="checkout-cart-changed-refresh"
267
+ >
268
+ {copy.refreshAction}
269
+ </Button>
270
+ </Box>
271
+ </Box>
272
+ );
273
+ }
@@ -297,6 +297,7 @@ function PagamentoStep({
297
297
  errorField={c.errorField}
298
298
  errorCode={c.errorCode}
299
299
  onGenerate={(chosen) => void c.startPayment(chosen)}
300
+ refresh={c.refresh}
300
301
  onUseEmail={c.payWithEmail}
301
302
  // Set only for a skipped-Dados flow (the controller decides); the payer
302
303
  // block hides itself when it is absent.
@@ -307,8 +308,7 @@ function PagamentoStep({
307
308
  cartTotals={cart}
308
309
  totalOverride={settlementTotalOverride(settlement)}
309
310
  discountLines={cart.discountLines}
310
- // Retrying a refused card: the saved card that failed is not chosen for
311
- // them again (FUT-1145).
311
+ // Retrying a refused card: the failed saved card is not chosen again (FUT-1145).
312
312
  freshInstrument={c.freshInstrument}
313
313
  // The card path parks an order of its own for a 3-D Secure challenge, so
314
314
  // it needs the same basket the flow was mounted for (FUT-1213).
@@ -3,6 +3,7 @@ import { useEffect, useRef, type JSX, type ReactNode } from "react";
3
3
 
4
4
  import type { CheckoutBasketIdentity } from "./basket";
5
5
  import { displayTotals, PayBarTotal } from "./checkout-totals";
6
+ import { CartChangedBoundary, type OrderRefresh } from "./cart-changed";
6
7
  import { useCheckoutCopy } from "./copy-context";
7
8
  import { useMethodChoice } from "./method-choice";
8
9
  import { MethodPicker } from "./method-picker";
@@ -180,6 +181,11 @@ interface PaymentStepProps {
180
181
  */
181
182
  errorCode?: string | null;
182
183
  onGenerate: (method: PaymentMethod) => void;
184
+ /**
185
+ * Re-price the raised order IN PLACE, keeping the card form (FUT-1139). Absent,
186
+ * the step falls back to raising the payment again.
187
+ */
188
+ refresh?: OrderRefresh;
183
189
  onUseEmail: (email: string) => void;
184
190
  /**
185
191
  * Present ⇒ the buyer reached this step without a Dados step (FUT-465), so
@@ -240,6 +246,7 @@ export function PaymentStep({
240
246
  errorField,
241
247
  errorCode,
242
248
  onGenerate,
249
+ refresh,
243
250
  onUseEmail,
244
251
  onEditBuyer,
245
252
  providerConfig,
@@ -261,11 +268,8 @@ export function PaymentStep({
261
268
  return (
262
269
  <PixStageProvider stage={pixStage}>
263
270
  <Box sx={{ display: "flex", flexDirection: "column", gap: 3 }}>
264
- {/* THE AMOUNT, before anything asks for it (FUT-1179). At the top rather
265
- than in a sticky bar of its own: this step's actions belong to the
266
- pane below it — the PIX code, the card form's own pay bar, the
267
- hand-off button — and a second bar would put two "pay" controls on
268
- one screen. */}
271
+ {/* THE AMOUNT, before anything asks for it (FUT-1179) — at the top, not in a
272
+ bar of its own: a second bar would put two "pay" controls on one screen. */}
269
273
  <PaymentStepTotal money={{ order, cartTotals, totalOverride, discountLines }} />
270
274
 
271
275
  {/* Self-hiding: renders only for a flow whose Dados step was skipped. */}
@@ -278,20 +282,22 @@ export function PaymentStep({
278
282
  )}
279
283
 
280
284
  <StepColumn full={order?.method === "PIX"}>
281
- <PaymentBody
282
- order={order}
283
- buyer={buyer}
284
- providerConfig={config}
285
- method={method}
286
- tenantSlug={tenantSlug}
287
- onResolved={onResolved}
288
- onStart={choice.onStart}
289
- creating={creating}
290
- pollIntervalMs={pollIntervalMs}
291
- freshInstrument={freshInstrument}
292
- basket={basket}
293
- validateApplePayMerchant={validateApplePayMerchant}
294
- />
285
+ <CartChangedBoundary {...{ order, creating, onGenerate, refresh }} basket={totalOverride ? undefined : basket}>
286
+ <PaymentBody
287
+ order={order}
288
+ buyer={buyer}
289
+ providerConfig={config}
290
+ method={method}
291
+ tenantSlug={tenantSlug}
292
+ onResolved={onResolved}
293
+ onStart={choice.onStart}
294
+ creating={creating}
295
+ pollIntervalMs={pollIntervalMs}
296
+ freshInstrument={freshInstrument}
297
+ basket={basket}
298
+ validateApplePayMerchant={validateApplePayMerchant}
299
+ />
300
+ </CartChangedBoundary>
295
301
  </StepColumn>
296
302
 
297
303
  <StepColumn>
@@ -19,15 +19,21 @@ import {
19
19
  CircularProgress,
20
20
  FormControl,
21
21
  FormControlLabel,
22
+ FormHelperText,
22
23
  FormLabel,
24
+ InputLabel,
25
+ OutlinedInput,
23
26
  Radio,
24
27
  RadioGroup as MuiRadioGroup,
25
28
  Step,
26
29
  StepLabel,
27
30
  Stepper as MuiStepper,
28
- TextField,
29
31
  Typography,
32
+ styled,
33
+ useThemeProps,
30
34
  } from '@mui/material';
35
+ import type { TextFieldProps } from '@mui/material';
36
+ import { useId } from 'react';
31
37
  import type { JSX } from 'react';
32
38
 
33
39
  import type {
@@ -93,25 +99,170 @@ function DefaultButton({ variant = 'solid', color = 'primary', size = 'md', full
93
99
  );
94
100
  }
95
101
 
96
- function DefaultInput({ label, type = 'text', inputMode, fullWidth, required, autoComplete, placeholder, maxLength, value, error, helperText, endAdornment, onChange, onBlur, ...rest }: CheckoutInputProps): JSX.Element {
97
- return (
98
- <TextField
99
- label={label}
100
- type={type}
101
- size="small"
102
- fullWidth={fullWidth}
103
- required={required}
104
- placeholder={placeholder}
105
- value={value}
106
- error={error}
107
- helperText={helperText}
108
- onChange={onChange}
109
- onBlur={onBlur}
110
- slotProps={{
102
+ type OwnerState = Record<string, unknown>;
103
+ type SlotProps = Record<string, unknown> | ((ownerState: OwnerState) => Record<string, unknown>) | undefined;
104
+
105
+ /**
106
+ * `TextField`'s root, by name and with its `ownerState`: a theme's
107
+ * `components.MuiTextField` (`styleOverrides`, including functions reading
108
+ * `ownerState`, and `variants`) applies exactly as it did to `TextField`.
109
+ */
110
+ const TextFieldRoot = styled(FormControl, {
111
+ name: 'MuiTextField',
112
+ slot: 'Root',
113
+ overridesResolver: (_props, styles) => styles.root,
114
+ })<{ ownerState: OwnerState }>({});
115
+
116
+ /** A theme's slot props may be a function of `ownerState`, as `useSlot` allows. */
117
+ function slotPropsOf(slotProps: SlotProps, ownerState: OwnerState): Record<string, unknown> {
118
+ return (typeof slotProps === 'function' ? slotProps(ownerState) : slotProps) ?? {};
119
+ }
120
+
121
+ /** `TextField`'s `ownerState`, for the outlined, non-select field this slot is. */
122
+ function ownerStateOf(props: TextFieldProps): OwnerState {
123
+ return {
124
+ ...props,
125
+ autoFocus: false,
126
+ color: props.color ?? 'primary',
127
+ disabled: props.disabled ?? false,
128
+ error: props.error ?? false,
129
+ fullWidth: props.fullWidth ?? false,
130
+ multiline: false,
131
+ required: props.required ?? false,
132
+ select: false,
133
+ variant: 'outlined',
134
+ };
135
+ }
136
+
137
+ /**
138
+ * The slot's own props, run through the theme's `MuiTextField` defaults the way
139
+ * `TextField` runs them (`useDefaultProps` is `useThemeProps` by another name).
140
+ */
141
+ function useTextFieldProps({ label, type = 'text', inputMode, fullWidth, required, autoComplete, placeholder, maxLength, value, error, helperText, endAdornment, onChange, onBlur, ...rest }: CheckoutInputProps): TextFieldProps {
142
+ return useThemeProps({
143
+ name: 'MuiTextField',
144
+ props: {
145
+ label,
146
+ type,
147
+ size: 'small',
148
+ fullWidth,
149
+ required,
150
+ placeholder,
151
+ value,
152
+ error,
153
+ helperText,
154
+ onChange,
155
+ onBlur,
156
+ slotProps: {
111
157
  htmlInput: { inputMode, maxLength, autoComplete, 'data-testid': rest['data-testid'] },
112
158
  input: { endAdornment },
113
- }}
114
- />
159
+ },
160
+ } as TextFieldProps,
161
+ });
162
+ }
163
+
164
+ /**
165
+ * What `TextField` consumes by name rather than handing to its root. A key LIST
166
+ * rather than a destructure, which would bind every one of them only to drop it.
167
+ */
168
+ const CONSUMED_BY_FIELD: ReadonlySet<string> = new Set([
169
+ 'autoComplete', 'autoFocus', 'children', 'className', 'color', 'defaultValue', 'disabled', 'error',
170
+ 'FormHelperTextProps', 'fullWidth', 'helperText', 'id', 'InputLabelProps', 'inputProps', 'InputProps',
171
+ 'inputRef', 'label', 'maxRows', 'minRows', 'multiline', 'name', 'onBlur', 'onChange', 'onFocus',
172
+ 'placeholder', 'required', 'rows', 'select', 'SelectProps', 'slotProps', 'slots', 'type', 'value',
173
+ 'variant',
174
+ ]);
175
+
176
+ /**
177
+ * The slot props as `TextField` resolves them: the legacy `InputProps`,
178
+ * `InputLabelProps`, `inputProps` and `FormHelperTextProps` first, then
179
+ * `slotProps` over them.
180
+ */
181
+ function slotPropsFor(props: TextFieldProps): Record<string, SlotProps> {
182
+ return {
183
+ input: props.InputProps as SlotProps,
184
+ inputLabel: props.InputLabelProps as SlotProps,
185
+ htmlInput: props.inputProps as SlotProps,
186
+ formHelperText: props.FormHelperTextProps as SlotProps,
187
+ ...((props.slotProps ?? {}) as Record<string, SlotProps>),
188
+ };
189
+ }
190
+
191
+ /** The root: what `TextField` did not consume, then the props it hands the root by name. */
192
+ function rootPropsOf(props: TextFieldProps, ownerState: OwnerState): Record<string, unknown> {
193
+ return {
194
+ ...Object.fromEntries(Object.entries(props).filter(([key]) => !CONSUMED_BY_FIELD.has(key))),
195
+ ownerState,
196
+ className: ['MuiTextField-root', (props.classes as { root?: string } | undefined)?.root, props.className]
197
+ .filter(Boolean)
198
+ .join(' '),
199
+ disabled: ownerState.disabled,
200
+ error: ownerState.error,
201
+ fullWidth: ownerState.fullWidth,
202
+ required: ownerState.required,
203
+ color: ownerState.color,
204
+ variant: 'outlined',
205
+ };
206
+ }
207
+
208
+ /** The input: the props `TextField` names, then the theme's and the slot's `input` slot props. */
209
+ function inputPropsOf(props: TextFieldProps, ownerState: OwnerState, ids: { id: string; helperTextId?: string }): Record<string, unknown> {
210
+ const slots = slotPropsFor(props);
211
+ const shrink = slotPropsOf(slots.inputLabel, ownerState).shrink;
212
+ return {
213
+ 'aria-describedby': ids.helperTextId,
214
+ autoComplete: props.autoComplete,
215
+ autoFocus: ownerState.autoFocus,
216
+ defaultValue: props.defaultValue,
217
+ fullWidth: ownerState.fullWidth,
218
+ name: props.name,
219
+ inputRef: props.inputRef,
220
+ onFocus: props.onFocus,
221
+ type: props.type,
222
+ value: props.value,
223
+ id: ids.id,
224
+ onBlur: props.onBlur,
225
+ onChange: props.onChange,
226
+ placeholder: props.placeholder,
227
+ inputProps: slotPropsOf(slots.htmlInput, ownerState),
228
+ label: props.label,
229
+ ...(shrink === undefined ? {} : { notched: shrink }),
230
+ ...slotPropsOf(slots.input, ownerState),
231
+ };
232
+ }
233
+
234
+ /**
235
+ * An outlined text field, composed from the parts MUI's `TextField` composes
236
+ * (FUT-3402). `TextField` itself imports `Select` unconditionally, and through
237
+ * it `Menu`, `MenuList`, `Popover` and `List`; this slot is in every host's
238
+ * checkout bundle whether or not the host fills it, so it paid for a dropdown
239
+ * it can never render.
240
+ *
241
+ * Same DOM and the same theme hooks — `defaultProps`, `styleOverrides`,
242
+ * `variants`, slot props — pinned by `default-input-parity.test.tsx`, with two
243
+ * deliberate exceptions: a theme default `variant` other than `outlined`, and
244
+ * `slots` replacing a part, are not honoured. Either would need the very
245
+ * components this slot exists to leave out; a host that wants them fills the
246
+ * `Input` slot.
247
+ */
248
+ function DefaultInput(slotProps: CheckoutInputProps): JSX.Element {
249
+ const props = useTextFieldProps(slotProps);
250
+ const ownerState = ownerStateOf(props);
251
+ const generatedId = useId();
252
+ const id = props.id ?? generatedId;
253
+ const helperTextId = props.helperText ? `${id}-helper-text` : undefined;
254
+ const slots = slotPropsFor(props);
255
+ const { label, helperText } = props;
256
+ return (
257
+ <TextFieldRoot {...(rootPropsOf(props, ownerState) as { ownerState: OwnerState })}>
258
+ {label != null && label !== '' ? (
259
+ <InputLabel htmlFor={id} id={`${id}-label`} {...slotPropsOf(slots.inputLabel, ownerState)}>{label}</InputLabel>
260
+ ) : null}
261
+ <OutlinedInput {...inputPropsOf(props, ownerState, { id, helperTextId })} />
262
+ {helperText ? (
263
+ <FormHelperText id={helperTextId} {...slotPropsOf(slots.formHelperText, ownerState)}>{helperText}</FormHelperText>
264
+ ) : null}
265
+ </TextFieldRoot>
115
266
  );
116
267
  }
117
268
 
@@ -266,6 +266,21 @@ export interface HostedHandoverCopy {
266
266
  preparing: string;
267
267
  }
268
268
 
269
+ /**
270
+ * The cart changed after this payment was prepared (FUT-1139): another tab, a
271
+ * price the store moved, a line it stopped selling. The charge prepared for the
272
+ * OLD basket must not be the one paid, so the step says so and offers the one
273
+ * control that brings the payment up to date.
274
+ */
275
+ export interface CartChangedCopy {
276
+ heading: string;
277
+ support: string;
278
+ /** Re-prepares the payment for the cart as it stands now. */
279
+ refreshAction: string;
280
+ /** The heading over a refresh that failed; the host's own sentence is the body. */
281
+ refreshFailed: string;
282
+ }
283
+
269
284
  /** What the browser says when the wire itself failed. */
270
285
  export interface CheckoutTransportCopy {
271
286
  /** A refusal whose envelope carried no sentence of its own. */
@@ -298,6 +313,7 @@ export interface CheckoutScreensCopy {
298
313
  hosted: HostedHandoverCopy;
299
314
  transport: CheckoutTransportCopy;
300
315
  validation: CheckoutValidationCopy;
316
+ cartChanged: CartChangedCopy;
301
317
  /** The step wrapper's own wait, while the charge is being raised. */
302
318
  generatingPayment: string;
303
319
  /**
@@ -143,6 +143,12 @@ export const EN_US_CHECKOUT_SCREENS_COPY: CheckoutScreensCopy = {
143
143
  invalidResponse: 'Invalid response from the server.',
144
144
  offline: 'Could not connect. Check your connection and try again.',
145
145
  },
146
+ cartChanged: {
147
+ heading: 'Your cart changed',
148
+ support: 'This payment was prepared for your previous cart. Update it to pay the right amount.',
149
+ refreshAction: 'Update payment',
150
+ refreshFailed: 'Could not update the payment',
151
+ },
146
152
  validation: {
147
153
  taxIdInvalid: 'That CPF is not valid.',
148
154
  nameRequired: 'Enter your name.',
@@ -129,6 +129,12 @@ export const PT_BR_CHECKOUT_SCREENS_COPY: CheckoutScreensCopy = {
129
129
  invalidResponse: 'Resposta inválida do servidor.',
130
130
  offline: 'Não foi possível conectar. Verifique sua conexão e tente novamente.',
131
131
  },
132
+ cartChanged: {
133
+ heading: 'Seu carrinho mudou',
134
+ support: 'O pagamento foi preparado para o carrinho anterior. Atualize para pagar o valor certo.',
135
+ refreshAction: 'Atualizar pagamento',
136
+ refreshFailed: 'Não foi possível atualizar o pagamento',
137
+ },
132
138
  validation: {
133
139
  taxIdInvalid: 'CPF inválido.',
134
140
  nameRequired: 'Informe seu nome.',
@@ -124,28 +124,57 @@ export function useStartPayment(input: {
124
124
  failure.fail(result.error);
125
125
  return;
126
126
  }
127
- // NOTHING LEFT TO PAY — see the docblock. Parked first for the same
128
- // reason every other raised order is: the confirmation this is about to
129
- // show has to survive a tab the phone discards on the way to it.
130
- if (result.data.status !== "AWAITING_PAYMENT") {
131
- rememberHostedOrder(result.data, { tenantSlug, basket: parkedBasket(basket) });
132
- setOrder(result.data);
133
- setFinalStatus(result.data.status);
134
- setStep("status");
135
- return;
136
- }
137
- if (handOverToProvider(result.data, navigate, tenantSlug, basket)) return;
138
- // PARKED EVEN THOUGH NOBODY IS LEAVING (FUT-1140). A low-memory phone
139
- // discards this tab while the shopper is in their bank app, and the SPA
140
- // that comes back has never heard of the order it raised — so the buyer
141
- // meets an empty cart and a retry button instead of the confirmation for
142
- // the payment they just made.
143
- rememberHostedOrder(result.data, { tenantSlug, basket: parkedBasket(basket) });
144
- setOrder(result.data);
127
+ routeRaisedOrder(result.data, { navigate, tenantSlug, basket, setOrder, setFinalStatus, setStep });
145
128
  },
146
129
  [
147
130
  buyer, saveProfile, createOrder, failure, navigate, tenantSlug, basket,
148
131
  setCreating, setDecline, setOrder, setFinalStatus, setStep,
149
132
  ],
150
133
  );
151
- }
134
+ }
135
+
136
+ /**
137
+ * What becomes of an order the host just raised — or re-priced (FUT-1139): the
138
+ * three outcomes of {@link useStartPayment} after its refusal branch, shared so
139
+ * a refresh routes a PAID or handed-off answer exactly as a raise does.
140
+ */
141
+ export function routeRaisedOrder(
142
+ order: CheckoutOrder,
143
+ ctx: {
144
+ navigate: CheckoutNavigate;
145
+ tenantSlug: string | undefined;
146
+ basket: CheckoutBasketIdentity | undefined;
147
+ setOrder: Dispatch<SetStateAction<CheckoutOrder | null>>;
148
+ setFinalStatus: Dispatch<SetStateAction<OrderStatus | null>>;
149
+ setStep: Dispatch<SetStateAction<Step>>;
150
+ },
151
+ ): void {
152
+ const { navigate, tenantSlug, basket, setOrder, setFinalStatus, setStep } = ctx;
153
+ // NOTHING LEFT TO PAY — see the docblock. Parked first for the same
154
+ // reason every other raised order is: the confirmation this is about to
155
+ // show has to survive a tab the phone discards on the way to it.
156
+ if (order.status !== "AWAITING_PAYMENT") {
157
+ rememberHostedOrder(order, { tenantSlug, basket: parkedBasket(basket) });
158
+ setOrder(order);
159
+ setFinalStatus(order.status);
160
+ setStep("status");
161
+ return;
162
+ }
163
+ if (handOverToProvider(order, navigate, tenantSlug, basket)) return;
164
+ // PARKED EVEN THOUGH NOBODY IS LEAVING (FUT-1140). A low-memory phone
165
+ // discards this tab while the shopper is in their bank app, and the SPA
166
+ // that comes back has never heard of the order it raised — so the buyer
167
+ // meets an empty cart and a retry button instead of the confirmation for
168
+ // the payment they just made.
169
+ rememberHostedOrder(order, { tenantSlug, basket: parkedBasket(basket) });
170
+ setOrder(order);
171
+ }
172
+
173
+ /** {@link routeRaisedOrder}, bound to one checkout and stable while its inputs are. */
174
+ export function useRaisedOrderRoute(ctx: Parameters<typeof routeRaisedOrder>[1]): (order: CheckoutOrder) => void {
175
+ const { navigate, tenantSlug, basket, setOrder, setFinalStatus, setStep } = ctx;
176
+ return useCallback(
177
+ (order: CheckoutOrder) => routeRaisedOrder(order, { navigate, tenantSlug, basket, setOrder, setFinalStatus, setStep }),
178
+ [navigate, tenantSlug, basket, setOrder, setFinalStatus, setStep],
179
+ );
180
+ }
@@ -11,7 +11,8 @@ import {
11
11
  useSettledPort,
12
12
  type Step,
13
13
  } from "./checkout-actions";
14
- import { useStartPayment } from "./start-payment";
14
+ import { useOrderRefresh } from "./cart-changed";
15
+ import { useRaisedOrderRoute, useStartPayment } from "./start-payment";
15
16
  import { useConfirmationWait } from "./confirmation-wait";
16
17
  import { useCheckoutCopy } from "./copy-context";
17
18
 
@@ -144,6 +145,9 @@ export function useCheckoutController(
144
145
  const { payWithEmail, handleResolved } = useResolutionActions({
145
146
  buyer, method, startPayment, setBuyerState, setDecline, setFinalStatus, setStep,
146
147
  });
148
+ // Re-price the order in place when the cart moved under it (FUT-1139), routed as a raise is.
149
+ const route = useRaisedOrderRoute({ navigate, tenantSlug, basket, setOrder, setFinalStatus, setStep });
150
+ const refresh = useOrderRefresh({ order, buyer, saveProfile, createOrder, route });
147
151
  const retry = useRetryAction({
148
152
  decline, order, clearError, setOrder, setDecline, setFinalStatus, setStep, setFreshInstrument,
149
153
  });
@@ -166,6 +170,6 @@ export function useCheckoutController(
166
170
  ...resumeSurface(resume, confirming),
167
171
  createError: failure.message, errorField: failure.field, errorCode: failure.code,
168
172
  goToMenu: onExitToMenu, back, editBuyer,
169
- goToPayment, startPayment, payWithEmail, handleResolved, retry, completed,
173
+ goToPayment, startPayment, refresh, payWithEmail, handleResolved, retry, completed,
170
174
  };
171
175
  }
package/src/index.ts CHANGED
@@ -118,6 +118,7 @@ export {
118
118
  // pack section by section types the section it is writing (FUT-760).
119
119
  export type {
120
120
  CardPaneCopy,
121
+ CartChangedCopy,
121
122
  CheckoutScreensCopy,
122
123
  CheckoutTransportCopy,
123
124
  CheckoutValidationCopy,