@12-apps/payments-frontend 3.34.2 → 3.34.4

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.2",
3
+ "version": "3.34.4",
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.",
@@ -5,9 +5,10 @@
5
5
  * Nothing here knows what the channel is. A host that can hear an order move —
6
6
  * a signed-in buyer's own topic, say — passes whether that channel is live and
7
7
  * a way to be told when it fires; every wait under the provider then asks the
8
- * moment it fires and slows its own timer while the channel is live. A tree with
9
- * no provider, or a host that passes `live: false` (a guest, who has no channel
10
- * of their own), polls exactly as before.
8
+ * moment it fires, and keeps no timer of its own while the channel is live
9
+ * (FUT-3223, `poll-live.ts`). A tree with no provider, or a host that passes
10
+ * `live: false` (a guest, who has no channel of their own), polls exactly as
11
+ * before.
11
12
  *
12
13
  * The hint carries nothing and is never trusted as an answer: it only makes the
13
14
  * wait ask `/status` now. The endpoint stays the truth.
@@ -26,14 +27,19 @@ export interface CheckoutLiveSignal {
26
27
  * unsubscribe. Should be stable across renders: a new function re-subscribes.
27
28
  */
28
29
  subscribe: (onHint: () => void) => () => void;
29
- /** The wait's healthy delay while live (ms). Defaults to {@link DEFAULT_LIVE_INTERVAL_MS}. */
30
+ /**
31
+ * The healthy delay while live (ms) for the one wait that keeps reading while
32
+ * live: a hosted-checkout return carrying the settlement pair (`poll-live.ts`).
33
+ * Every other wait keeps no timer while live. Defaults to
34
+ * {@link DEFAULT_LIVE_INTERVAL_MS}.
35
+ */
30
36
  liveIntervalMs?: number;
31
37
  }
32
38
 
33
39
  /**
34
- * The floor under a lost hint while the channel is live: the same 15 s the PIX
35
- * wait already backs off to, so a wait whose channel is up never asks faster
36
- * than the slowest wait it would have become anyway.
40
+ * The floor a hosted-checkout return keeps while the channel is live: the same
41
+ * 15 s the PIX wait already backs off to, so a wait whose channel is up never
42
+ * asks faster than the slowest wait it would have become anyway.
37
43
  */
38
44
  export const DEFAULT_LIVE_INTERVAL_MS = 15_000;
39
45
 
@@ -61,9 +67,16 @@ type LoopRef = { readonly current: PollLoop | null };
61
67
  /**
62
68
  * A channel that DROPS: ask now — and if the loop declines because it asked a
63
69
  * moment ago, ask again once that quiet window is over. The ask it lands just
64
- * after may have been answered before the drop, and its next tick sleeps out
65
- * the live floor, so declining for good would leave the buyer waiting with
66
- * nobody left to wake them.
70
+ * after may have been answered before the drop, and a live wait books no next
71
+ * tick, so declining for good would leave the buyer waiting with nobody left
72
+ * to wake them.
73
+ *
74
+ * A channel that (RE-)OPENS does not come here either: it is taken as a HINT
75
+ * (FUT-3223). A hint sent while the channel was down was never heard, and a
76
+ * live wait has no timer to catch it, so the open is the re-read that bounds a
77
+ * lost hint — and `PollLoop.hint` never drops it: with an ask in flight it
78
+ * owes one more the moment that ask answers, where a declined poke could leave
79
+ * a pre-open answer as the last word.
67
80
  *
68
81
  * A HINT does not come here. It is `PollLoop.hint` (FUT-3205): deferring a
69
82
  * hint by the quiet window cost a paid buyer a full second on the pay step.
@@ -75,9 +88,10 @@ function wake(loop: LoopRef, pending: { timer?: ReturnType<typeof setTimeout> })
75
88
  }
76
89
 
77
90
  /**
78
- * Tie one wait to the host's channel: a hint asks now, and a channel that
79
- * DROPS asks now too — the timer may be sleeping out the live floor, and the
80
- * buyer should not wait the rest of it with nobody left to wake them.
91
+ * Tie one wait to the host's channel: a hint asks now, a channel that DROPS
92
+ * asks now too (see {@link wake}), and a channel that (RE-)OPENS is taken as a
93
+ * hint. The channel's state at mount needs no ask of its own: the wait's first
94
+ * ask is already that read.
81
95
  *
82
96
  * `loop` is the hook's own ref to its running loop, read when the event lands.
83
97
  */
