@12-apps/payments-frontend 1.7.1 → 1.8.1

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.
@@ -0,0 +1,196 @@
1
+ /**
2
+ * `createPaymentFlows` — the buyer checkout, mounted rather than composed
3
+ * (FUT-741).
4
+ *
5
+ * Called ONCE at module scope; the host mounts what comes back. See
6
+ * `./types.ts` for the config vocabulary and why none of it names a vendor.
7
+ *
8
+ * ## Why the scope arrives as HOOKS
9
+ *
10
+ * `useScope`, `useCart`, `useBuyerDefaults`, `useComanda` and
11
+ * `ports.useAvailability` are hooks, not values, and they are invoked in a
12
+ * component BODY — never read at factory time. The factory runs once, at module
13
+ * evaluation, so a value-shaped config would freeze the first store's slug and
14
+ * one moment's cart total onto every checkout the page ever renders. Naming
15
+ * them `use*` also keeps rules-of-hooks lint able to see them.
16
+ */
17
+ import { useCallback, type JSX, type ReactNode } from "react";
18
+
19
+ import { buyerFieldsFor } from "../components/checkout/buyer-fields";
20
+ import { CheckoutFlow } from "../components/checkout/checkout-flow";
21
+ import { createCheckoutClient } from "../components/checkout/transport";
22
+ import type { CheckoutProviderConfig, ComandaCheckout } from "../components/checkout/types";
23
+ import { useCheckoutController } from "../components/checkout/use-checkout-controller";
24
+
25
+ import { DEFAULT_CHECKOUT_COPY_FE } from "./copy";
26
+ import { FlowsProvider, useResolvedConfig, type FlowsRuntime } from "./runtime";
27
+ import { buyerScreens } from "./screens-buyer";
28
+ import { hostedScreens } from "./screens-hosted";
29
+ import { payScreens, storeCannotCharge } from "./screens-pay";
30
+ import type {
31
+ CheckoutAvailability,
32
+ CheckoutController,
33
+ CheckoutScreens,
34
+ PaymentFlows,
35
+ PaymentFlowsConfig,
36
+ } from "./types";
37
+
38
+ /** A store that is always payable — what a host with no veto to cast means. */
39
+ const ALWAYS_PAYABLE: CheckoutAvailability = { payable: true };
40
+
41
+ /** Build the runtime every screen closes over. */
42
+ function buildRuntime(config: PaymentFlowsConfig): FlowsRuntime {
43
+ const client = createCheckoutClient(config.transport);
44
+ const navigate =
45
+ config.ports.navigate ??
46
+ ((url: string) => {
47
+ window.location.assign(url);
48
+ });
49
+ return {
50
+ config,
51
+ client,
52
+ copy: { ...DEFAULT_CHECKOUT_COPY_FE, ...config.copy },
53
+ navigate,
54
+ // Both of these are HOOKS. They are called from a component body on every
55
+ // render, so the slug follows the host's router and the availability vote
56
+ // follows whatever the host currently knows.
57
+ useTenantSlug: () => config.useScope?.().tenantSlug,
58
+ useAvailability: () => config.ports.useAvailability?.() ?? ALWAYS_PAYABLE,
59
+ };
60
+ }
61
+
62
+ /** The whole three-step flow, with the config fetched and availability decided. */
63
+ function buildCheckout(
64
+ runtime: FlowsRuntime,
65
+ screens: CheckoutScreens,
66
+ ): PaymentFlows["Checkout"] {
67
+ const { ports } = runtime.config;
68
+ const Unavailable = screens.PaymentsUnavailable;
69
+
70
+ function CheckoutBody({ comanda }: { comanda?: ComandaCheckout | null }): JSX.Element {
71
+ const cart = runtime.config.useCart();
72
+ const defaults = runtime.config.useBuyerDefaults?.() ?? {};
73
+ const hostComanda = runtime.config.useComanda?.() ?? null;
74
+ const { config, pending } = useResolvedConfig(runtime);
75
+ const availability = runtime.useAvailability();
76
+ const tenantSlug = runtime.useTenantSlug();
77
+ // Adapts the port to the package's older, prop-shaped contract. Memoised so
78
+ // the controller does not see a fresh `createOrder` identity each render.
79
+ const createOrder = useCallback(
80
+ (input: Parameters<typeof ports.createPayable>[0]) => ports.createPayable(input),
81
+ [],
82
+ );
83
+
84
+ if (storeCannotCharge(config, pending, availability.payable)) return <Unavailable />;
85
+
86
+ return (
87
+ <CheckoutFlow
88
+ cart={cart}
89
+ createOrder={createOrder}
90
+ saveBuyerContact={ports.saveBuyerContact}
91
+ onExitToMenu={ports.exitToCatalog}
92
+ onPaid={ports.onPaid}
93
+ defaultBuyer={defaults.buyer}
94
+ taxIdOnFile={defaults.taxIdOnFile ?? false}
95
+ comanda={comanda ?? hostComanda}
96
+ providerConfig={config}
97
+ tenantSlug={tenantSlug}
98
+ confirmationExtra={runtime.config.confirmation?.extra}
99
+ />
100
+ );
101
+ }
102
+
103
+ return function Checkout(props) {
104
+ // Its OWN provider, so the one-line mount really is one line: `/config` is
105
+ // fetched here and every nested screen reads that answer.
106
+ //
107
+ // The design-system slots come from the `FlowsShell` inside it, and the
108
+ // `CheckoutComponentsProvider` that `CheckoutFlow` opens one level down
109
+ // INHERITS them (see `ui.tsx`) rather than resetting to raw MUI — which is
110
+ // why nothing here re-threads `components`.
111
+ return (
112
+ <FlowsProvider runtime={runtime}>
113
+ <CheckoutBody {...props} />
114
+ </FlowsProvider>
115
+ );
116
+ };
117
+ }
118
+
119
+ /** Assemble the eleven screens from their builders. */
120
+ function buildScreens(runtime: FlowsRuntime): CheckoutScreens {
121
+ return {
122
+ MethodChoice: buyerScreens.buildMethodChoice(runtime),
123
+ BuyerDetails: buyerScreens.buildBuyerDetails(runtime),
124
+ CardEntry: payScreens.buildCardEntry(runtime),
125
+ PixPayment: payScreens.buildPixPayment(runtime),
126
+ HostedHandoff: hostedScreens.buildHostedHandoff(runtime),
127
+ HostedReturn: hostedScreens.buildHostedReturn(runtime),
128
+ PaymentStatus: payScreens.buildPaymentStatus(runtime),
129
+ PaymentsUnavailable: payScreens.buildPaymentsUnavailable(runtime),
130
+ PayerSummary: buyerScreens.buildPayerSummary(runtime),
131
+ SavedCards: buyerScreens.buildSavedCards(runtime),
132
+ EmptyCart: buyerScreens.buildEmptyCart(runtime),
133
+ };
134
+ }
135
+
136
+ /** The flow controller, pre-bound to the ports and the chain's declaration. */
137
+ function buildUseCheckout(runtime: FlowsRuntime): () => CheckoutController {
138
+ const { ports } = runtime.config;
139
+ return function useCheckout(): CheckoutController {
140
+ const { config } = useResolvedConfig(runtime);
141
+ const defaults = runtime.config.useBuyerDefaults?.() ?? {};
142
+ return useCheckoutController(
143
+ {
144
+ createOrder: ports.createPayable,
145
+ saveBuyerContact: ports.saveBuyerContact,
146
+ onExitToMenu: ports.exitToCatalog,
147
+ onPaid: ports.onPaid,
148
+ },
149
+ defaults.buyer,
150
+ defaults.taxIdOnFile ?? false,
151
+ // Resolved for NO method: the gate runs on the Dados step, before the
152
+ // picker, and FUT-595's rule is to collect the union up front.
153
+ buyerFieldsFor(config?.chain, null),
154
+ );
155
+ };
156
+ }
157
+
158
+ /**
159
+ * The buyer checkout, pre-bound.
160
+ *
161
+ * ```ts
162
+ * export const components = createPaymentFlows({ useCart, ports: { … } });
163
+ * // …and in the page: <components.Checkout />
164
+ * ```
165
+ *
166
+ * The flat exports (`CheckoutFlow`, the hooks, the fetch clients) are
167
+ * unchanged and remain the escape hatch for a host that wants its own
168
+ * composition — nothing here replaces them.
169
+ */
170
+ export function createPaymentFlows(config: PaymentFlowsConfig): PaymentFlows {
171
+ const runtime = buildRuntime(config);
172
+ const screens = buildScreens(runtime);
173
+
174
+ function Provider({
175
+ children,
176
+ config: provided,
177
+ }: {
178
+ children: ReactNode;
179
+ config?: CheckoutProviderConfig | null;
180
+ }): JSX.Element {
181
+ return (
182
+ <FlowsProvider runtime={runtime} config={provided}>
183
+ {children}
184
+ </FlowsProvider>
185
+ );
186
+ }
187
+
188
+ return {
189
+ Checkout: buildCheckout(runtime, screens),
190
+ Provider,
191
+ screens,
192
+ useCheckout: buildUseCheckout(runtime),
193
+ useCheckoutConfig: () => useResolvedConfig(runtime),
194
+ client: runtime.client,
195
+ };
196
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * The factory's shared runtime (FUT-741): the fetched store protocol, and the
3
+ * providers that stop every nested screen re-fetching it.
4
+ *
5
+ * Two layers, on purpose:
6
+ *
7
+ * - {@link FlowsShell} supplies the things a screen cannot work WITHOUT —
8
+ * the design-system slots, the bound transport, the navigate port. Every
9
+ * returned screen wraps itself in one, which is what makes "every
10
+ * `screens.*` member already works standalone" true rather than
11
+ * aspirational. Re-supplying them under a `Provider` is a no-op.
12
+ * - {@link FlowsProvider} adds the one thing that is worth NOT repeating: the
13
+ * fetched `/config`. A screen under it reads that answer; a screen standing
14
+ * alone fetches its own.
15
+ */
16
+ import {
17
+ createContext,
18
+ useContext,
19
+ useEffect,
20
+ useMemo,
21
+ useState,
22
+ type JSX,
23
+ type ReactNode,
24
+ } from "react";
25
+
26
+ import { CheckoutClientProvider } from "../components/checkout/client-context";
27
+ import { CheckoutNavigateProvider } from "../components/checkout/navigate-context";
28
+ import type { CheckoutClient } from "../components/checkout/transport";
29
+ import type { CheckoutProviderConfig } from "../components/checkout/types";
30
+ import { CheckoutComponentsProvider } from "../components/checkout/ui";
31
+
32
+ import type { CheckoutCopyFE } from "./copy";
33
+ import type { CheckoutAvailability, CheckoutConfigState, PaymentFlowsConfig } from "./types";
34
+
35
+ /** Supplied by a `Provider`; `null` means "nobody above me fetched it". */
36
+ const FetchedConfigContext = createContext<CheckoutConfigState | null>(null);
37
+
38
+ /** Everything one factory hands the screens it built. */
39
+ export interface FlowsRuntime {
40
+ config: PaymentFlowsConfig;
41
+ client: CheckoutClient;
42
+ copy: CheckoutCopyFE;
43
+ navigate: (url: string) => void;
44
+ /** The store slug for THIS render — read from the host's hook, never frozen. */
45
+ useTenantSlug(): string | undefined;
46
+ /** The host's availability vote for THIS render. Defaults to "payable". */
47
+ useAvailability(): CheckoutAvailability;
48
+ }
49
+
50
+ /**
51
+ * Fetch the store protocol for one slug.
52
+ *
53
+ * A failure is NOT retried into a loop and NOT surfaced as an error state: the
54
+ * config read fails OPEN for the UI (`null` renders both method tiles) and the
55
+ * server still fails the charge CLOSED. Nothing about a blip here may hide a
56
+ * working checkout, and nothing about it may invent permission to mock.
57
+ */
58
+ function useFetchedConfig(
59
+ runtime: FlowsRuntime,
60
+ enabled: boolean,
61
+ ): CheckoutConfigState {
62
+ const { client } = runtime;
63
+ const onWarning = runtime.config.onWarning;
64
+ const tenantSlug = runtime.useTenantSlug();
65
+ const [state, setState] = useState<CheckoutConfigState>({ config: null, pending: enabled });
66
+
67
+ useEffect(() => {
68
+ if (!enabled || !tenantSlug) {
69
+ setState({ config: null, pending: false });
70
+ return undefined;
71
+ }
72
+ let active = true;
73
+ setState({ config: null, pending: true });
74
+ void client.getConfig(tenantSlug).then((result) => {
75
+ if (!active) return;
76
+ if (!result.ok) {
77
+ // Reported, never logged: a `console.error` in a buyer's browser tells
78
+ // nobody. Silent by default is the honest alternative.
79
+ onWarning?.("checkout config read failed", { tenantSlug, error: result.error });
80
+ setState({ config: null, pending: false });
81
+ return;
82
+ }
83
+ setState({ config: result.data, pending: false });
84
+ });
85
+ return () => {
86
+ active = false;
87
+ };
88
+ }, [client, tenantSlug, enabled, onWarning]);
89
+
90
+ return state;
91
+ }
92
+
93
+ /**
94
+ * The store protocol, from the nearest `Provider` or fetched here.
95
+ *
96
+ * Both branches always run their hooks; the self-fetch is merely disabled when
97
+ * somebody above already has the answer, which keeps the rule of hooks honest.
98
+ */
99
+ export function useResolvedConfig(runtime: FlowsRuntime): CheckoutConfigState {
100
+ const provided = useContext(FetchedConfigContext);
101
+ const own = useFetchedConfig(runtime, provided === null);
102
+ return provided ?? own;
103
+ }
104
+
105
+ /** Slots, transport and the navigate port — what a screen cannot work without. */
106
+ export function FlowsShell({
107
+ runtime,
108
+ children,
109
+ }: {
110
+ runtime: FlowsRuntime;
111
+ children: ReactNode;
112
+ }): JSX.Element {
113
+ return (
114
+ <CheckoutComponentsProvider components={runtime.config.components}>
115
+ <CheckoutClientProvider client={runtime.client}>
116
+ <CheckoutNavigateProvider navigate={runtime.navigate}>{children}</CheckoutNavigateProvider>
117
+ </CheckoutClientProvider>
118
+ </CheckoutComponentsProvider>
119
+ );
120
+ }
121
+
122
+ /**
123
+ * The shell plus the fetched `/config`, supplied once for a host that nests
124
+ * individual screens in its own layout.
125
+ *
126
+ * A host that passes its own `config` skips the fetch entirely — that is the
127
+ * seam a story, a harness page or a server-rendered host uses to STATE the
128
+ * protocol rather than round-trip for it.
129
+ */
130
+ export function FlowsProvider({
131
+ runtime,
132
+ config,
133
+ children,
134
+ }: {
135
+ runtime: FlowsRuntime;
136
+ config?: CheckoutProviderConfig | null;
137
+ children: ReactNode;
138
+ }): JSX.Element {
139
+ const fetched = useFetchedConfig(runtime, config === undefined);
140
+ const value = useMemo<CheckoutConfigState>(
141
+ () => (config === undefined ? fetched : { config, pending: false }),
142
+ [config, fetched],
143
+ );
144
+ return (
145
+ <FlowsShell runtime={runtime}>
146
+ <FetchedConfigContext.Provider value={value}>{children}</FetchedConfigContext.Provider>
147
+ </FlowsShell>
148
+ );
149
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * The buyer-facing screens the factory binds (FUT-741), part one: choosing a
3
+ * method, stating who is paying, reusing a card, and the empty cart.
4
+ *
5
+ * Every one of them is a THIN binding. The behaviour still lives in the
6
+ * components this package already shipped; what is new is that the config,
7
+ * the transport, the scope and the slots arrive from the factory instead of
8
+ * from six props the host had to thread itself.
9
+ */
10
+ import { Box } from "@mui/material";
11
+ import { useEffect, useMemo, useState, type JSX } from "react";
12
+
13
+ import { SavedCardsPicker, type SavedCard } from "../card";
14
+ import { buyerFieldsFor } from "../components/checkout/buyer-fields";
15
+ import { BuyerInfoForm } from "../components/checkout/buyer-info-form";
16
+ import { EmptyCart as EmptyCartView } from "../components/checkout/checkout-steps";
17
+ import {
18
+ cardPathAvailable,
19
+ offeredMethods,
20
+ selectableMethods,
21
+ usePreselectSoleMethod,
22
+ } from "../components/checkout/method-capability";
23
+ import { MethodPicker } from "../components/checkout/method-picker";
24
+ import { PayerSummary as PayerSummaryView } from "../components/checkout/payer-summary";
25
+ import type { BuyerInfo, PaymentMethod } from "../components/checkout/types";
26
+ import { useCheckoutComponents } from "../components/checkout/ui";
27
+
28
+ import { FlowsShell, useResolvedConfig, type FlowsRuntime } from "./runtime";
29
+ import type { BuyerDetailsProps, CheckoutScreens } from "./types";
30
+
31
+ function buildMethodChoice(runtime: FlowsRuntime): CheckoutScreens["MethodChoice"] {
32
+ function MethodChoiceBody({
33
+ value,
34
+ onChange,
35
+ }: {
36
+ value: PaymentMethod | null;
37
+ onChange: (method: PaymentMethod) => void;
38
+ }): JSX.Element {
39
+ const { config } = useResolvedConfig(runtime);
40
+ const offered = offeredMethods(config);
41
+ const cardUnavailable = !cardPathAvailable(config);
42
+ usePreselectSoleMethod(selectableMethods(offered, cardUnavailable), value, onChange);
43
+ return (
44
+ <MethodPicker
45
+ value={value}
46
+ onChange={onChange}
47
+ cardUnavailable={cardUnavailable}
48
+ offered={offered}
49
+ />
50
+ );
51
+ }
52
+ return function MethodChoice(props) {
53
+ return (
54
+ <FlowsShell runtime={runtime}>
55
+ <MethodChoiceBody {...props} />
56
+ </FlowsShell>
57
+ );
58
+ };
59
+ }
60
+
61
+ function buildBuyerDetails(runtime: FlowsRuntime): CheckoutScreens["BuyerDetails"] {
62
+ function BuyerDetailsBody({
63
+ value,
64
+ onChange,
65
+ method,
66
+ onContinue,
67
+ error,
68
+ }: BuyerDetailsProps): JSX.Element {
69
+ const { Button, Text } = useCheckoutComponents();
70
+ const { config } = useResolvedConfig(runtime);
71
+ // ∩ method (FUT-595). An absent declaration degrades to CPF-required —
72
+ // never to "ask nothing", which is a 400 the buyer only meets after
73
+ // finishing the form.
74
+ const fields = useMemo(() => buyerFieldsFor(config?.chain, method), [config, method]);
75
+ return (
76
+ <Box sx={{ display: "flex", flexDirection: "column", gap: 3 }}>
77
+ <BuyerInfoForm value={value} onChange={onChange} fields={fields} fieldError={error} />
78
+ <Box>
79
+ <Button
80
+ variant="solid"
81
+ color="primary"
82
+ size="lg"
83
+ fullWidth
84
+ onClick={onContinue}
85
+ dataTestId="checkout-continue"
86
+ >
87
+ {runtime.copy.continueAction}
88
+ </Button>
89
+ </Box>
90
+ <Text variant="caption" size="xs" color="secondary" as="p">
91
+ Pagamento seguro
92
+ </Text>
93
+ </Box>
94
+ );
95
+ }
96
+ return function BuyerDetails(props) {
97
+ return (
98
+ <FlowsShell runtime={runtime}>
99
+ <BuyerDetailsBody {...props} />
100
+ </FlowsShell>
101
+ );
102
+ };
103
+ }
104
+
105
+ function buildPayerSummary(runtime: FlowsRuntime): CheckoutScreens["PayerSummary"] {
106
+ return function PayerSummary({ buyer, onEdit }: { buyer: BuyerInfo; onEdit?: () => void }) {
107
+ return (
108
+ <FlowsShell runtime={runtime}>
109
+ <PayerSummaryView name={buyer.name} taxId={buyer.taxId} onEdit={onEdit} />
110
+ </FlowsShell>
111
+ );
112
+ };
113
+ }
114
+
115
+ /** The vaulted instruments this store can actually charge (FUT-697 scoping). */
116
+ function useScopedInstruments(runtime: FlowsRuntime): SavedCard[] {
117
+ const tenantSlug = runtime.useTenantSlug();
118
+ const [cards, setCards] = useState<SavedCard[]>([]);
119
+ useEffect(() => {
120
+ let active = true;
121
+ void runtime.client.listInstruments(tenantSlug).then((list) => {
122
+ if (active) setCards(list);
123
+ });
124
+ return () => {
125
+ active = false;
126
+ };
127
+ }, [runtime, tenantSlug]);
128
+ return cards;
129
+ }
130
+
131
+ function buildSavedCards(runtime: FlowsRuntime): CheckoutScreens["SavedCards"] {
132
+ function SavedCardsBody({
133
+ selection,
134
+ onSelect,
135
+ }: {
136
+ selection: string;
137
+ onSelect: (id: string) => void;
138
+ }): JSX.Element | null {
139
+ const savedCards = useScopedInstruments(runtime);
140
+ // Nothing to reuse renders nothing at all — an empty picker is a heading
141
+ // over a void, and the new-card form below it already says what to do.
142
+ if (savedCards.length === 0) return null;
143
+ return <SavedCardsPicker savedCards={savedCards} selection={selection} onSelect={onSelect} />;
144
+ }
145
+ return function SavedCards(props) {
146
+ return (
147
+ <FlowsShell runtime={runtime}>
148
+ <SavedCardsBody {...props} />
149
+ </FlowsShell>
150
+ );
151
+ };
152
+ }
153
+
154
+ function buildEmptyCart(runtime: FlowsRuntime): CheckoutScreens["EmptyCart"] {
155
+ return function EmptyCart() {
156
+ return (
157
+ <FlowsShell runtime={runtime}>
158
+ <EmptyCartView onBack={runtime.config.ports.exitToCatalog} />
159
+ </FlowsShell>
160
+ );
161
+ };
162
+ }
163
+
164
+ export const buyerScreens = {
165
+ buildMethodChoice,
166
+ buildBuyerDetails,
167
+ buildPayerSummary,
168
+ buildSavedCards,
169
+ buildEmptyCart,
170
+ };
@@ -0,0 +1,149 @@
1
+ /**
2
+ * The hosted handover, as two SCREENS (FUT-741) — the leg of checkout that had
3
+ * none.
4
+ *
5
+ * Today a redirect provider's link (FUT-556) and a 3-DS challenge (FUT-698) are
6
+ * both a bare `window.location.assign` buried in a hook. When the navigation
7
+ * works, nobody notices. When it does not — a popup/redirect blocker, a slow
8
+ * DNS, an in-app webview that refuses cross-origin navigations, a buyer who
9
+ * taps back — the buyer is left looking at a page that says nothing, offers
10
+ * nothing, and has already raised a charge.
11
+ *
12
+ * So: an interstitial that PARKS the order first, then navigates, and renders
13
+ * an explicit link as the fallback the assign never had. And the return leg as
14
+ * a screen of its own, so "rehydrate the parked order and poll it to a terminal
15
+ * state" is something a host can mount at its return route rather than
16
+ * something that only happens if the buyer lands back on the exact component
17
+ * that left.
18
+ *
19
+ * ORDERING IS LOAD-BEARING: park, then navigate. The navigation may tear this
20
+ * SPA down at any point after it starts, and a return trip that finds nothing
21
+ * parked drops the buyer on a blank confirmation after they have paid.
22
+ */
23
+ import { Box } from "@mui/material";
24
+ import { useEffect, useState, type JSX } from "react";
25
+
26
+ import { rememberHostedOrder, takeHostedOrder } from "../components/checkout/hosted-return";
27
+ import type { CheckoutOrder, OrderStatus } from "../components/checkout/types";
28
+ import { useCheckoutComponents } from "../components/checkout/ui";
29
+ import { usePaymentPolling } from "../components/checkout/use-payment-polling";
30
+
31
+ import { FlowsShell, type FlowsRuntime } from "./runtime";
32
+ import type { CheckoutScreens } from "./types";
33
+
34
+ /** Park the order, then navigate — exactly once, even under StrictMode. */
35
+ function useHandover(payable: CheckoutOrder, url: string, navigate: (url: string) => void): void {
36
+ const [done, setDone] = useState(false);
37
+ useEffect(() => {
38
+ if (done) return;
39
+ setDone(true);
40
+ rememberHostedOrder(payable);
41
+ navigate(url);
42
+ }, [done, payable, url, navigate]);
43
+ }
44
+
45
+ function buildHostedHandoff(runtime: FlowsRuntime): CheckoutScreens["HostedHandoff"] {
46
+ function HostedHandoffBody({
47
+ url,
48
+ payable,
49
+ onCancel,
50
+ }: {
51
+ url: string;
52
+ payable: CheckoutOrder;
53
+ onCancel?: () => void;
54
+ }): JSX.Element {
55
+ const { Button, LoadingState, Text } = useCheckoutComponents();
56
+ const copy = runtime.copy;
57
+ useHandover(payable, url, runtime.navigate);
58
+ return (
59
+ <Box
60
+ data-testid="checkout-hosted-handoff"
61
+ sx={{ display: "flex", flexDirection: "column", gap: 2, alignItems: "center", py: 4 }}
62
+ >
63
+ <Text variant="heading" size="md" weight="bold" as="h2">
64
+ {copy.handoffTitle}
65
+ </Text>
66
+ <Text variant="body" size="sm" color="secondary" as="p">
67
+ {copy.handoffBody}
68
+ </Text>
69
+ <LoadingState variant="spinner" size="md" message="" dataTestId="checkout-hosted-waiting" />
70
+ {/* A real anchor, not a second scripted navigation: when the scripted
71
+ one was blocked, another one will be too. This is the affordance a
72
+ bare `location.assign` never had. */}
73
+ <a href={url} data-testid="checkout-hosted-link" rel="noreferrer">
74
+ {copy.handoffLink}
75
+ </a>
76
+ {onCancel ? (
77
+ <Button
78
+ variant="text"
79
+ color="neutral"
80
+ size="sm"
81
+ onClick={onCancel}
82
+ dataTestId="checkout-hosted-cancel"
83
+ >
84
+ {copy.handoffCancel}
85
+ </Button>
86
+ ) : null}
87
+ </Box>
88
+ );
89
+ }
90
+ return function HostedHandoff(props) {
91
+ return (
92
+ <FlowsShell runtime={runtime}>
93
+ <HostedHandoffBody {...props} />
94
+ </FlowsShell>
95
+ );
96
+ };
97
+ }
98
+
99
+ function buildHostedReturn(runtime: FlowsRuntime): CheckoutScreens["HostedReturn"] {
100
+ function HostedReturnBody({
101
+ onResolved,
102
+ }: {
103
+ onResolved: (status: OrderStatus) => void;
104
+ }): JSX.Element {
105
+ const { Alert, LoadingState } = useCheckoutComponents();
106
+ // Read-and-clear, once, on first render: the resumed view belongs to
107
+ // exactly one return trip.
108
+ const [parked] = useState(takeHostedOrder);
109
+ const { status } = usePaymentPolling(parked?.orderId ?? null, {
110
+ enabled: Boolean(parked),
111
+ intervalMs: runtime.config.polling?.intervalMs,
112
+ });
113
+
114
+ useEffect(() => {
115
+ if (status && status !== "AWAITING_PAYMENT") onResolved(status);
116
+ }, [status, onResolved]);
117
+
118
+ if (!parked) {
119
+ return (
120
+ <Alert
121
+ variant="info"
122
+ title={runtime.copy.returnPending}
123
+ description={runtime.copy.returnUnknown}
124
+ showIcon
125
+ data-testid="checkout-hosted-return-unknown"
126
+ />
127
+ );
128
+ }
129
+ return (
130
+ <Box data-testid="checkout-hosted-return" sx={{ py: 4 }}>
131
+ <LoadingState
132
+ variant="spinner"
133
+ size="md"
134
+ message={runtime.copy.returnPending}
135
+ dataTestId="checkout-hosted-return-waiting"
136
+ />
137
+ </Box>
138
+ );
139
+ }
140
+ return function HostedReturn(props) {
141
+ return (
142
+ <FlowsShell runtime={runtime}>
143
+ <HostedReturnBody {...props} />
144
+ </FlowsShell>
145
+ );
146
+ };
147
+ }
148
+
149
+ export const hostedScreens = { buildHostedHandoff, buildHostedReturn };