@12-apps/payments-frontend 3.18.1 → 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 +2 -2
- package/src/activation/screens/copy-en-US.ts +110 -0
- package/src/activation/screens/copy-pt-BR.ts +126 -0
- package/src/components/checkout/checkout-flow.tsx +35 -20
- package/src/components/checkout/one-click.tsx +179 -0
- package/src/components/checkout/use-card-checkout.ts +8 -0
- package/src/locales.ts +15 -0
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.",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"storybook:build": "storybook build"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@12-apps/payments-backend": "^4.
|
|
25
|
+
"@12-apps/payments-backend": "^4.26.0",
|
|
26
26
|
"react-qr-code": "^2.2.0"
|
|
27
27
|
},
|
|
28
28
|
"peerDependencies": {
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { EN_US_CARD_COPY } from '../../card';
|
|
2
|
+
|
|
3
|
+
import type { ActivationStepCopy } from './copy';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The twin of `./copy-pt-BR.ts`, key for key (FUT-764).
|
|
7
|
+
*
|
|
8
|
+
* A pack, not a default — see that file for the whole argument, and for the
|
|
9
|
+
* list of strings that deliberately do not move across the pair: **Checkout
|
|
10
|
+
* Integrado** (PagBank's own console label), **CPF** and its `000.000.000-00`
|
|
11
|
+
* mask (a Brazilian document and a FORMAT), the provider names, and this
|
|
12
|
+
* package's `{provider}` placeholder.
|
|
13
|
+
*/
|
|
14
|
+
export const EN_US_ACTIVATION_STEP_COPY: ActivationStepCopy = {
|
|
15
|
+
intro: {
|
|
16
|
+
title: 'Step 3 · Turn selling on',
|
|
17
|
+
cardBody: (amountLabel) =>
|
|
18
|
+
`Make a test charge${amountLabel ? ` of ${amountLabel}` : ''} on your own card. It is ` +
|
|
19
|
+
'refunded automatically, and it is what proves your shop can genuinely take money.',
|
|
20
|
+
realCharge: (amountLabel) =>
|
|
21
|
+
`We raise a real ${amountLabel} charge on your own account. Paying it is what proves your ` +
|
|
22
|
+
'shop can genuinely take money.',
|
|
23
|
+
payingYourself: (amountLabel) =>
|
|
24
|
+
`You are paying yourself: the ${amountLabel} leaves your payment method and lands in the ` +
|
|
25
|
+
"account that receives this shop's sales. There is no refund because the money never left " +
|
|
26
|
+
'your control.',
|
|
27
|
+
},
|
|
28
|
+
actions: {
|
|
29
|
+
chargeAndActivate: (amountLabel) =>
|
|
30
|
+
`Charge${amountLabel ? ` ${amountLabel}` : ''} and activate`,
|
|
31
|
+
payAndActivate: (amountLabel) => `Pay ${amountLabel} and activate`,
|
|
32
|
+
testAgain: 'Test again',
|
|
33
|
+
retry: 'Try again',
|
|
34
|
+
tryAgain: 'Try once more',
|
|
35
|
+
restart: 'I have switched it on — restart Step 3',
|
|
36
|
+
generateNewCharge: 'Generate a new charge',
|
|
37
|
+
checkNow: 'Check now',
|
|
38
|
+
alreadyPaidCheckNow: 'I have paid — check now',
|
|
39
|
+
setProviderOrder: 'Set the order between providers',
|
|
40
|
+
seePublishedStore: 'See the published shop',
|
|
41
|
+
},
|
|
42
|
+
awaiting: {
|
|
43
|
+
receivedTitle: 'Payment received — confirming',
|
|
44
|
+
receivedBody:
|
|
45
|
+
'We are confirming your payment with the provider. This usually takes a few seconds.',
|
|
46
|
+
declinedTitle: 'The payment was declined',
|
|
47
|
+
waitingTitle: 'Waiting for the payment…',
|
|
48
|
+
waitingBody: (amountLabel) =>
|
|
49
|
+
`We raised the ${amountLabel} charge and opened the provider's page in another tab. ` +
|
|
50
|
+
'Pay there and come back: we check for ourselves the moment it lands.',
|
|
51
|
+
lastChecked: (seconds) => `Last checked ${seconds}s ago`,
|
|
52
|
+
openPaymentPage: 'Open the payment page',
|
|
53
|
+
copyLink: 'Copy the link',
|
|
54
|
+
linkCopied: 'Link copied',
|
|
55
|
+
showLink: 'Show the link',
|
|
56
|
+
hideLink: 'Hide the link',
|
|
57
|
+
},
|
|
58
|
+
outcome: {
|
|
59
|
+
approvedTitle: 'Test charge approved',
|
|
60
|
+
refundedBody: (amountLabel) =>
|
|
61
|
+
`Your shop can take payments. The ${amountLabel} was refunded automatically.`,
|
|
62
|
+
refundPendingBody: (amountLabel) =>
|
|
63
|
+
`Your shop can take payments. The ${amountLabel} refund did not complete — ` +
|
|
64
|
+
'it will show up on your statement.',
|
|
65
|
+
someAmount: 'amount',
|
|
66
|
+
authenticatedNotActive: 'Authenticated, but not activated',
|
|
67
|
+
refusedTitle: (displayName) => `${displayName} refused to create the charge`,
|
|
68
|
+
refusedBody: (displayName) =>
|
|
69
|
+
`${displayName} refused to create the charge. That almost always means ` +
|
|
70
|
+
'Checkout Integrado is still switched off on your account. Nothing was charged — ' +
|
|
71
|
+
'we reopened Step 2 above: turn it on there and come back.',
|
|
72
|
+
unreachableTitle: 'We could not reach the provider',
|
|
73
|
+
expiredTitle: 'The charge expired',
|
|
74
|
+
settledTitle: 'Test charge confirmed',
|
|
75
|
+
settledBody: (amountLabel) =>
|
|
76
|
+
`Your shop can take payments. The ${amountLabel} stays in your account — ` +
|
|
77
|
+
'InfinitePay refunds are made in their own app.',
|
|
78
|
+
provenTitle: 'Test charge confirmed',
|
|
79
|
+
provenBody:
|
|
80
|
+
'Your shop has proved it can take money through this provider. We switched sales ' +
|
|
81
|
+
'on automatically — you can pause them with the button at the top whenever you like. ' +
|
|
82
|
+
'No further charge is needed.',
|
|
83
|
+
providerSaid: 'The provider answered:',
|
|
84
|
+
blockedTitle: 'Step 2 is still unconfirmed',
|
|
85
|
+
blockedBody:
|
|
86
|
+
'Confirm above that Checkout Integrado is enabled on your account. Without it the ' +
|
|
87
|
+
'provider creates no payment link at all, and this charge would fail.',
|
|
88
|
+
},
|
|
89
|
+
taxId: {
|
|
90
|
+
label: "Cardholder's CPF",
|
|
91
|
+
hint: 'PagBank requires it on every card charge.',
|
|
92
|
+
placeholder: '000.000.000-00',
|
|
93
|
+
},
|
|
94
|
+
charge: {
|
|
95
|
+
card: EN_US_CARD_COPY,
|
|
96
|
+
noTokenizer:
|
|
97
|
+
'Card tokenization is not implemented for {provider} yet, ' +
|
|
98
|
+
'so the verification charge cannot be made from here.',
|
|
99
|
+
chargeFailed: 'The test charge could not be completed.',
|
|
100
|
+
unreachable: 'Could not connect. Check your connection and try again.',
|
|
101
|
+
},
|
|
102
|
+
redirect: {
|
|
103
|
+
chargeExpired: 'The charge expired.',
|
|
104
|
+
confirmFailed: 'The test charge could not be confirmed.',
|
|
105
|
+
createFailed: 'The test charge could not be raised.',
|
|
106
|
+
confirmTimedOut:
|
|
107
|
+
'We could not confirm the payment in time. If you have already paid, the charge is ' +
|
|
108
|
+
'still valid — reload the page to check again.',
|
|
109
|
+
},
|
|
110
|
+
};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { PT_BR_CARD_COPY } from '../../card';
|
|
2
|
+
|
|
3
|
+
import type { ActivationStepCopy } from './copy';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Everything step 3 says, in Portuguese (FUT-764).
|
|
7
|
+
*
|
|
8
|
+
* A pack, not a default. Nothing in this package reads it: `createActivationStep`
|
|
9
|
+
* still REQUIRES its copy, and a host that wants these words names them. That
|
|
10
|
+
* distinction is the one `copy.ts` and `settings-copy.ts` argue for — a default
|
|
11
|
+
* in the origin host's language reads as finished to the next host right up
|
|
12
|
+
* until an owner sees it — and it is why the eight packs already on
|
|
13
|
+
* `../../locales` are not a breach of it either.
|
|
14
|
+
*
|
|
15
|
+
* These sentences are here because they are the ACTIVATION STEP's vocabulary
|
|
16
|
+
* and not one product's voice: "a cobrança de teste é o que libera as vendas",
|
|
17
|
+
* "autenticado, mas não ativado", "última verificação há Ns". Every adopter
|
|
18
|
+
* would write them again, word for word, because they describe outcomes this
|
|
19
|
+
* package decides. They came from the first adopting host, where they had been
|
|
20
|
+
* maintained beside a screen that no longer lives there.
|
|
21
|
+
*
|
|
22
|
+
* ## What does NOT get translated, in either half
|
|
23
|
+
*
|
|
24
|
+
* **Checkout Integrado** is the exact label on PagBank's own console; an owner
|
|
25
|
+
* sent looking for "Integrated Checkout" is looking for a screen that does not
|
|
26
|
+
* exist. **CPF** and its `000.000.000-00` mask are a Brazilian document and a
|
|
27
|
+
* FORMAT — a translated mask shows a shape the field refuses. **PagBank** and
|
|
28
|
+
* **InfinitePay** are names. `{provider}` is this package's own placeholder,
|
|
29
|
+
* substituted after the sentence is chosen.
|
|
30
|
+
*/
|
|
31
|
+
export const PT_BR_ACTIVATION_STEP_COPY: ActivationStepCopy = {
|
|
32
|
+
intro: {
|
|
33
|
+
title: 'Passo 3 · Ative as vendas',
|
|
34
|
+
cardBody: (amountLabel) =>
|
|
35
|
+
`Faça uma cobrança de teste${amountLabel ? ` de ${amountLabel}` : ''} no seu próprio ` +
|
|
36
|
+
'cartão. Ela é estornada automaticamente e é o que comprova que sua loja consegue ' +
|
|
37
|
+
'receber de verdade.',
|
|
38
|
+
realCharge: (amountLabel) =>
|
|
39
|
+
`Geramos uma cobrança real de ${amountLabel} na sua própria conta. Pagar por ela é o que ` +
|
|
40
|
+
'comprova que sua loja consegue receber de verdade.',
|
|
41
|
+
payingYourself: (amountLabel) =>
|
|
42
|
+
`Você paga para si mesmo: os ${amountLabel} saem do seu meio de pagamento e caem na conta ` +
|
|
43
|
+
'que recebe as vendas desta loja. Não há estorno porque o valor não sai do seu controle.',
|
|
44
|
+
},
|
|
45
|
+
actions: {
|
|
46
|
+
chargeAndActivate: (amountLabel) => `Cobrar${amountLabel ? ` ${amountLabel}` : ''} e ativar`,
|
|
47
|
+
payAndActivate: (amountLabel) => `Pagar ${amountLabel} e ativar`,
|
|
48
|
+
testAgain: 'Testar novamente',
|
|
49
|
+
retry: 'Tentar novamente',
|
|
50
|
+
tryAgain: 'Tentar de novo',
|
|
51
|
+
restart: 'Já habilitei — recomeçar o Passo 3',
|
|
52
|
+
generateNewCharge: 'Gerar nova cobrança',
|
|
53
|
+
checkNow: 'Conferir agora',
|
|
54
|
+
alreadyPaidCheckNow: 'Já paguei — conferir agora',
|
|
55
|
+
setProviderOrder: 'Definir a ordem entre provedores',
|
|
56
|
+
seePublishedStore: 'Ver a loja publicada',
|
|
57
|
+
},
|
|
58
|
+
awaiting: {
|
|
59
|
+
receivedTitle: 'Pagamento recebido — confirmando',
|
|
60
|
+
receivedBody:
|
|
61
|
+
'Estamos confirmando seu pagamento com o provedor. Isso costuma levar poucos segundos.',
|
|
62
|
+
declinedTitle: 'O pagamento foi recusado',
|
|
63
|
+
waitingTitle: 'Aguardando o pagamento…',
|
|
64
|
+
waitingBody: (amountLabel) =>
|
|
65
|
+
`Geramos a cobrança de ${amountLabel} e abrimos a página do provedor em outra aba. ` +
|
|
66
|
+
'Pague por lá e volte aqui: conferimos sozinhos assim que o pagamento cair.',
|
|
67
|
+
lastChecked: (seconds) => `Última verificação há ${seconds}s`,
|
|
68
|
+
openPaymentPage: 'Abrir a página de pagamento',
|
|
69
|
+
copyLink: 'Copiar link',
|
|
70
|
+
linkCopied: 'Link copiado',
|
|
71
|
+
showLink: 'Ver o link',
|
|
72
|
+
hideLink: 'Esconder o link',
|
|
73
|
+
},
|
|
74
|
+
outcome: {
|
|
75
|
+
approvedTitle: 'Cobrança de teste aprovada',
|
|
76
|
+
refundedBody: (amountLabel) =>
|
|
77
|
+
`Sua loja já consegue receber pagamentos. O ${amountLabel} foi estornado automaticamente.`,
|
|
78
|
+
refundPendingBody: (amountLabel) =>
|
|
79
|
+
`Sua loja já consegue receber pagamentos. O estorno do ${amountLabel} não foi concluído — ` +
|
|
80
|
+
'ele aparecerá na sua fatura.',
|
|
81
|
+
someAmount: 'valor',
|
|
82
|
+
authenticatedNotActive: 'Autenticado, mas não ativado',
|
|
83
|
+
refusedTitle: (displayName) => `A ${displayName} recusou criar a cobrança`,
|
|
84
|
+
refusedBody: (displayName) =>
|
|
85
|
+
`A ${displayName} recusou criar a cobrança. Isso quase sempre significa que o ` +
|
|
86
|
+
'Checkout Integrado ainda está desligado na sua conta. Nada foi cobrado — ' +
|
|
87
|
+
'reabrimos o Passo 2 acima: habilite lá e volte aqui.',
|
|
88
|
+
unreachableTitle: 'Não conseguimos falar com o provedor',
|
|
89
|
+
expiredTitle: 'A cobrança expirou',
|
|
90
|
+
settledTitle: 'Cobrança de teste confirmada',
|
|
91
|
+
settledBody: (amountLabel) =>
|
|
92
|
+
`Sua loja já consegue receber pagamentos. O ${amountLabel} fica na sua conta — ` +
|
|
93
|
+
'estornos da InfinitePay são feitos no app dela.',
|
|
94
|
+
provenTitle: 'Cobrança de teste confirmada',
|
|
95
|
+
provenBody:
|
|
96
|
+
'Sua loja provou que consegue receber por este provedor. Ligamos as vendas ' +
|
|
97
|
+
'automaticamente — você pode pausar pelo botão no topo quando quiser. Nenhuma nova ' +
|
|
98
|
+
'cobrança é necessária.',
|
|
99
|
+
providerSaid: 'Resposta do provedor:',
|
|
100
|
+
blockedTitle: 'Falta confirmar o Passo 2',
|
|
101
|
+
blockedBody:
|
|
102
|
+
'Confirme acima que o Checkout Integrado está habilitado na sua conta. Sem ele o ' +
|
|
103
|
+
'provedor não cria nenhum link de pagamento, e esta cobrança falharia.',
|
|
104
|
+
},
|
|
105
|
+
taxId: {
|
|
106
|
+
label: 'CPF do titular',
|
|
107
|
+
hint: 'Exigido pelo PagBank em qualquer cobrança no cartão.',
|
|
108
|
+
placeholder: '000.000.000-00',
|
|
109
|
+
},
|
|
110
|
+
charge: {
|
|
111
|
+
card: PT_BR_CARD_COPY,
|
|
112
|
+
noTokenizer:
|
|
113
|
+
'Ainda não há tokenização de cartão implementada para {provider}, ' +
|
|
114
|
+
'então a cobrança de verificação não pode ser feita por aqui.',
|
|
115
|
+
chargeFailed: 'Não foi possível concluir a cobrança de teste.',
|
|
116
|
+
unreachable: 'Não foi possível conectar. Verifique sua conexão e tente novamente.',
|
|
117
|
+
},
|
|
118
|
+
redirect: {
|
|
119
|
+
chargeExpired: 'A cobrança expirou.',
|
|
120
|
+
confirmFailed: 'Não foi possível confirmar a cobrança de teste.',
|
|
121
|
+
createFailed: 'Não foi possível gerar a cobrança de teste.',
|
|
122
|
+
confirmTimedOut:
|
|
123
|
+
'Não conseguimos confirmar o pagamento a tempo. Se você já pagou, a cobrança continua ' +
|
|
124
|
+
'válida — recarregue a página para conferir de novo.',
|
|
125
|
+
},
|
|
126
|
+
};
|
|
@@ -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,
|
package/src/locales.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import type { ActivationStepCopy } from './activation/screens/copy';
|
|
2
|
+
import { EN_US_ACTIVATION_STEP_COPY } from './activation/screens/copy-en-US';
|
|
3
|
+
import { PT_BR_ACTIVATION_STEP_COPY } from './activation/screens/copy-pt-BR';
|
|
1
4
|
import type { CardCopy } from './card/copy';
|
|
2
5
|
import { EN_US_CARD_COPY, PT_BR_CARD_COPY } from './card';
|
|
3
6
|
import type { CheckoutPaymentCopy } from './components/checkout-payment-copy';
|
|
@@ -40,6 +43,17 @@ import { PT_BR_PAYMENTS_SETTINGS_COPY } from './components/settings-pt-BR';
|
|
|
40
43
|
*/
|
|
41
44
|
type LocalePack<T> = { readonly 'pt-BR': T; readonly 'en-US': T };
|
|
42
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Step 3's words (FUT-764). `createActivationStep` still REQUIRES its copy and
|
|
48
|
+
* reads nothing from here — a host that wants these names them, which is the
|
|
49
|
+
* distinction between a pack and a default. They arrived from the first
|
|
50
|
+
* adopting host, where they had outlived the screen they belonged to.
|
|
51
|
+
*/
|
|
52
|
+
export const ACTIVATION_STEP_COPY = {
|
|
53
|
+
'pt-BR': PT_BR_ACTIVATION_STEP_COPY,
|
|
54
|
+
'en-US': EN_US_ACTIVATION_STEP_COPY,
|
|
55
|
+
} as const satisfies LocalePack<ActivationStepCopy>;
|
|
56
|
+
|
|
43
57
|
export const CARD_COPY = {
|
|
44
58
|
'pt-BR': PT_BR_CARD_COPY,
|
|
45
59
|
'en-US': EN_US_CARD_COPY,
|
|
@@ -88,6 +102,7 @@ export const PLATFORM_HOMOLOGACAO_COPY = {
|
|
|
88
102
|
* key them — while the root keeps exporting the pt-BR names it always has, so
|
|
89
103
|
* no existing import moves.
|
|
90
104
|
*/
|
|
105
|
+
export { EN_US_ACTIVATION_STEP_COPY } from './activation/screens/copy-en-US';
|
|
91
106
|
export { EN_US_CARD_COPY } from './card';
|
|
92
107
|
export { EN_US_CHECKOUT_PAYMENT_COPY } from './components/checkout-payment-en-US';
|
|
93
108
|
export {
|