@12-apps/payments-frontend 3.19.0 → 3.20.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.
|
|
3
|
+
"version": "3.20.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.",
|
|
@@ -7,6 +7,7 @@ import { ArrowBackIcon } from "./icons";
|
|
|
7
7
|
import { PaymentStatus } from "./payment-status";
|
|
8
8
|
import type { BuyerInfo, CheckoutProviderConfig, SettlementCheckout } from "./types";
|
|
9
9
|
import { CheckoutCopyProvider } from "./copy-context";
|
|
10
|
+
import { OneClickProvider, useOneClick } from "./one-click";
|
|
10
11
|
import { CheckoutComponentsProvider, useCheckoutComponents, type CheckoutComponents } from "./ui";
|
|
11
12
|
import type { CheckoutViewCopy } from "./view-copy";
|
|
12
13
|
import { useCheckoutController, type CheckoutHostPorts } from "./use-checkout-controller";
|
|
@@ -59,6 +60,17 @@ export interface CheckoutFlowProps extends CheckoutHostPorts {
|
|
|
59
60
|
providerConfig?: CheckoutProviderConfig | null;
|
|
60
61
|
/** Scopes the saved-card list to the store being paid. */
|
|
61
62
|
tenantSlug?: string;
|
|
63
|
+
/**
|
|
64
|
+
* The buyer pressed a BUY button rather than opening a checkout — pay with
|
|
65
|
+
* their saved card and land them on Confirmação, with no tap in between.
|
|
66
|
+
*
|
|
67
|
+
* A REQUEST, never an instruction: it is honoured only where it can be, and
|
|
68
|
+
* degrades to the ordinary flow everywhere else — a store that finishes on
|
|
69
|
+
* the provider's page, a buyer with no CPF on file, a buyer with no saved
|
|
70
|
+
* card. See `./one-click.tsx` for the whole decision and why every clause
|
|
71
|
+
* narrows toward standing down.
|
|
72
|
+
*/
|
|
73
|
+
oneClick?: boolean;
|
|
62
74
|
/**
|
|
63
75
|
* The host's Apple Pay merchant-validation port (FUT-472): exchange the
|
|
64
76
|
* session's `validationURL` for an Apple merchant session, SERVER-SIDE.
|
|
@@ -167,13 +179,14 @@ function StatusStep({
|
|
|
167
179
|
}
|
|
168
180
|
|
|
169
181
|
function CheckoutFlowBody(props: Omit<CheckoutFlowProps, "components">): JSX.Element {
|
|
170
|
-
const { copy, cart, defaultBuyer, settlement, taxIdOnFile = false, providerConfig, tenantSlug, confirmationExtra, validateApplePayMerchant, ...ports } = props;
|
|
182
|
+
const { copy, cart, defaultBuyer, settlement, taxIdOnFile = false, providerConfig, tenantSlug, confirmationExtra, validateApplePayMerchant, oneClick = false, ...ports } = props;
|
|
171
183
|
// Resolved for NO method on purpose (FUT-595): the Dados step opens before
|
|
172
184
|
// the picker, and the form is filled once — so it asks for the union of what
|
|
173
185
|
// any chain member may need rather than re-opening after the choice. A chain
|
|
174
186
|
// that declares nothing degrades to CPF-required, never to "ask nothing".
|
|
175
187
|
const buyerFields = useMemo(() => buyerFieldsFor(providerConfig?.chain, null), [providerConfig]);
|
|
176
188
|
const c = useCheckoutController(ports, defaultBuyer, taxIdOnFile, buyerFields, tenantSlug);
|
|
189
|
+
const armed = useOneClick({ requested: oneClick, config: providerConfig, taxIdOnFile, step: c.step, method: c.method, setMethod: c.setMethod });
|
|
177
190
|
|
|
178
191
|
// A settlement settlement pays already-sent kitchen items — the cart is
|
|
179
192
|
// legitimately empty here, so the empty-cart guard only applies to cart mode.
|
|
@@ -210,25 +223,27 @@ function CheckoutFlowBody(props: Omit<CheckoutFlowProps, "components">): JSX.Ele
|
|
|
210
223
|
) : null}
|
|
211
224
|
|
|
212
225
|
{c.step === "payment" ? (
|
|
213
|
-
<
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
226
|
+
<OneClickProvider armed={armed}>
|
|
227
|
+
<PaymentStep
|
|
228
|
+
method={c.method}
|
|
229
|
+
onMethodChange={c.setMethod}
|
|
230
|
+
order={c.order}
|
|
231
|
+
buyer={c.buyer}
|
|
232
|
+
creating={c.creating}
|
|
233
|
+
createError={c.createError}
|
|
234
|
+
errorField={c.errorField}
|
|
235
|
+
errorCode={c.errorCode}
|
|
236
|
+
onGenerate={(chosen) => void c.startPayment(chosen)}
|
|
237
|
+
onUseEmail={c.payWithEmail}
|
|
238
|
+
// Set only for a skipped-Dados flow (the controller decides); the
|
|
239
|
+
// payer block hides itself when it is absent.
|
|
240
|
+
onEditBuyer={c.editBuyer}
|
|
241
|
+
providerConfig={providerConfig}
|
|
242
|
+
tenantSlug={tenantSlug}
|
|
243
|
+
validateApplePayMerchant={validateApplePayMerchant}
|
|
244
|
+
onResolved={c.handleResolved}
|
|
245
|
+
/>
|
|
246
|
+
</OneClickProvider>
|
|
232
247
|
) : null}
|
|
233
248
|
|
|
234
249
|
{c.step === "status" ? (
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ONE-CLICK checkout — the buyer who already decided (FUT-1070).
|
|
3
|
+
*
|
|
4
|
+
* A storefront can offer a BUY button beside a product or a past order: press
|
|
5
|
+
* it and the shopper expects to have bought, not to be handed a form. The
|
|
6
|
+
* whole flow that follows already exists — Pagamento raises the order, the
|
|
7
|
+
* card path charges a saved instrument, Confirmação reports the outcome — and
|
|
8
|
+
* every step of it is a tap the buyer has already made by pressing that
|
|
9
|
+
* button. So one-click makes those taps, in order, and reaches the same
|
|
10
|
+
* terminal screen through the same code the ordinary flow uses.
|
|
11
|
+
*
|
|
12
|
+
* That reuse is the design, not an economy. A second charge path would be a
|
|
13
|
+
* second answer to "what does paying with a saved card do", and the two would
|
|
14
|
+
* eventually disagree about failover instruments, the unresolved-charge rule,
|
|
15
|
+
* or the poll cap — each of which is money.
|
|
16
|
+
*
|
|
17
|
+
* ## It arms, or it stands down. It never guesses.
|
|
18
|
+
*
|
|
19
|
+
* `armedFor` is the whole decision, and every clause narrows toward
|
|
20
|
+
* NOT arming, because the failure directions are not symmetric: standing down
|
|
21
|
+
* costs a buyer the taps they would have made anyway, and arming wrongly
|
|
22
|
+
* charges a card nobody chose.
|
|
23
|
+
*
|
|
24
|
+
* - **No request** — the host did not ask. This is every ordinary checkout.
|
|
25
|
+
* - **No CPF on file** — the buyer still has a Dados step to fill (FUT-465),
|
|
26
|
+
* so there is no tap to skip and the flow opens where it always did. In
|
|
27
|
+
* practice a buyer with a saved card has one; a buyer without one is a buyer
|
|
28
|
+
* we have never charged.
|
|
29
|
+
* - **No protocol yet** (`config === null`, still loading or a fetch blip) —
|
|
30
|
+
* the ordinary flow fails OPEN here and renders a picker the server may
|
|
31
|
+
* refuse, which costs a tap. Arming on the same guess would raise a charge.
|
|
32
|
+
* - **The choice is not ours to ask** — a store that finishes on the
|
|
33
|
+
* provider's own page has no card path in this browser at all, and the one
|
|
34
|
+
* thing one-click must never do is redirect a checkout the moment it
|
|
35
|
+
* renders. This is the InfinitePay shape, and it is why a store on a hosted
|
|
36
|
+
* provider degrades to the ordinary hand-off screen with nothing else
|
|
37
|
+
* changed.
|
|
38
|
+
* - **No card path** — the chain declares no CARD entry this browser can mint
|
|
39
|
+
* or charge for.
|
|
40
|
+
*
|
|
41
|
+
* The last condition cannot be answered here at all: **whether the buyer has a
|
|
42
|
+
* saved card**. That list is fetched by the card path itself, scoped to the
|
|
43
|
+
* store, and asking for it twice would be two answers to one question. So an
|
|
44
|
+
* armed flow selects the card tile and the card view does the rest —
|
|
45
|
+
* {@link useOneClickPay} pays only once a SAVED card is the selection, which
|
|
46
|
+
* is a state the picker can only reach after the list came back non-empty. A
|
|
47
|
+
* buyer with no saved card therefore lands on Pagamento with the picker and
|
|
48
|
+
* the card form, which is exactly the ordinary step 2.
|
|
49
|
+
*/
|
|
50
|
+
import {
|
|
51
|
+
createContext,
|
|
52
|
+
useContext,
|
|
53
|
+
useEffect,
|
|
54
|
+
useRef,
|
|
55
|
+
type JSX,
|
|
56
|
+
type ReactNode,
|
|
57
|
+
} from "react";
|
|
58
|
+
|
|
59
|
+
import { cardPathAvailable } from "./method-capability";
|
|
60
|
+
import { methodChosenAtProvider } from "./providers/registry";
|
|
61
|
+
import type { CheckoutProviderConfig, PaymentMethod } from "./types";
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Whether the flow above this subtree is running as one-click.
|
|
65
|
+
*
|
|
66
|
+
* CONTEXT rather than a prop, because the consumer is the card path — four
|
|
67
|
+
* layers down, behind the published `ProviderCheckoutScreen` contract that
|
|
68
|
+
* every provider screen implements. Threading a flag through it would widen a
|
|
69
|
+
* contract that three screens share for the benefit of one, and would oblige
|
|
70
|
+
* an out-of-tree screen to forward a prop it has no use for.
|
|
71
|
+
*/
|
|
72
|
+
const OneClickContext = createContext(false);
|
|
73
|
+
|
|
74
|
+
/** Arm (or explicitly disarm) one-click for everything below. */
|
|
75
|
+
export function OneClickProvider({
|
|
76
|
+
armed,
|
|
77
|
+
children,
|
|
78
|
+
}: {
|
|
79
|
+
armed: boolean;
|
|
80
|
+
children: ReactNode;
|
|
81
|
+
}): JSX.Element {
|
|
82
|
+
return <OneClickContext.Provider value={armed}>{children}</OneClickContext.Provider>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Whether this subtree is a one-click checkout. `false` outside a provider. */
|
|
86
|
+
export function useOneClickArmed(): boolean {
|
|
87
|
+
return useContext(OneClickContext);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Can this store honour a one-click request right now? See the module comment
|
|
92
|
+
* for why every clause narrows toward `false`.
|
|
93
|
+
*
|
|
94
|
+
* `step` is what keeps the answer honest over TIME rather than only at mount:
|
|
95
|
+
* a resumed hosted return opens on Confirmação, and a buyer who walked back to
|
|
96
|
+
* Dados is a buyer who took over. Neither is a checkout that should still be
|
|
97
|
+
* charging on its own.
|
|
98
|
+
*/
|
|
99
|
+
function armedFor(input: OneClickFlow): boolean {
|
|
100
|
+
const { requested, config, taxIdOnFile, step } = input;
|
|
101
|
+
if (!requested || !taxIdOnFile || step !== "payment" || !config) return false;
|
|
102
|
+
if (methodChosenAtProvider(config.chain?.[0]?.checkoutScreen, config)) return false;
|
|
103
|
+
return cardPathAvailable(config);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** What the flow knows that decides whether one-click may run, and how it starts. */
|
|
107
|
+
interface OneClickFlow {
|
|
108
|
+
/** The host asked for one-click on this checkout. */
|
|
109
|
+
requested: boolean;
|
|
110
|
+
/** The store's published protocol; `null` while it is still unknown. */
|
|
111
|
+
config: CheckoutProviderConfig | null | undefined;
|
|
112
|
+
/** The buyer's CPF is already saved, so there is no Dados step to fill. */
|
|
113
|
+
taxIdOnFile: boolean;
|
|
114
|
+
/** Which step the flow is showing — one-click only ever runs on Pagamento. */
|
|
115
|
+
step: string;
|
|
116
|
+
/** The method currently selected, or `null` before any choice. */
|
|
117
|
+
method: PaymentMethod | null;
|
|
118
|
+
/** Selecting a method — the same event a picker tile press is. */
|
|
119
|
+
setMethod: (method: PaymentMethod) => void;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Arm one-click for this render, and take the card tile for the buyer ONCE
|
|
124
|
+
* when it is armed.
|
|
125
|
+
*
|
|
126
|
+
* Selecting a method is what raises the order (`useAutoRaiseOrder`), so that
|
|
127
|
+
* one call starts everything downstream — and it is the same event a tile
|
|
128
|
+
* press is, which is why nothing else in the flow has to change.
|
|
129
|
+
*
|
|
130
|
+
* Once only, by ref. `setMethod` clears any order raised for a previous
|
|
131
|
+
* method, so a re-fire would discard a live charge and raise a second; and a
|
|
132
|
+
* buyer who switches to PIX after this ran must be allowed to stay there.
|
|
133
|
+
*/
|
|
134
|
+
export function useOneClick(flow: OneClickFlow): boolean {
|
|
135
|
+
const armed = armedFor(flow);
|
|
136
|
+
const { method, setMethod } = flow;
|
|
137
|
+
const taken = useRef(false);
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
if (!armed || taken.current || method !== null) return;
|
|
140
|
+
taken.current = true;
|
|
141
|
+
setMethod("CARD");
|
|
142
|
+
}, [armed, method, setMethod]);
|
|
143
|
+
return armed;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Press "Pagar" for the buyer, ONCE, when a saved card is what is selected.
|
|
148
|
+
*
|
|
149
|
+
* `ready` is the caller's whole precondition and is deliberately narrow: a
|
|
150
|
+
* SAVED card is the current selection and no charge is in flight, has landed,
|
|
151
|
+
* or has already failed. It can only become true after the instrument list
|
|
152
|
+
* came back with something, which is what makes "the buyer has no saved card"
|
|
153
|
+
* a silent stand-down rather than a branch.
|
|
154
|
+
*
|
|
155
|
+
* The submit is held in a ref because `handlePay` is rebuilt every render;
|
|
156
|
+
* depending on it directly would re-run this effect constantly and leave the
|
|
157
|
+
* once-only guard as the only thing between a shopper and a second charge.
|
|
158
|
+
* One guard for one job: the ref fires it, `fired` decides whether it may.
|
|
159
|
+
*
|
|
160
|
+
* A DECLINE is terminal for one-click, and that is the point of listing
|
|
161
|
+
* `error` in `ready`: the buyer's own "Pagar R$ …" comes back under the
|
|
162
|
+
* refusal, and retrying a declined card automatically is how a shopper gets
|
|
163
|
+
* three identical declines they never asked for.
|
|
164
|
+
*/
|
|
165
|
+
export function useOneClickPay(input: {
|
|
166
|
+
armed: boolean;
|
|
167
|
+
ready: boolean;
|
|
168
|
+
pay: () => Promise<void>;
|
|
169
|
+
}): void {
|
|
170
|
+
const { armed, ready } = input;
|
|
171
|
+
const submit = useRef(input.pay);
|
|
172
|
+
submit.current = input.pay;
|
|
173
|
+
const fired = useRef(false);
|
|
174
|
+
useEffect(() => {
|
|
175
|
+
if (!armed || !ready || fired.current) return;
|
|
176
|
+
fired.current = true;
|
|
177
|
+
void submit.current();
|
|
178
|
+
}, [armed, ready]);
|
|
179
|
+
}
|
|
@@ -26,6 +26,7 @@ import { useCheckoutClientApi } from "./client-context";
|
|
|
26
26
|
import { rememberHostedOrder } from "./hosted-return";
|
|
27
27
|
import { useCheckoutNavigate, type CheckoutNavigate } from "./navigate-context";
|
|
28
28
|
import type { CardChainLink } from "./method-capability";
|
|
29
|
+
import { useOneClickArmed, useOneClickPay } from "./one-click";
|
|
29
30
|
import type { BuyerInfo, CheckoutOrder, OrderStatus } from "./types";
|
|
30
31
|
import { usePaymentPolling } from "./use-payment-polling";
|
|
31
32
|
import type { CardCopy } from "../../card/copy";
|
|
@@ -339,6 +340,13 @@ export function useCardCheckout(
|
|
|
339
340
|
{ card, usingNewCard, selection, saveCard, validate, setFieldErrors },
|
|
340
341
|
providerChain,
|
|
341
342
|
);
|
|
343
|
+
// The tap a one-click buyer already made (`./one-click.tsx`). Nothing about
|
|
344
|
+
// the charge differs — this only presses the button, and only while a SAVED
|
|
345
|
+
// card is the selection, which is a state the picker reaches exactly when the
|
|
346
|
+
// instrument list came back with something. A buyer with no saved card is
|
|
347
|
+
// left on the form, which is the ordinary step 2.
|
|
348
|
+
const ready = !usingNewCard && !submit.submitting && !submit.submitted && submit.error === null;
|
|
349
|
+
useOneClickPay({ armed: useOneClickArmed(), ready, pay: submit.handlePay });
|
|
342
350
|
|
|
343
351
|
return {
|
|
344
352
|
savedCards,
|