@@ -92,6 +106,7 @@ export function useLiveWait(loop: LoopRef): LiveWait {
92
106
  const was = liveRef.current;
93
107
  liveRef.current = live;
94
108
  if (was && !live) wake(loop, pending.current);
109
+ else if (!was && live) loop.current?.hint();
95
110
  }, [live, loop]);
96
111
 
97
112
  useEffect(() => {
@@ -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
 
@@ -1,14 +1,29 @@
1
1
  /**
2
2
  * The cadence a payment wait keeps while the host HEARS the order move
3
- * (FUT-649).
3
+ * (FUT-649, FUT-3223).
4
4
  *
5
5
  * The wait asks `/status` every 2.5 s because, on its own, asking is the only
6
6
  * way it can learn that a webhook settled the order in another process. A host
7
7
  * that holds a realtime channel for this buyer already learns it the moment it
8
8
  * happens — `CheckoutLiveProvider` hands that hint in and the wait asks at once.
9
- * While that channel is live the timer is only the floor under a lost hint, so
10
- * it slows to {@link LiveCadence.liveIntervalMs}; the moment the channel drops,
11
- * the ordinary cadence is back on the next tick.
9
+ * So while that channel is live a healthy answer books NO timer: the wait asks
10
+ * on a hint, on `visibilitychange`/`online`, and when the channel opens or
11
+ * re-opens — a screen hears live data over the socket and polls only while the
12
+ * socket is down. The host's server owns what a timer used to catch: a lapsed
13
+ * code and a webhook that never came are found server-side and published to
14
+ * the same channel. The moment the channel drops, the ordinary cadence is back.
15
+ *
16
+ * Two exceptions keep a timer while live:
17
+ *
18
+ * - A FAILED ask is a read that did not happen, not a check for news, so it
19
+ * retries on the ordinary error backoff (`poll-delay.ts`: 2.5 s, 5 s, then
20
+ * every 10 s), as a wait with no channel does — no longer stretched to the
21
+ * 15 s live floor. Otherwise a hint whose ask hit a 500 would leave a paid
22
+ * buyer waiting for a second hint that is never sent.
23
+ * - A wait given {@link LiveCadence.liveIntervalMs} keeps that floor. The hook
24
+ * gives it one only on a hosted-checkout return carrying the settlement pair
25
+ * (`carriesSettlementPair`), where the buyer's own read is what confirms the
26
+ * payment and no server sweep can stand in for it.
12
27
  *
13
28
  * Split out of `poll-loop.ts` so the loop stays one question: WHEN to ask. This
14
29
  * file answers only how being live changes that answer.
@@ -21,12 +36,21 @@ export interface LiveCadence {
21
36
  */
22
37
  isLive?: () => boolean;
23
38
  /**
24
- * The healthy delay while live (ms). A floor, never a speed-up: a slower
25
- * cadence already in force (the PIX backoff) is kept.
39
+ * The healthy delay while live (ms), for a wait that must keep reading while
40
+ * live (see the module docblock). A floor, never a speed-up: a slower cadence
41
+ * already in force (the PIX backoff) is kept. Undefined: no timer while live.
26
42
  */
27
43
  liveIntervalMs?: number;
28
44
  }
29
45
 
46
+ /**
47
+ * Whether the wait books nothing after this answer and waits to be told: a
48
+ * healthy answer, while live, for a wait with no live floor.
49
+ */
50
+ export function idlesWhileLive(errors: number, options: LiveCadence): boolean {
51
+ return errors === 0 && options.liveIntervalMs === undefined && options.isLive?.() === true;
52
+ }
53
+
30
54
  /** `delay`, stretched to the live floor while the host's channel is live. */
31
55
  export function withLive(delay: number, options: LiveCadence): number {
32
56
  if (options.liveIntervalMs === undefined || options.isLive?.() !== true) return delay;
@@ -1,6 +1,6 @@
1
1
  import type { Result } from "../../result";
2
2
  import { pollDelay } from "./poll-delay";
3
- import { waitsForDeadline, type LiveCadence } from "./poll-live";
3
+ import { idlesWhileLive, waitsForDeadline, type LiveCadence } from "./poll-live";
4
4
  import { claimHint, owedAfterAnswer } from "./poll-hint";
5
5
  import { claimRearm } from "./poll-rearm";
6
6
  import { TERMINAL_STATUSES, type OrderStatus } from "./types";
@@ -254,13 +254,19 @@ function mayWrite(run: PollRun, mine: number, result: Result<OrderStatus>): bool
254
254
  return run.attempt === mine || (result.ok && TERMINAL_STATUSES.includes(result.data));
255
255
  }
256
256
 
257
- /** Book the next tick, or end the wait because its clock has run out. */
257
+ /**
258
+ * Book the next tick, or end the wait because its clock has run out — or, live
259
+ * with a healthy answer, book nothing: the hint, the re-arm events and the
260
+ * channel re-opening are what ask next (`poll-live.ts`, FUT-3223).
261
+ */
258
262
  function scheduleNext(
259
263
  run: PollRun,
260
264
  options: PollingOptions,
261
265
  sink: PollSink,
262
266
  again: () => void,
263
267
  ): void {
268
+ clearPending(run);
269
+ if (idlesWhileLive(run.errors, options)) return;
264
270
  const delay = pollDelay(run.healthy, run.errors, options);
265
271
  if (outOfTime(run, options, delay)) {
266
272
  run.stopped = true;
@@ -268,7 +274,6 @@ function scheduleNext(
268
274
  sink.setTimedOut(true);
269
275
  return;
270
276
  }
271
- clearPending(run);
272
277
  run.timer = setTimeout(again, delay);
273
278
  }
274
279
 
@@ -152,6 +152,16 @@ function refused<T>(json: ApiEnvelope<T> | null, copy: CheckoutTransportCopy): R
152
152
  return err(json?.error ?? copy.failed, json?.code);
153
153
  }
154
154
 
155
+ /**
156
+ * Whether this page is a hosted-checkout return carrying the WHOLE settlement
157
+ * pair — the one case where a `/status` read is itself what confirms the
158
+ * payment, so a live wait keeps reading on its floor (FUT-3223, `poll-live.ts`).
159
+ */
160
+ export function carriesSettlementPair(): boolean {
161
+ const { transactionNsu, slug } = returnedSettlement();
162
+ return Boolean(transactionNsu && slug);
163
+ }
164
+
155
165
  /**
156
166
  * What a hosted checkout appended to the return URL when it sent the buyer
157
167
  * back, or undefined.
@@ -4,6 +4,7 @@ import { useCheckoutClientApi } from "./client-context";
4
4
  import { useCheckoutCopy } from "./copy-context";
5
5
  import { createPollLoop, type PollLoop, type PollingOptions, type PollSink } from "./poll-loop";
6
6
  import { useLiveWait } from "./live-context";
7
+ import { carriesSettlementPair } from "./transport";
7
8
  import type { OrderStatus } from "./types";
8
9
 
9
10
  /** What a consumer reads off the wait, and the one action it can take. */
@@ -110,6 +111,10 @@ function listenForRearm(poke: () => void): () => void {
110
111
  * QR under a red alert with no retry, a card spinner replaced by one, and the
111
112
  * hosted return spinning forever. None of the three could recover on its own,
112
113
  * which is what made a blip cost a payment nobody was ever told about.
114
+ *
115
+ * WHILE LIVE it keeps no timer (FUT-3223, `poll-live.ts`), except on a hosted
116
+ * return carrying the settlement pair, where the read itself confirms the
117
+ * payment and the live floor is kept.
113
118
  */
114
119
  export function usePaymentPolling(
115
120
  orderId: string | null,
@@ -149,7 +154,8 @@ export function usePaymentPolling(
149
154
  slowIntervalMs,
150
155
  askTimeoutMs,
151
156
  askTimeoutError: transportCopy.offline,
152
- ...{ isLive, liveIntervalMs },
157
+ isLive,
158
+ liveIntervalMs: carriesSettlementPair() ? liveIntervalMs : undefined,
153
159
  },
154
160
  sinkFor(orderId, setAnswer),
155
161
  );