@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.
- package/package.json +17 -6
- package/src/components/checkout/buyer-fields.ts +117 -0
- package/src/components/checkout/buyer-info-form.tsx +154 -85
- package/src/components/checkout/card-instruments.ts +65 -21
- package/src/components/checkout/checkout-flow.tsx +9 -2
- package/src/components/checkout/checkout-steps.tsx +58 -26
- package/src/components/checkout/client-context.tsx +57 -0
- package/src/components/checkout/client.ts +18 -110
- package/src/components/checkout/method-capability.ts +60 -10
- package/src/components/checkout/navigate-context.tsx +40 -0
- package/src/components/checkout/transport.ts +191 -0
- package/src/components/checkout/types.ts +27 -0
- package/src/components/checkout/ui.tsx +15 -3
- package/src/components/checkout/use-card-checkout.ts +30 -13
- package/src/components/checkout/use-checkout-controller.ts +64 -20
- package/src/components/checkout/use-payment-polling.ts +7 -3
- package/src/flows/copy.ts +57 -0
- package/src/flows/create-payment-flows.tsx +196 -0
- package/src/flows/runtime.tsx +149 -0
- package/src/flows/screens-buyer.tsx +170 -0
- package/src/flows/screens-hosted.tsx +149 -0
- package/src/flows/screens-pay.tsx +178 -0
- package/src/flows/types.ts +179 -0
- package/src/index.ts +35 -0
|
@@ -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 };
|