@12-apps/payments-frontend 3.20.0 → 3.21.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.
@@ -36,9 +36,16 @@ export interface WalletCharge {
36
36
  errorCode: string | null;
37
37
  /** The charge is unresolved: no pay control may render (FUT-563). */
38
38
  unresolved: boolean;
39
+ /**
40
+ * The last status poll failed. TRANSIENT (FUT-1144): the wait carries on at a
41
+ * backed-off cadence and this clears on the next success, so the pane shows
42
+ * it as "still trying" beside {@link pollCheckAgain} rather than as an end.
43
+ */
39
44
  pollError: string | null;
40
- /** The healthy-poll cap elapsed while still AWAITING (FUT-191). */
45
+ /** The bounded AWAITING wait elapsed (FUT-191, now wall-clock FUT-1144). */
41
46
  pollTimedOut: boolean;
47
+ /** Ask now and restart the wait — the buyer's "verificar de novo". */
48
+ pollCheckAgain: () => void;
42
49
  /**
43
50
  * Charge the wallet's key. The button calls this once the sheet resolves.
44
51
  * Resolves `true` when the charge was ACCEPTED — paid, confirming, or
@@ -49,10 +56,11 @@ export interface WalletCharge {
49
56
  }
50
57
 
51
58
  /**
52
- * Healthy-poll cap for the wallet AWAITING wait — the card path's own cap
53
- * (FUT-191): 36 polls 90 s at the 2500 ms default interval.
59
+ * The wallet AWAITING wait — the card path's own bound (FUT-191): 90 s, in WALL
60
+ * TIME rather than healthy polls (FUT-1144, and see `CARD_AWAITING_WAIT_MS` for
61
+ * why a count of the polls that SUCCEEDED cannot bound a wait that is failing).
54
62
  */
55
- const WALLET_AWAITING_POLL_CAP = 36;
63
+ const WALLET_AWAITING_WAIT_MS = 90_000;
56
64
 
57
65
  /** The wallet charge state machine. See the module comment. */
58
66
  export function useWalletCharge(
@@ -67,10 +75,15 @@ export function useWalletCharge(
67
75
  const client = useCheckoutClientApi();
68
76
  const navigate = useCheckoutNavigate();
69
77
 
70
- const { status, error: pollError, timedOut: pollTimedOut } = usePaymentPolling(order.orderId, {
78
+ const {
79
+ status,
80
+ error: pollError,
81
+ timedOut: pollTimedOut,
82
+ checkAgain: pollCheckAgain,
83
+ } = usePaymentPolling(order.orderId, {
71
84
  enabled: phase === "polling",
72
85
  intervalMs: pollIntervalMs,
73
- maxHealthyPolls: WALLET_AWAITING_POLL_CAP,
86
+ maxWaitMs: WALLET_AWAITING_WAIT_MS,
74
87
  });
75
88
 
76
89
  useEffect(() => {
@@ -124,6 +137,7 @@ export function useWalletCharge(
124
137
  unresolved: errorCode === UNRESOLVED_CODE,
125
138
  pollError,
126
139
  pollTimedOut,
140
+ pollCheckAgain,
127
141
  payWithKey,
128
142
  };
129
143
  }
@@ -74,8 +74,23 @@ export interface PaymentStatusCopy {
74
74
  failed: StatusOutcomeCopy;
75
75
  expired: StatusOutcomeCopy;
76
76
  awaitingTimedOut: StatusOutcomeCopy;
77
+ /**
78
+ * The wait cannot reach the payment right now (FUT-1144) — and is STILL
79
+ * ASKING, which is the whole difference between this outcome and the one
80
+ * above it, and why the one above it wins when a screen somehow has both. The
81
+ * resumed hosted return used to render neither: its poll could fail forever
82
+ * and the screen went on saying "isso costuma levar alguns segundos" under a
83
+ * spinner, so the one leg with no PIX or card view of its own was also the
84
+ * one that never mentioned a problem.
85
+ */
86
+ awaitingUnreachable: StatusOutcomeCopy;
77
87
  retryAction: string;
78
88
  regenerateAction: string;
89
+ /**
90
+ * Ask now, rather than waiting for the next automatic poll — and, once the
91
+ * wait has run out, the only thing that starts it again.
92
+ */
93
+ checkAgainAction: string;
79
94
  backAction: string;
80
95
  /** The paid receipt's three row labels. */
81
96
  amountLabel: string;
@@ -12,6 +12,7 @@ import {
12
12
  googlePayConfig,
13
13
  } from "./method-capability";
14
14
  import type { ProviderCheckoutScreenProps } from "./providers/types";
15
+ import { StalledWait } from "./stalled-wait";
15
16
  import { useCheckoutComponents } from "./ui";
16
17
  import { useWalletCharge, type WalletCharge } from "./use-wallet-charge";
17
18
 
@@ -35,29 +36,29 @@ type WalletPaneProps = ProviderCheckoutScreenProps & {
35
36
  order: NonNullable<ProviderCheckoutScreenProps["order"]>;
36
37
  };
37
38
 
38
- /** Post-submit confirmation, error > timeout > spinner — the card view's order. */
39
+ /** Post-submit confirmation, timeout > error > spinner — the card view's order. */
39
40
  function WalletProcessing({ wallet }: { wallet: WalletCharge }): JSX.Element {
40
- const { Alert, LoadingState } = useCheckoutComponents();
41
+ const { LoadingState } = useCheckoutComponents();
41
42
  const copy = useCheckoutCopy().screens.settling;
42
- if (wallet.pollError) {
43
+ if (wallet.pollTimedOut) {
43
44
  return (
44
- <Alert
45
- variant="danger"
46
- title={copy.cannotConfirm}
47
- description={wallet.pollError}
48
- showIcon
49
- data-testid="wallet-poll-error"
45
+ <StalledWait
46
+ title={copy.takingLonger}
47
+ description={copy.takingLongerHelp}
48
+ onCheckAgain={wallet.pollCheckAgain}
49
+ testId="wallet-poll-timeout"
50
+ actionTestId="wallet-check-again"
50
51
  />
51
52
  );
52
53
  }
53
- if (wallet.pollTimedOut) {
54
+ if (wallet.pollError) {
54
55
  return (
55
- <Alert
56
- variant="warning"
57
- title={copy.takingLonger}
58
- description={copy.takingLongerHelp}
59
- showIcon
60
- data-testid="wallet-poll-timeout"
56
+ <StalledWait
57
+ title={copy.connectionLost}
58
+ description={wallet.pollError}
59
+ onCheckAgain={wallet.pollCheckAgain}
60
+ testId="wallet-poll-error"
61
+ actionTestId="wallet-check-again"
61
62
  />
62
63
  );
63
64
  }
package/src/flows/copy.ts CHANGED
@@ -59,6 +59,25 @@ export interface CheckoutCopyFE {
59
59
  * part only a host can own.
60
60
  */
61
61
  returnTimedOut?: string;
62
+ /**
63
+ * The return leg while the poll cannot REACH us (FUT-1144) — a dropped
64
+ * connection, or a browser that aborted our requests while the buyer was on
65
+ * the provider's page. The wait is still running and re-arms the moment the
66
+ * tab or the signal comes back, so the sentence must say "still trying", not
67
+ * "failed".
68
+ *
69
+ * OPTIONAL, on the {@link returnTimedOut} precedent: without it the screen
70
+ * shows the transport's own sentence — which the host already owns, through
71
+ * `views.screens.transport` — under `returnPending` as a warning.
72
+ */
73
+ returnUnreachable?: string;
74
+ /**
75
+ * The label on the buyer's "ask now" (FUT-1144), offered whenever the wait is
76
+ * unreachable or has elapsed. OPTIONAL for the same reason, and its ABSENCE
77
+ * costs the button: a control with no host-written label could only carry
78
+ * this package's Portuguese.
79
+ */
80
+ returnCheckAgain?: string;
62
81
  /** The Dados step's primary action. */
63
82
  continueAction: string;
64
83
  /**
@@ -108,13 +108,60 @@ function buildHostedHandoff(runtime: FlowsRuntime): CheckoutScreens["HostedHando
108
108
  * Stated here rather than imported because the two waits are the same DECISION
109
109
  * arrived at twice, not one shared implementation: this screen takes its FAST
110
110
  * interval from the host's `polling` config when there is one, and a host that
111
- * tunes that must not have this package's cap silently mean a different
112
- * wall-clock window than the constant's comment claims.
111
+ * tunes that must not have this package's bound silently mean a different
112
+ * wall-clock window than the constant's comment claims — which is exactly what
113
+ * a bound counted in POLLS did, and why it is counted in milliseconds now
114
+ * (FUT-1144).
113
115
  */
114
116
  const RETURN_FAST_MS = 2_500;
115
117
  const RETURN_SLOW_MS = 10_000;
116
118
  const RETURN_FAST_POLLS = (2 * 60_000) / RETURN_FAST_MS;
117
- const RETURN_POLL_CAP = RETURN_FAST_POLLS + (13 * 60_000) / RETURN_SLOW_MS;
119
+ const RETURN_WINDOW_MS = 15 * 60_000;
120
+
121
+ /**
122
+ * The two ways this wait stops looking like progress, said as a warning with
123
+ * the buyer's own "ask now" under it (FUT-1144).
124
+ *
125
+ * The button is drawn only when the host wrote a label for it. That is the
126
+ * `returnTimedOut` precedent one step further: a bound with no copy still
127
+ * stops the spinner, and an action with no copy could only be labelled in this
128
+ * package's Portuguese, so it is the one half that stands down.
129
+ */
130
+ function ReturnStalled({
131
+ runtime,
132
+ description,
133
+ onCheckAgain,
134
+ testId,
135
+ }: {
136
+ runtime: FlowsRuntime;
137
+ description: string | undefined;
138
+ onCheckAgain: () => void;
139
+ testId: string;
140
+ }): JSX.Element {
141
+ const { Alert, Button } = useCheckoutComponents();
142
+ return (
143
+ <Box data-testid="checkout-hosted-return" sx={{ py: 4, display: "flex", flexDirection: "column", gap: 2 }}>
144
+ <Alert
145
+ variant="warning"
146
+ title={runtime.copy.returnPending}
147
+ {...(description === undefined ? {} : { description })}
148
+ showIcon
149
+ data-testid={testId}
150
+ />
151
+ {runtime.copy.returnCheckAgain === undefined ? null : (
152
+ <Button
153
+ variant="outline"
154
+ color="neutral"
155
+ size="md"
156
+ onClick={onCheckAgain}
157
+ dataTestId="checkout-hosted-return-check-again"
158
+ >
159
+ {runtime.copy.returnCheckAgain}
160
+ </Button>
161
+ )}
162
+ </Box>
163
+ );
164
+ }
118
165
 
119
166
  function buildHostedReturn(runtime: FlowsRuntime): CheckoutScreens["HostedReturn"] {
120
167
  function HostedReturnBody({
@@ -126,15 +173,15 @@ function buildHostedReturn(runtime: FlowsRuntime): CheckoutScreens["HostedReturn
126
173
  // Read-and-clear, once, on first render: the resumed view belongs to
127
174
  // exactly one return trip.
128
175
  const [parked] = useState(takeHostedOrder);
129
- // Bounded, for the reason on RETURN_POLL_CAP: nothing here can ever reach a
176
+ // Bounded, for the reason on RETURN_WINDOW_MS: nothing here can ever reach a
130
177
  // terminal state on its own, so an unbounded poll is a spinner the buyer
131
178
  // watches until they close the tab.
132
- const { status, timedOut } = usePaymentPolling(parked?.orderId ?? null, {
179
+ const { status, timedOut, error, checkAgain } = usePaymentPolling(parked?.orderId ?? null, {
133
180
  enabled: Boolean(parked),
134
181
  intervalMs: runtime.config.polling?.intervalMs ?? RETURN_FAST_MS,
135
182
  slowAfterPolls: RETURN_FAST_POLLS,
136
183
  slowIntervalMs: RETURN_SLOW_MS,
137
- maxHealthyPolls: RETURN_POLL_CAP,
184
+ maxWaitMs: RETURN_WINDOW_MS,
138
185
  });
139
186
 
140
187
  useEffect(() => {
@@ -152,19 +199,27 @@ function buildHostedReturn(runtime: FlowsRuntime): CheckoutScreens["HostedReturn
152
199
  />
153
200
  );
154
201
  }
202
+ // STOPPED beats STILL TRYING. A wait that failed its way to the wall clock
203
+ // carries both, and the elapsed state is the one that stops asking — and the
204
+ // one whose sentence says not to pay again.
155
205
  if (timedOut) {
156
206
  return (
157
- <Box data-testid="checkout-hosted-return" sx={{ py: 4 }}>
158
- <Alert
159
- variant="warning"
160
- title={runtime.copy.returnPending}
161
- {...(runtime.copy.returnTimedOut === undefined
162
- ? {}
163
- : { description: runtime.copy.returnTimedOut })}
164
- showIcon
165
- data-testid="checkout-hosted-return-timeout"
166
- />
167
- </Box>
207
+ <ReturnStalled
208
+ runtime={runtime}
209
+ description={runtime.copy.returnTimedOut}
210
+ onCheckAgain={checkAgain}
211
+ testId="checkout-hosted-return-timeout"
212
+ />
213
+ );
214
+ }
215
+ if (error !== null) {
216
+ return (
217
+ <ReturnStalled
218
+ runtime={runtime}
219
+ description={runtime.copy.returnUnreachable ?? error}
220
+ onCheckAgain={checkAgain}
221
+ testId="checkout-hosted-return-unreachable"
222
+ />
168
223
  );
169
224
  }
170
225
  return (