@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.19.0",
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
- <PaymentStep
214
- method={c.method}
215
- onMethodChange={c.setMethod}
216
- order={c.order}
217
- buyer={c.buyer}
218
- creating={c.creating}
219
- createError={c.createError}
220
- errorField={c.errorField}
221
- errorCode={c.errorCode}
222
- onGenerate={(chosen) => void c.startPayment(chosen)}
223
- onUseEmail={c.payWithEmail}
224
- // Set only for a skipped-Dados flow (the controller decides); the
225
- // payer block hides itself when it is absent.
226
- onEditBuyer={c.editBuyer}
227
- providerConfig={providerConfig}
228
- tenantSlug={tenantSlug}
229
- validateApplePayMerchant={validateApplePayMerchant}
230
- onResolved={c.handleResolved}
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,