@12-apps/payments-frontend 3.24.1 → 3.25.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.24.1",
3
+ "version": "3.25.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.",
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The host's realtime channel for THIS buyer, handed to every payment wait
3
+ * below it (FUT-649).
4
+ *
5
+ * Nothing here knows what the channel is. A host that can hear an order move —
6
+ * a signed-in buyer's own topic, say — passes whether that channel is live and
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.
11
+ *
12
+ * The hint carries nothing and is never trusted as an answer: it only makes the
13
+ * wait ask `/status` now. The endpoint stays the truth.
14
+ */
15
+ import { createContext, useCallback, useContext, useEffect, useRef, type JSX, type ReactNode } from "react";
16
+
17
+ import type { PollLoop } from "./poll-loop";
18
+ import { REARM_QUIET_MS } from "./poll-rearm";
19
+
20
+ /** What a host hands the checkout about its own realtime channel. */
21
+ export interface CheckoutLiveSignal {
22
+ /** Whether the channel is connected right now. */
23
+ live: boolean;
24
+ /**
25
+ * Be told when the channel says this buyer's orders moved. Returns the
26
+ * unsubscribe. Should be stable across renders: a new function re-subscribes.
27
+ */
28
+ subscribe: (onHint: () => void) => () => void;
29
+ /** The wait's healthy delay while live (ms). Defaults to {@link DEFAULT_LIVE_INTERVAL_MS}. */
30
+ liveIntervalMs?: number;
31
+ }
32
+
33
+ /**
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.
37
+ */
38
+ export const DEFAULT_LIVE_INTERVAL_MS = 15_000;
39
+
40
+ const CheckoutLiveContext = createContext<CheckoutLiveSignal | null>(null);
41
+
42
+ /** Hand every payment wait below the host's channel. */
43
+ export function CheckoutLiveProvider({
44
+ signal,
45
+ children,
46
+ }: {
47
+ signal: CheckoutLiveSignal;
48
+ children: ReactNode;
49
+ }): JSX.Element {
50
+ return <CheckoutLiveContext.Provider value={signal}>{children}</CheckoutLiveContext.Provider>;
51
+ }
52
+
53
+ /** What one wait reads off the provider, for `createPollLoop`. */
54
+ interface LiveWait {
55
+ isLive: () => boolean;
56
+ liveIntervalMs: number;
57
+ }
58
+
59
+ type LoopRef = { readonly current: PollLoop | null };
60
+
61
+ /**
62
+ * Ask now — and if the loop declines because it asked a moment ago, ask again
63
+ * once that quiet window is over. The window exists so a flapping network does
64
+ * not stampede `/status`; a hint is different, because the ask it lands just
65
+ * after may have been answered BEFORE the order moved. Dropping it would leave
66
+ * the buyer on the live floor with the news already sent.
67
+ */
68
+ function wake(loop: LoopRef, pending: { timer?: ReturnType<typeof setTimeout> }): void {
69
+ if (loop.current?.poke() !== false) return;
70
+ clearTimeout(pending.timer);
71
+ pending.timer = setTimeout(() => loop.current?.poke(), REARM_QUIET_MS);
72
+ }
73
+
74
+ /**
75
+ * Tie one wait to the host's channel: a hint asks now, and a channel that
76
+ * DROPS asks now too — the timer may be sleeping out the live floor, and the
77
+ * buyer should not wait the rest of it with nobody left to wake them.
78
+ *
79
+ * `loop` is the hook's own ref to its running loop, read when the event lands.
80
+ */
81
+ export function useLiveWait(loop: LoopRef): LiveWait {
82
+ const signal = useContext(CheckoutLiveContext);
83
+ const live = signal?.live === true;
84
+ const liveRef = useRef(live);
85
+ const pending = useRef<{ timer?: ReturnType<typeof setTimeout> }>({});
86
+ const subscribe = signal?.subscribe;
87
+
88
+ useEffect(() => {
89
+ const was = liveRef.current;
90
+ liveRef.current = live;
91
+ if (was && !live) wake(loop, pending.current);
92
+ }, [live, loop]);
93
+
94
+ useEffect(() => {
95
+ if (!subscribe) return undefined;
96
+ const retry = pending.current;
97
+ const unsubscribe = subscribe(() => wake(loop, retry));
98
+ return () => {
99
+ unsubscribe();
100
+ clearTimeout(retry.timer);
101
+ };
102
+ }, [subscribe, loop]);
103
+
104
+ // Stable, so the loop's deps do not churn: the loop reads the flag when it
105
+ // schedules, and a flip must never restart the wait's wall clock.
106
+ const isLive = useCallback(() => liveRef.current, []);
107
+ return { isLive, liveIntervalMs: signal?.liveIntervalMs ?? DEFAULT_LIVE_INTERVAL_MS };
108
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The cadence a payment wait keeps while the host HEARS the order move
3
+ * (FUT-649).
4
+ *
5
+ * The wait asks `/status` every 2.5 s because, on its own, asking is the only
6
+ * way it can learn that a webhook settled the order in another process. A host
7
+ * that holds a realtime channel for this buyer already learns it the moment it
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.
12
+ *
13
+ * Split out of `poll-loop.ts` so the loop stays one question: WHEN to ask. This
14
+ * file answers only how being live changes that answer.
15
+ */
16
+ export interface LiveCadence {
17
+ /**
18
+ * Whether the host's channel is live right now. Read at every scheduling
19
+ * decision rather than once, so a channel that drops mid-wait takes effect
20
+ * at the next tick instead of at the next restart.
21
+ */
22
+ isLive?: () => boolean;
23
+ /**
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.
26
+ */
27
+ liveIntervalMs?: number;
28
+ }
29
+
30
+ /** `delay`, stretched to the live floor while the host's channel is live. */
31
+ export function withLive(delay: number, options: LiveCadence): number {
32
+ if (options.liveIntervalMs === undefined || options.isLive?.() !== true) return delay;
33
+ return Math.max(delay, options.liveIntervalMs);
34
+ }
35
+
36
+ /**
37
+ * Whether the wait must stay open until its wall clock actually strikes.
38
+ *
39
+ * The loop normally ends one sleep EARLY when that sleep would cross
40
+ * `maxWaitMs` — nothing could land in it. While live, something can: the hint.
41
+ * Ending early there would hand a 90 s card wait over at 75 s and then refuse
42
+ * the hint that arrives at 80 s, so the armed deadline ends it instead.
43
+ */
44
+ export function waitsForDeadline(options: LiveCadence): boolean {
45
+ return options.isLive?.() === true;
46
+ }
@@ -1,4 +1,5 @@
1
1
  import type { Result } from "../../result";
2
+ import { waitsForDeadline, withLive, type LiveCadence } from "./poll-live";
2
3
  import { claimRearm } from "./poll-rearm";
3
4
  import { TERMINAL_STATUSES, type OrderStatus } from "./types";
4
5
 
@@ -11,7 +12,7 @@ import { TERMINAL_STATUSES, type OrderStatus } from "./types";
11
12
  * attempt counter that lets a hung request be abandoned rather than waited on.
12
13
  */
13
14
 
14
- export interface PollingOptions {
15
+ export interface PollingOptions extends LiveCadence {
15
16
  /** Delay between successful polls (ms). */
16
17
  intervalMs?: number;
17
18
  /**
@@ -106,7 +107,7 @@ function healthyDelay(healthy: number, options: PollingOptions): number {
106
107
  const { intervalMs = 2500, slowAfterPolls, slowIntervalMs } = options;
107
108
  const backingOff =
108
109
  slowAfterPolls !== undefined && slowIntervalMs !== undefined && healthy >= slowAfterPolls;
109
- return backingOff ? slowIntervalMs : intervalMs;
110
+ return withLive(backingOff ? slowIntervalMs : intervalMs, options);
110
111
  }
111
112
 
112
113
  /**
@@ -281,7 +282,8 @@ async function askOnce(
281
282
 
282
283
  /** Whether sleeping `delay` would carry the wait past its wall-clock bound. */
283
284
  function outOfTime(run: PollRun, options: PollingOptions, delay: number): boolean {
284
- return options.maxWaitMs !== undefined && Date.now() - run.startedAt + delay >= options.maxWaitMs;
285
+ if (options.maxWaitMs === undefined || waitsForDeadline(options)) return false;
286
+ return Date.now() - run.startedAt + delay >= options.maxWaitMs;
285
287
  }
286
288
 
287
289
  /**
@@ -316,8 +318,8 @@ function scheduleNext(
316
318
  export interface PollLoop {
317
319
  /** Reset the clock and the counters, then ask immediately. */
318
320
  restart: () => void;
319
- /** Ask immediately, keeping the clock — the re-arm events' entry point. */
320
- poke: () => void;
321
+ /** Ask immediately, keeping the clock — the re-arm events' entry point. Whether it asked. */
322
+ poke: () => boolean;
321
323
  /** Tear down: nothing further is scheduled and nothing further is written. */
322
324
  stop: () => void;
323
325
  }
@@ -336,8 +338,6 @@ export function createPollLoop(
336
338
  sink: PollSink,
337
339
  ): PollLoop {
338
340
  const run = newRun();
339
-
340
-
341
341
  const tick = async (): Promise<void> => {
342
342
  if (run.cancelled || run.settled || run.inFlight) return;
343
343
  run.inFlight = true;
@@ -364,9 +364,8 @@ export function createPollLoop(
364
364
  restart: (): void => {
365
365
  if (run.cancelled || run.settled) return;
366
366
  clearPending(run);
367
- // Same reasoning as `poke`, and this one is the buyer pressing a button:
368
- // "Verificar de novo" that cleared the panel and sent nothing — because
369
- // an ask was still notionally in flight — is the exact complaint.
367
+ // Same reasoning as `poke`, and this one is the buyer pressing a button: "Verificar
368
+ // de novo" that cleared the panel and sent nothing (an ask was in flight) is the complaint.
370
369
  run.attempt += 1;
371
370
  run.inFlight = false;
372
371
  run.stopped = false;
@@ -379,17 +378,18 @@ export function createPollLoop(
379
378
  sink.setError(null);
380
379
  void tick();
381
380
  },
382
- poke: (): void => {
383
- if (run.cancelled || run.stopped) return;
381
+ poke: (): boolean => {
382
+ if (run.cancelled || run.stopped) return false;
384
383
  // Deliberately NOT gated on `inFlight`: the shopper who just came back
385
384
  // from their bank app is exactly the case where the previous ask is a
386
385
  // socket that died while the screen was hidden. Abandon it and ask now —
387
386
  // but `claimRearm` bounds how many live asks that may abandon in a row.
388
- if (!claimRearm(run)) return;
387
+ if (!claimRearm(run)) return false;
389
388
  run.attempt += 1;
390
389
  run.inFlight = false;
391
390
  clearPending(run);
392
391
  void tick();
392
+ return true;
393
393
  },
394
394
  stop: (): void => {
395
395
  run.cancelled = true;
@@ -28,7 +28,7 @@ interface RearmState {
28
28
  * per trip. This collapses a burst into one request without delaying it: the
29
29
  * first re-arm of a burst polls immediately, the rest fall inside the gap.
30
30
  */
31
- const REARM_QUIET_MS = 1_000;
31
+ export const REARM_QUIET_MS = 1_000;
32
32
 
33
33
  /**
34
34
  * How many live asks a re-arm may abandon before it has to let one finish.
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateA
3
3
  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
+ import { useLiveWait } from "./live-context";
6
7
  import type { OrderStatus } from "./types";
7
8
 
8
9
  /** What a consumer reads off the wait, and the one action it can take. */
@@ -134,6 +135,7 @@ export function usePaymentPolling(
134
135
  // The live wait, so the returned action stays stable across renders while
135
136
  // still reaching whichever loop the effect currently owns.
136
137
  const loop = useRef<PollLoop | null>(null);
138
+ const { isLive, liveIntervalMs } = useLiveWait(loop); // FUT-649
137
139
 
138
140
  useEffect(() => {
139
141
  if (!orderId || !enabled) return undefined;
@@ -147,6 +149,7 @@ export function usePaymentPolling(
147
149
  slowIntervalMs,
148
150
  askTimeoutMs,
149
151
  askTimeoutError: transportCopy.offline,
152
+ ...{ isLive, liveIntervalMs },
150
153
  },
151
154
  sinkFor(orderId, setAnswer),
152
155
  );
@@ -169,6 +172,7 @@ export function usePaymentPolling(
169
172
  askTimeoutMs,
170
173
  client,
171
174
  transportCopy,
175
+ isLive, liveIntervalMs,
172
176
  ]);
173
177
 
174
178
  const checkAgain = useCallback(() => {
package/src/index.ts CHANGED
@@ -131,6 +131,13 @@ export type {
131
131
  } from './components/checkout/screens-copy';
132
132
  export { PT_BR_CHECKOUT_SCREENS_COPY } from './components/checkout/screens-pt-BR';
133
133
  export { fetchCheckoutConfig } from './components/checkout/client';
134
+ // The host's realtime channel for this buyer (FUT-649): a hint asks `/status`
135
+ // now, and every wait slows to a floor while the channel is live.
136
+ export {
137
+ CheckoutLiveProvider,
138
+ DEFAULT_LIVE_INTERVAL_MS,
139
+ type CheckoutLiveSignal,
140
+ } from './components/checkout/live-context';
134
141
  /**
135
142
  * The `sessionStorage` key the hosted-checkout return leg parks the raised
136
143
  * order under. Public because it is already observable — a spec asserting the