@12-apps/payments-frontend 3.9.0 → 3.10.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/charge-copy.ts +10 -0
- package/src/activation/use-activation-charge.ts +10 -8
- package/src/card/copy.ts +115 -0
- package/src/card/cpf.ts +12 -5
- package/src/card/fields.tsx +17 -12
- package/src/card/format.ts +32 -16
- package/src/card/index.ts +3 -1
- package/src/card/pt-BR.ts +51 -0
- package/src/card/stripe-token.ts +5 -6
- package/src/card/tokenize.ts +28 -32
- package/src/components/CheckoutPayment.tsx +37 -201
- package/src/components/ConfirmCredentialSave.tsx +8 -10
- package/src/components/ConnectionCard.tsx +23 -18
- package/src/components/ConnectionProbe.tsx +3 -1
- package/src/components/CredentialFieldStack.tsx +8 -2
- package/src/components/CredentialFields.tsx +13 -6
- package/src/components/CredentialFormAlerts.tsx +28 -19
- package/src/components/EnvironmentTabs.tsx +23 -15
- package/src/components/OAuthPanel.tsx +5 -6
- package/src/components/PaymentProviderSettings.tsx +45 -13
- package/src/components/ProviderConnection.tsx +37 -28
- package/src/components/ProviderList.tsx +6 -3
- package/src/components/ProviderPriorityList.tsx +18 -16
- package/src/components/ProviderStatusBar.tsx +24 -24
- package/src/components/SetupGuideSection.tsx +15 -6
- package/src/components/checkout/apple-pay-button.tsx +19 -9
- package/src/components/checkout/buyer-fields.ts +5 -3
- package/src/components/checkout/buyer-gate.ts +68 -0
- package/src/components/checkout/buyer-info-form.tsx +14 -15
- package/src/components/checkout/card-instruments.ts +31 -12
- package/src/components/checkout/card-view.tsx +11 -7
- package/src/components/checkout/checkout-flow.tsx +4 -1
- package/src/components/checkout/checkout-steps.tsx +3 -1
- package/src/components/checkout/client-context.tsx +28 -16
- package/src/components/checkout/client.ts +43 -16
- package/src/components/checkout/copy-context.tsx +83 -0
- package/src/components/checkout/google-pay-button.tsx +19 -7
- package/src/components/checkout/method-picker.tsx +34 -10
- package/src/components/checkout/payer-summary.tsx +8 -6
- package/src/components/checkout/payment-error-panel.tsx +8 -5
- package/src/components/checkout/pix-view.tsx +16 -9
- package/src/components/checkout/providers/hosted-link.tsx +26 -16
- package/src/components/checkout/pt-BR.ts +27 -0
- package/src/components/checkout/screens-copy.ts +201 -0
- package/src/components/checkout/screens-pt-BR.ts +101 -0
- package/src/components/checkout/transport.ts +34 -8
- package/src/components/checkout/use-card-checkout.ts +13 -5
- package/src/components/checkout/use-checkout-controller.ts +5 -48
- package/src/components/checkout/view-copy.ts +13 -0
- package/src/components/checkout/wallet-pane.tsx +13 -8
- package/src/components/checkout-payment-copy.ts +88 -0
- package/src/components/checkout-payment-pt-BR.ts +50 -0
- package/src/components/checkout-payment-types.ts +25 -0
- package/src/components/checkout-payment-views.tsx +225 -0
- package/src/components/connection-state.ts +22 -10
- package/src/components/credential-rules.ts +8 -3
- package/src/components/platform/ConnectApplicationPanel.tsx +6 -2
- package/src/components/platform/ConnectEnvironmentCard.tsx +19 -15
- package/src/components/platform/HomologacaoGuideCard.tsx +23 -19
- package/src/components/platform/HomologacaoOutcomeCard.tsx +24 -16
- package/src/components/platform/PlatformHomologacao.tsx +12 -8
- package/src/components/settings-copy-context.tsx +50 -0
- package/src/components/settings-copy.ts +304 -0
- package/src/components/settings-pt-BR.ts +169 -0
- package/src/flows/copy.ts +12 -0
- package/src/flows/create-payment-flows.tsx +8 -1
- package/src/flows/runtime.tsx +12 -4
- package/src/flows/screens-vault.tsx +10 -10
- package/src/flows/types.ts +5 -2
- package/src/flows/use-add-card.ts +10 -5
- package/src/index.ts +63 -0
package/src/card/tokenize.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { err, ok, type Result } from "../result";
|
|
|
26
26
|
import { detectBrand, onlyDigits } from "./format";
|
|
27
27
|
import { tokenizeWithStripe } from "./stripe-token";
|
|
28
28
|
import type { CardDetails, CardToken } from "./types";
|
|
29
|
+
import type { CardCopy } from "./copy";
|
|
29
30
|
|
|
30
31
|
/** Test PAN that always declines (mirrors common sandbox decline cards). */
|
|
31
32
|
const DECLINE_PAN = "4000000000000002";
|
|
@@ -80,11 +81,11 @@ function ensurePagBankSdk(): Promise<boolean> {
|
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
/** Local card-field validation. Returns an error message, or `null` when valid. */
|
|
83
|
-
function validateCardInput(card: CardDetails): string | null {
|
|
84
|
-
if (!passesLuhn(onlyDigits(card.number))) return
|
|
85
|
-
if (card.holder.trim().length < 2) return
|
|
86
|
-
if (!expiryIsFuture(card.expiry)) return
|
|
87
|
-
if (!/^\d{3,4}$/.test(card.cvv.trim())) return
|
|
84
|
+
function validateCardInput(card: CardDetails, copy: CardCopy): string | null {
|
|
85
|
+
if (!passesLuhn(onlyDigits(card.number))) return copy.fields.numberInvalid;
|
|
86
|
+
if (card.holder.trim().length < 2) return copy.fields.holderRequired;
|
|
87
|
+
if (!expiryIsFuture(card.expiry)) return copy.fields.expiryInvalid;
|
|
88
|
+
if (!/^\d{3,4}$/.test(card.cvv.trim())) return copy.fields.cvvInvalid;
|
|
88
89
|
return null;
|
|
89
90
|
}
|
|
90
91
|
|
|
@@ -98,12 +99,13 @@ function encryptWithSdk(
|
|
|
98
99
|
publicKey: string,
|
|
99
100
|
brand: string,
|
|
100
101
|
last4: string,
|
|
102
|
+
copy: CardCopy,
|
|
101
103
|
): Result<CardToken> {
|
|
102
104
|
if (typeof window === "undefined" || !window.PagSeguro?.encryptCard) {
|
|
103
|
-
return err(
|
|
105
|
+
return err(copy.tokenize.sdkUnavailable);
|
|
104
106
|
}
|
|
105
107
|
const match = /^(\d{2})\/(\d{2})$/.exec(card.expiry.trim());
|
|
106
|
-
if (!match) return err(
|
|
108
|
+
if (!match) return err(copy.fields.expiryInvalid);
|
|
107
109
|
const encrypted = window.PagSeguro.encryptCard({
|
|
108
110
|
publicKey,
|
|
109
111
|
holder: card.holder.trim(),
|
|
@@ -113,7 +115,7 @@ function encryptWithSdk(
|
|
|
113
115
|
securityCode: card.cvv.trim(),
|
|
114
116
|
});
|
|
115
117
|
if (encrypted.hasErrors || !encrypted.encryptedCard) {
|
|
116
|
-
return err(
|
|
118
|
+
return err(copy.tokenize.cardNotProcessed);
|
|
117
119
|
}
|
|
118
120
|
return ok({ token: encrypted.encryptedCard, brand, last4 });
|
|
119
121
|
}
|
|
@@ -157,11 +159,12 @@ async function tokenizeWithPagarme(
|
|
|
157
159
|
publicKey: string,
|
|
158
160
|
brand: string,
|
|
159
161
|
last4: string,
|
|
162
|
+
copy: CardCopy,
|
|
160
163
|
/** Deadline for the round trip; an abort reads as "could not contact". */
|
|
161
164
|
signal?: AbortSignal,
|
|
162
165
|
): Promise<Result<CardToken>> {
|
|
163
166
|
const match = /^(\d{2})\/(\d{2})$/.exec(card.expiry.trim());
|
|
164
|
-
if (!match) return err(
|
|
167
|
+
if (!match) return err(copy.fields.expiryInvalid);
|
|
165
168
|
|
|
166
169
|
let response: Response;
|
|
167
170
|
try {
|
|
@@ -181,7 +184,7 @@ async function tokenizeWithPagarme(
|
|
|
181
184
|
}),
|
|
182
185
|
});
|
|
183
186
|
} catch {
|
|
184
|
-
return err(
|
|
187
|
+
return err(copy.tokenize.providerUnreachable);
|
|
185
188
|
}
|
|
186
189
|
|
|
187
190
|
const body = (await response.json().catch(() => null)) as { id?: unknown } | null;
|
|
@@ -189,8 +192,7 @@ async function tokenizeWithPagarme(
|
|
|
189
192
|
// Carried verbatim: a rejected tokenization is the provider's answer, and
|
|
190
193
|
// the whole point of the activation screen is that it reaches a human.
|
|
191
194
|
return err(
|
|
192
|
-
|
|
193
|
-
`Resposta: ${JSON.stringify(body).slice(0, 300)}`,
|
|
195
|
+
copy.tokenize.providerRefused(response.status, JSON.stringify(body).slice(0, 300)),
|
|
194
196
|
);
|
|
195
197
|
}
|
|
196
198
|
return ok({ token: body.id, brand, last4 });
|
|
@@ -205,6 +207,7 @@ async function tokenizeWithPagarme(
|
|
|
205
207
|
export async function tokenizeCard(
|
|
206
208
|
card: CardDetails,
|
|
207
209
|
publicKey: string | null | undefined,
|
|
210
|
+
copy: CardCopy,
|
|
208
211
|
tokenizer: CardTokenizer = "pagbank-sdk",
|
|
209
212
|
/**
|
|
210
213
|
* Bounds the network schemes (Pagar.me / Stripe). The PagBank one encrypts
|
|
@@ -212,31 +215,24 @@ export async function tokenizeCard(
|
|
|
212
215
|
*/
|
|
213
216
|
signal?: AbortSignal,
|
|
214
217
|
): Promise<Result<CardToken>> {
|
|
215
|
-
const validationError = validateCardInput(card);
|
|
218
|
+
const validationError = validateCardInput(card, copy);
|
|
216
219
|
if (validationError) return err(validationError);
|
|
217
220
|
|
|
218
|
-
if (!publicKey)
|
|
219
|
-
return err(
|
|
220
|
-
"A chave pública do cartão não está disponível para esta loja. " +
|
|
221
|
-
"Reconecte o provedor e tente novamente.",
|
|
222
|
-
);
|
|
223
|
-
}
|
|
221
|
+
if (!publicKey) return err(copy.tokenize.noPublicKey);
|
|
224
222
|
|
|
225
223
|
const pan = onlyDigits(card.number);
|
|
226
224
|
const brand = detectBrand(pan);
|
|
227
225
|
const last4 = pan.slice(-4);
|
|
228
226
|
|
|
229
227
|
if (tokenizer === "pagarme-token") {
|
|
230
|
-
return tokenizeWithPagarme(card, pan, publicKey, brand, last4, signal);
|
|
228
|
+
return tokenizeWithPagarme(card, pan, publicKey, brand, last4, copy, signal);
|
|
231
229
|
}
|
|
232
230
|
if (tokenizer === "stripe-pm") {
|
|
233
|
-
return tokenizeWithStripe(card, pan, publicKey, brand, last4, signal);
|
|
231
|
+
return tokenizeWithStripe(card, pan, publicKey, brand, last4, copy, signal);
|
|
234
232
|
}
|
|
235
233
|
|
|
236
|
-
if (!(await ensurePagBankSdk()))
|
|
237
|
-
|
|
238
|
-
}
|
|
239
|
-
return encryptWithSdk(card, pan, publicKey, brand, last4);
|
|
234
|
+
if (!(await ensurePagBankSdk())) return err(copy.tokenize.sdkUnavailable);
|
|
235
|
+
return encryptWithSdk(card, pan, publicKey, brand, last4, copy);
|
|
240
236
|
}
|
|
241
237
|
|
|
242
238
|
/**
|
|
@@ -259,10 +255,6 @@ export interface CardTokenizationConfig {
|
|
|
259
255
|
mockTokenization: boolean;
|
|
260
256
|
}
|
|
261
257
|
|
|
262
|
-
/** The clear bail (FUT-697): said BEFORE any charge, naming the remedy. */
|
|
263
|
-
const CARD_PATH_UNAVAILABLE =
|
|
264
|
-
"O pagamento com cartão está indisponível nesta loja no momento. Recarregue a página e tente de novo, escolha outro método de pagamento ou combine diretamente com a loja.";
|
|
265
|
-
|
|
266
258
|
/**
|
|
267
259
|
* Tokenize for the buyer checkout, speaking the ACTIVE provider's protocol.
|
|
268
260
|
*
|
|
@@ -282,6 +274,7 @@ const CARD_PATH_UNAVAILABLE =
|
|
|
282
274
|
export async function tokenizeForCheckout(
|
|
283
275
|
card: CardDetails,
|
|
284
276
|
config: CardTokenizationConfig,
|
|
277
|
+
copy: CardCopy,
|
|
285
278
|
/**
|
|
286
279
|
* Optional deadline for a provider that mints over the network. The chain
|
|
287
280
|
* path passes one so a backup acquirer nobody can reach cannot hold the
|
|
@@ -290,11 +283,14 @@ export async function tokenizeForCheckout(
|
|
|
290
283
|
signal?: AbortSignal,
|
|
291
284
|
): Promise<Result<CardToken>> {
|
|
292
285
|
const scheme = config.provider ? tokenizerFor(config.provider) : null;
|
|
293
|
-
if (scheme && config.publicKey)
|
|
286
|
+
if (scheme && config.publicKey) {
|
|
287
|
+
return tokenizeCard(card, config.publicKey, copy, scheme, signal);
|
|
288
|
+
}
|
|
294
289
|
|
|
295
|
-
|
|
290
|
+
// The clear bail (FUT-697): said BEFORE any charge, naming the remedy.
|
|
291
|
+
if (!config.mockTokenization) return err(copy.tokenize.cardUnavailable);
|
|
296
292
|
|
|
297
|
-
const validationError = validateCardInput(card);
|
|
293
|
+
const validationError = validateCardInput(card, copy);
|
|
298
294
|
if (validationError) return err(validationError);
|
|
299
295
|
|
|
300
296
|
const pan = onlyDigits(card.number);
|
|
@@ -1,17 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
Alert,
|
|
5
|
-
Box,
|
|
6
|
-
Button,
|
|
7
|
-
CircularProgress,
|
|
8
|
-
FormControlLabel,
|
|
9
|
-
Radio,
|
|
10
|
-
RadioGroup,
|
|
11
|
-
Stack,
|
|
12
|
-
TextField,
|
|
13
|
-
Typography,
|
|
14
|
-
} from '@mui/material';
|
|
3
|
+
import { Alert, Button, CircularProgress, Stack, Typography } from '@mui/material';
|
|
15
4
|
import { useCallback, useEffect, useState } from 'react';
|
|
16
5
|
|
|
17
6
|
import type { ClientChargeView, CustomerInfo, Money } from '@12-apps/payments-backend';
|
|
@@ -19,6 +8,12 @@ import type { ClientChargeView, CustomerInfo, Money } from '@12-apps/payments-ba
|
|
|
19
8
|
import type { ClientPaymentsConfig, PaymentsClient } from '../client';
|
|
20
9
|
import { useChargeStatus, useCreateCharge, usePaymentsClient } from '../context';
|
|
21
10
|
|
|
11
|
+
import type { CheckoutPaymentCopy, LegacyRefusalCopy } from './checkout-payment-copy';
|
|
12
|
+
import type { CardFormValues, SavedCardOption } from './checkout-payment-types';
|
|
13
|
+
import { formatAmount, MethodChooser, PixPanel } from './checkout-payment-views';
|
|
14
|
+
|
|
15
|
+
export type { CardFormValues, SavedCardOption };
|
|
16
|
+
|
|
22
17
|
/**
|
|
23
18
|
* Plug-and-play checkout payment step — the reusable equivalent of the
|
|
24
19
|
* buyer-facing payment screen. Renders the right flow for the merchant's
|
|
@@ -36,22 +31,6 @@ import { useChargeStatus, useCreateCharge, usePaymentsClient } from '../context'
|
|
|
36
31
|
* order creation, and what happens on `onPaid` — this component owns only
|
|
37
32
|
* the payment interaction.
|
|
38
33
|
*/
|
|
39
|
-
export interface CardFormValues {
|
|
40
|
-
number: string;
|
|
41
|
-
holder: string;
|
|
42
|
-
expiry: string;
|
|
43
|
-
cvv: string;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** A provider-vaulted card the buyer may reuse ("Mastercard •••• 7599"). */
|
|
47
|
-
export interface SavedCardOption {
|
|
48
|
-
savedCardToken: string;
|
|
49
|
-
brand: string;
|
|
50
|
-
last4: string;
|
|
51
|
-
/** e.g. "02/2034" — shown as "Validade 02/2034". */
|
|
52
|
-
expiry?: string;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
34
|
export interface CheckoutPaymentProps {
|
|
56
35
|
/**
|
|
57
36
|
* Opaque host-side handle for what is being paid (cart/order id). Sent as
|
|
@@ -72,13 +51,11 @@ export interface CheckoutPaymentProps {
|
|
|
72
51
|
savedCards?: SavedCardOption[];
|
|
73
52
|
onPaid: (charge: ClientChargeView) => void;
|
|
74
53
|
onFailed?: (charge: ClientChargeView) => void;
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
currency: amount.currency,
|
|
81
|
-
});
|
|
54
|
+
/**
|
|
55
|
+
* Every sentence this step renders — the HOST's, required and with no
|
|
56
|
+
* default (FUT-760). A pt-BR host passes `PT_BR_CHECKOUT_PAYMENT_COPY`.
|
|
57
|
+
*/
|
|
58
|
+
copy: CheckoutPaymentCopy;
|
|
82
59
|
}
|
|
83
60
|
|
|
84
61
|
function firstError(...messages: (string | null | undefined)[]): string | null {
|
|
@@ -118,158 +95,6 @@ function useChargeOutcome(
|
|
|
118
95
|
}, [current, settled, onPaid, onFailed]);
|
|
119
96
|
}
|
|
120
97
|
|
|
121
|
-
function PixPanel({ charge }: { charge: ClientChargeView }) {
|
|
122
|
-
const [copied, setCopied] = useState(false);
|
|
123
|
-
if (!charge.pix) return null;
|
|
124
|
-
return (
|
|
125
|
-
<Stack spacing={1} alignItems="flex-start">
|
|
126
|
-
{charge.pix.qrImageUrl ? (
|
|
127
|
-
<Box component="img" src={charge.pix.qrImageUrl} alt="QR Code PIX" sx={{ width: 220 }} />
|
|
128
|
-
) : null}
|
|
129
|
-
<TextField fullWidth multiline size="small" label="PIX copia e cola" value={charge.pix.qrText} />
|
|
130
|
-
<Button
|
|
131
|
-
size="small"
|
|
132
|
-
onClick={() => {
|
|
133
|
-
void navigator.clipboard.writeText(charge.pix?.qrText ?? '').then(() => setCopied(true));
|
|
134
|
-
}}
|
|
135
|
-
>
|
|
136
|
-
{copied ? 'Copiado!' : 'Copiar código'}
|
|
137
|
-
</Button>
|
|
138
|
-
<Stack direction="row" spacing={1} alignItems="center">
|
|
139
|
-
<CircularProgress size={16} />
|
|
140
|
-
<Typography variant="body2">Aguardando pagamento…</Typography>
|
|
141
|
-
</Stack>
|
|
142
|
-
</Stack>
|
|
143
|
-
);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function CardPanel({ disabled, onSubmit }: { disabled: boolean; onSubmit: (values: CardFormValues) => void }) {
|
|
147
|
-
const [values, setValues] = useState<CardFormValues>({ number: '', holder: '', expiry: '', cvv: '' });
|
|
148
|
-
const field = (key: keyof CardFormValues, label: string, width?: number) => (
|
|
149
|
-
<TextField
|
|
150
|
-
size="small"
|
|
151
|
-
label={label}
|
|
152
|
-
value={values[key]}
|
|
153
|
-
sx={width ? { width } : undefined}
|
|
154
|
-
onChange={(e) => setValues((v) => ({ ...v, [key]: e.target.value }))}
|
|
155
|
-
/>
|
|
156
|
-
);
|
|
157
|
-
return (
|
|
158
|
-
<Stack spacing={2}>
|
|
159
|
-
{field('number', 'Número do cartão')}
|
|
160
|
-
{field('holder', 'Nome impresso no cartão')}
|
|
161
|
-
<Stack direction="row" spacing={2}>
|
|
162
|
-
{field('expiry', 'Validade (MM/AA)', 140)}
|
|
163
|
-
{field('cvv', 'CVV', 100)}
|
|
164
|
-
</Stack>
|
|
165
|
-
<Button variant="contained" disabled={disabled} onClick={() => onSubmit(values)}>
|
|
166
|
-
Pagar com cartão
|
|
167
|
-
</Button>
|
|
168
|
-
</Stack>
|
|
169
|
-
);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
interface CardSectionProps {
|
|
173
|
-
amount: Money;
|
|
174
|
-
savedCards: SavedCardOption[];
|
|
175
|
-
disabled: boolean;
|
|
176
|
-
onNewCard: (values: CardFormValues) => void;
|
|
177
|
-
onSavedCard: (card: SavedCardOption) => void;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/** "Pague com cartão": saved cards as radios + "Novo cartão" fallback. */
|
|
181
|
-
function CardSection({ amount, savedCards, disabled, onNewCard, onSavedCard }: CardSectionProps) {
|
|
182
|
-
const [selected, setSelected] = useState(savedCards[0]?.savedCardToken ?? 'new');
|
|
183
|
-
const chosen = savedCards.find((c) => c.savedCardToken === selected);
|
|
184
|
-
if (savedCards.length === 0) return <CardPanel disabled={disabled} onSubmit={onNewCard} />;
|
|
185
|
-
return (
|
|
186
|
-
<Stack spacing={2}>
|
|
187
|
-
<Typography variant="subtitle2">Pague com cartão</Typography>
|
|
188
|
-
<RadioGroup value={selected} onChange={(_, v) => setSelected(v)}>
|
|
189
|
-
{savedCards.map((card) => (
|
|
190
|
-
<FormControlLabel
|
|
191
|
-
key={card.savedCardToken}
|
|
192
|
-
value={card.savedCardToken}
|
|
193
|
-
control={<Radio />}
|
|
194
|
-
label={`${card.brand} •••• ${card.last4}${card.expiry ? ` — Validade ${card.expiry}` : ''}`}
|
|
195
|
-
/>
|
|
196
|
-
))}
|
|
197
|
-
<FormControlLabel value="new" control={<Radio />} label="Novo cartão — inserir outro cartão" />
|
|
198
|
-
</RadioGroup>
|
|
199
|
-
{chosen ? (
|
|
200
|
-
<Button variant="contained" disabled={disabled} onClick={() => onSavedCard(chosen)}>
|
|
201
|
-
Pagar {formatBRL(amount)}
|
|
202
|
-
</Button>
|
|
203
|
-
) : (
|
|
204
|
-
<CardPanel disabled={disabled} onSubmit={onNewCard} />
|
|
205
|
-
)}
|
|
206
|
-
</Stack>
|
|
207
|
-
);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
interface MethodChooserProps {
|
|
211
|
-
config: ClientPaymentsConfig;
|
|
212
|
-
amount: Money;
|
|
213
|
-
savedCards: SavedCardOption[];
|
|
214
|
-
loading: boolean;
|
|
215
|
-
startPix: () => void;
|
|
216
|
-
startCard: (values: CardFormValues) => void;
|
|
217
|
-
startSavedCard: (card: SavedCardOption) => void;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/** One "Forma de pagamento" option card (PIX / Cartão). */
|
|
221
|
-
function MethodCard(props: { title: string; subtitle: string; selected: boolean; onClick: () => void }) {
|
|
222
|
-
return (
|
|
223
|
-
<Button
|
|
224
|
-
variant="outlined"
|
|
225
|
-
onClick={props.onClick}
|
|
226
|
-
sx={{ flex: 1, justifyContent: 'flex-start', textAlign: 'left', borderWidth: props.selected ? 2 : 1 }}
|
|
227
|
-
>
|
|
228
|
-
<Stack alignItems="flex-start">
|
|
229
|
-
<Typography variant="subtitle2">{props.title}</Typography>
|
|
230
|
-
<Typography variant="caption" color="text.secondary">
|
|
231
|
-
{props.subtitle}
|
|
232
|
-
</Typography>
|
|
233
|
-
</Stack>
|
|
234
|
-
</Button>
|
|
235
|
-
);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/** Pre-charge phase: "Forma de pagamento" method cards, then the flow. */
|
|
239
|
-
function MethodChooser(props: MethodChooserProps) {
|
|
240
|
-
const { config, amount, savedCards, loading, startPix, startCard, startSavedCard } = props;
|
|
241
|
-
const [method, setMethod] = useState<'PIX' | 'CARD'>('PIX');
|
|
242
|
-
if (config.tokenization === 'REDIRECT') {
|
|
243
|
-
return (
|
|
244
|
-
<Button variant="contained" disabled={loading} onClick={startPix}>
|
|
245
|
-
Continuar para o pagamento
|
|
246
|
-
</Button>
|
|
247
|
-
);
|
|
248
|
-
}
|
|
249
|
-
return (
|
|
250
|
-
<>
|
|
251
|
-
<Typography variant="subtitle2">Forma de pagamento</Typography>
|
|
252
|
-
<Stack direction="row" spacing={2}>
|
|
253
|
-
<MethodCard title="PIX" subtitle="Aprovação imediata" selected={method === 'PIX'} onClick={() => setMethod('PIX')} />
|
|
254
|
-
<MethodCard title="Cartão" subtitle="Crédito à vista" selected={method === 'CARD'} onClick={() => setMethod('CARD')} />
|
|
255
|
-
</Stack>
|
|
256
|
-
{method === 'PIX' ? (
|
|
257
|
-
<Button variant="contained" disabled={loading} onClick={startPix}>
|
|
258
|
-
Gerar QR Code PIX
|
|
259
|
-
</Button>
|
|
260
|
-
) : (
|
|
261
|
-
<CardSection
|
|
262
|
-
amount={amount}
|
|
263
|
-
savedCards={savedCards}
|
|
264
|
-
disabled={loading}
|
|
265
|
-
onNewCard={startCard}
|
|
266
|
-
onSavedCard={startSavedCard}
|
|
267
|
-
/>
|
|
268
|
-
)}
|
|
269
|
-
</>
|
|
270
|
-
);
|
|
271
|
-
}
|
|
272
|
-
|
|
273
98
|
/** Poll target: only charges that can still change server-side. */
|
|
274
99
|
function pollRefOf(charge: ClientChargeView | null) {
|
|
275
100
|
if (!charge || isFinalOnCreate(charge)) return null;
|
|
@@ -279,36 +104,46 @@ function pollRefOf(charge: ClientChargeView | null) {
|
|
|
279
104
|
interface CheckoutGateProps {
|
|
280
105
|
errorMessage: string | null;
|
|
281
106
|
config: ClientPaymentsConfig | null | undefined;
|
|
107
|
+
copy: LegacyRefusalCopy;
|
|
282
108
|
}
|
|
283
109
|
|
|
284
110
|
/** Error / loading / payments-disabled gates ahead of the payment UI. */
|
|
285
|
-
function checkoutGate({ errorMessage, config }: CheckoutGateProps) {
|
|
111
|
+
function checkoutGate({ errorMessage, config, copy }: CheckoutGateProps) {
|
|
286
112
|
if (errorMessage) return <Alert severity="error">{errorMessage}</Alert>;
|
|
287
113
|
if (config === undefined) return <CircularProgress data-testid="checkout-payment-loading" />;
|
|
288
|
-
if (config === null) {
|
|
289
|
-
return <Alert severity="warning">Esta loja ainda não aceita pagamentos online.</Alert>;
|
|
290
|
-
}
|
|
114
|
+
if (config === null) return <Alert severity="warning">{copy.paymentsOff}</Alert>;
|
|
291
115
|
return null;
|
|
292
116
|
}
|
|
293
117
|
|
|
294
118
|
/** In-flight phase: the created charge decides what the buyer sees. */
|
|
295
|
-
function ChargePhase({
|
|
119
|
+
function ChargePhase({
|
|
120
|
+
charge,
|
|
121
|
+
amount,
|
|
122
|
+
copy,
|
|
123
|
+
}: {
|
|
124
|
+
charge: ClientChargeView;
|
|
125
|
+
amount: Money;
|
|
126
|
+
copy: CheckoutPaymentCopy;
|
|
127
|
+
}) {
|
|
296
128
|
if (charge.hostedCheckoutUrl && charge.status === 'PENDING') {
|
|
297
129
|
return (
|
|
298
130
|
<Stack spacing={2}>
|
|
299
|
-
<Typography>
|
|
131
|
+
<Typography>{copy.refusal.redirectNotice}</Typography>
|
|
300
132
|
<Button variant="contained" href={charge.hostedCheckoutUrl}>
|
|
301
|
-
|
|
133
|
+
{copy.money.payAction(formatAmount(amount, copy.money))}
|
|
302
134
|
</Button>
|
|
303
135
|
</Stack>
|
|
304
136
|
);
|
|
305
137
|
}
|
|
306
|
-
if (charge.method === 'PIX' && charge.status === 'PENDING')
|
|
138
|
+
if (charge.method === 'PIX' && charge.status === 'PENDING') {
|
|
139
|
+
return <PixPanel charge={charge} copy={copy.pix} />;
|
|
140
|
+
}
|
|
307
141
|
return null;
|
|
308
142
|
}
|
|
309
143
|
|
|
310
144
|
export function CheckoutPayment(props: CheckoutPaymentProps) {
|
|
311
|
-
const { reference, amount, customer, tokenizeCard, savedCards = [], onPaid, onFailed } =
|
|
145
|
+
const { reference, amount, customer, tokenizeCard, savedCards = [], onPaid, onFailed, copy } =
|
|
146
|
+
props;
|
|
312
147
|
const client = usePaymentsClient();
|
|
313
148
|
const { config, error: configError } = useClientConfig(client);
|
|
314
149
|
const [tokenizeError, setTokenizeError] = useState<string | null>(null);
|
|
@@ -325,7 +160,7 @@ export function CheckoutPayment(props: CheckoutPaymentProps) {
|
|
|
325
160
|
const startCard = useCallback(
|
|
326
161
|
async (values: CardFormValues) => {
|
|
327
162
|
if (!config || !tokenizeCard) {
|
|
328
|
-
setTokenizeError(
|
|
163
|
+
setTokenizeError(copy.refusal.cardUnavailable);
|
|
329
164
|
return;
|
|
330
165
|
}
|
|
331
166
|
try {
|
|
@@ -340,7 +175,7 @@ export function CheckoutPayment(props: CheckoutPaymentProps) {
|
|
|
340
175
|
setTokenizeError(err instanceof Error ? err.message : String(err));
|
|
341
176
|
}
|
|
342
177
|
},
|
|
343
|
-
[config, tokenizeCard, create, reference, customer],
|
|
178
|
+
[config, tokenizeCard, create, reference, customer, copy],
|
|
344
179
|
);
|
|
345
180
|
|
|
346
181
|
const startSavedCard = useCallback(
|
|
@@ -355,14 +190,14 @@ export function CheckoutPayment(props: CheckoutPaymentProps) {
|
|
|
355
190
|
);
|
|
356
191
|
|
|
357
192
|
const errorMessage = firstError(configError, tokenizeError, createError && createError.message);
|
|
358
|
-
const gate = checkoutGate({ errorMessage, config });
|
|
193
|
+
const gate = checkoutGate({ errorMessage, config, copy: copy.refusal });
|
|
359
194
|
if (gate || config === undefined || config === null) return gate;
|
|
360
195
|
|
|
361
196
|
return (
|
|
362
197
|
<Stack spacing={2}>
|
|
363
|
-
<Typography variant="h6">
|
|
198
|
+
<Typography variant="h6">{copy.money.totalLabel(formatAmount(amount, copy.money))}</Typography>
|
|
364
199
|
{current ? (
|
|
365
|
-
<ChargePhase charge={current} amount={amount} />
|
|
200
|
+
<ChargePhase charge={current} amount={amount} copy={copy} />
|
|
366
201
|
) : (
|
|
367
202
|
<MethodChooser
|
|
368
203
|
config={config}
|
|
@@ -372,6 +207,7 @@ export function CheckoutPayment(props: CheckoutPaymentProps) {
|
|
|
372
207
|
startPix={startPix}
|
|
373
208
|
startCard={(values) => void startCard(values)}
|
|
374
209
|
startSavedCard={startSavedCard}
|
|
210
|
+
copy={copy}
|
|
375
211
|
/>
|
|
376
212
|
)}
|
|
377
213
|
</Stack>
|
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
|
|
13
13
|
import type { CredentialFieldSpec } from '@12-apps/payments-backend';
|
|
14
14
|
|
|
15
|
+
import { usePaymentsSettingsCopy } from './settings-copy-context';
|
|
16
|
+
|
|
15
17
|
/**
|
|
16
18
|
* The last look at the value that decides who gets paid.
|
|
17
19
|
*
|
|
@@ -45,6 +47,7 @@ export function ConfirmCredentialSave({
|
|
|
45
47
|
onCancel: () => void;
|
|
46
48
|
onConfirm: () => void;
|
|
47
49
|
}) {
|
|
50
|
+
const copy = usePaymentsSettingsCopy().confirmSave;
|
|
48
51
|
return (
|
|
49
52
|
<Dialog
|
|
50
53
|
open={pending !== null}
|
|
@@ -54,13 +57,10 @@ export function ConfirmCredentialSave({
|
|
|
54
57
|
data-testid="payments-confirm-credential"
|
|
55
58
|
aria-labelledby="payments-confirm-credential-title"
|
|
56
59
|
>
|
|
57
|
-
<DialogTitle id="payments-confirm-credential-title">
|
|
58
|
-
Confirme para onde vai o dinheiro
|
|
59
|
-
</DialogTitle>
|
|
60
|
+
<DialogTitle id="payments-confirm-credential-title">{copy.title}</DialogTitle>
|
|
60
61
|
<DialogContent>
|
|
61
62
|
<Typography variant="body2" color="text.secondary">
|
|
62
|
-
|
|
63
|
-
{pending?.spec.label ?? 'credencial'}:
|
|
63
|
+
{copy.body(pending?.spec.label ?? copy.fieldFallback)}
|
|
64
64
|
</Typography>
|
|
65
65
|
<Box
|
|
66
66
|
data-testid="payments-confirm-credential-value"
|
|
@@ -79,9 +79,7 @@ export function ConfirmCredentialSave({
|
|
|
79
79
|
>
|
|
80
80
|
{pending?.value}
|
|
81
81
|
</Box>
|
|
82
|
-
<Typography variant="caption" color="text.secondary">
|
|
83
|
-
Um valor errado envia os pagamentos desta loja para outra pessoa, e não há como reverter.
|
|
84
|
-
</Typography>
|
|
82
|
+
<Typography variant="caption" color="text.secondary">{copy.warning}</Typography>
|
|
85
83
|
</DialogContent>
|
|
86
84
|
<DialogActions>
|
|
87
85
|
{/*
|
|
@@ -90,7 +88,7 @@ export function ConfirmCredentialSave({
|
|
|
90
88
|
go back and check than to press on.
|
|
91
89
|
*/}
|
|
92
90
|
<Button onClick={onCancel} disabled={busy} data-testid="payments-confirm-credential-cancel">
|
|
93
|
-
|
|
91
|
+
{copy.cancelAction}
|
|
94
92
|
</Button>
|
|
95
93
|
<Button
|
|
96
94
|
variant="contained"
|
|
@@ -98,7 +96,7 @@ export function ConfirmCredentialSave({
|
|
|
98
96
|
disabled={busy}
|
|
99
97
|
data-testid="payments-confirm-credential-confirm"
|
|
100
98
|
>
|
|
101
|
-
|
|
99
|
+
{copy.confirmAction}
|
|
102
100
|
</Button>
|
|
103
101
|
</DialogActions>
|
|
104
102
|
</Dialog>
|
|
@@ -17,6 +17,7 @@ import type { ReactNode } from 'react';
|
|
|
17
17
|
import type { ConnectedOAuthAccount, PaymentEnvironment } from '@12-apps/payments-backend';
|
|
18
18
|
|
|
19
19
|
import { BTN_PRIMARY_SX, BTN_SECONDARY_SX, LINKISH_SX, T } from './panel-tokens';
|
|
20
|
+
import { usePaymentsSettingsCopy } from './settings-copy-context';
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* The connect card's own pieces: what a connection IS, what authorizing
|
|
@@ -46,23 +47,25 @@ export function ConnectionFacts({
|
|
|
46
47
|
account: ConnectedOAuthAccount | null;
|
|
47
48
|
displayName: string;
|
|
48
49
|
}) {
|
|
50
|
+
const copy = usePaymentsSettingsCopy().card;
|
|
51
|
+
const env = usePaymentsSettingsCopy().environment;
|
|
49
52
|
// A fact's testid names its VALUE, not its row: the environment is asserted
|
|
50
53
|
// with an exact `toHaveText`, so hanging the id on the grid — or even on the
|
|
51
54
|
// row, which also carries the "AMBIENTE" label — makes that assertion read
|
|
52
55
|
// the whole card. Every consumer of this id wants the one string.
|
|
53
56
|
const facts: { key: string; value: string; testId?: string }[] = [
|
|
54
|
-
{ key:
|
|
55
|
-
{ key:
|
|
57
|
+
{ key: copy.accountLabel, value: account?.accountLabel ?? account?.accountId ?? copy.accountHeading(displayName) },
|
|
58
|
+
{ key: copy.connectionLabel, value: copy.authorizedAt(displayName) },
|
|
56
59
|
{
|
|
57
|
-
key:
|
|
58
|
-
value: environment === 'PRODUCTION' ?
|
|
60
|
+
key: copy.environmentLabel,
|
|
61
|
+
value: environment === 'PRODUCTION' ? env.production : copy.sandboxWithNote,
|
|
59
62
|
testId: 'payments-connected-environment',
|
|
60
63
|
},
|
|
61
64
|
];
|
|
62
65
|
if (account?.connectedAt) {
|
|
63
66
|
facts.push({
|
|
64
|
-
key:
|
|
65
|
-
value: new Date(account.connectedAt).toLocaleString(
|
|
67
|
+
key: copy.connectedAtLabel,
|
|
68
|
+
value: new Date(account.connectedAt).toLocaleString(),
|
|
66
69
|
});
|
|
67
70
|
}
|
|
68
71
|
return (
|
|
@@ -108,10 +111,11 @@ export function ConnectionFacts({
|
|
|
108
111
|
* you end up back here.
|
|
109
112
|
*/
|
|
110
113
|
export function ConnectSteps({ displayName }: { displayName: string }) {
|
|
114
|
+
const copy = usePaymentsSettingsCopy().card;
|
|
111
115
|
const steps = [
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
116
|
+
copy.steps.signIn(displayName),
|
|
117
|
+
copy.steps.authorize,
|
|
118
|
+
copy.steps.comeBack,
|
|
115
119
|
];
|
|
116
120
|
return (
|
|
117
121
|
<Stack component="ol" spacing={1.1} sx={{ listStyle: 'none', m: 0, p: 0 }}>
|
|
@@ -159,32 +163,33 @@ export function DisconnectDialog(props: {
|
|
|
159
163
|
/** The softer path: keep the connection, stop taking orders through it. */
|
|
160
164
|
onPauseInstead?: () => void;
|
|
161
165
|
}) {
|
|
166
|
+
const copy = usePaymentsSettingsCopy().card;
|
|
162
167
|
const { displayName, receiving } = props;
|
|
163
168
|
return (
|
|
164
169
|
<Dialog open={props.open} onClose={props.onCancel} data-testid="payments-disconnect-confirm">
|
|
165
170
|
<DialogTitle sx={{ fontSize: '16.5px', fontWeight: 700, letterSpacing: '-.01em', pb: '8px' }}>
|
|
166
|
-
|
|
171
|
+
{copy.removeQuestion(displayName)}
|
|
167
172
|
</DialogTitle>
|
|
168
173
|
<DialogContent sx={{ pb: '4px' }}>
|
|
169
174
|
<DialogContentText sx={{ fontSize: '13px', color: T.ink2, lineHeight: 1.55 }}>
|
|
170
175
|
{receiving
|
|
171
|
-
?
|
|
172
|
-
:
|
|
176
|
+
? copy.removeConsequenceLive
|
|
177
|
+
: copy.removeConsequenceIdle}
|
|
173
178
|
</DialogContentText>
|
|
174
179
|
{/* The consequences, itemised. A single paragraph makes the reversible
|
|
175
180
|
facts and the irreversible one weigh the same, and the one that
|
|
176
181
|
costs money is the one an owner skims past. */}
|
|
177
182
|
<Stack component="ul" spacing={1} sx={{ listStyle: 'none', m: 0, mt: '14px', p: 0 }}>
|
|
178
183
|
{receiving ? (
|
|
179
|
-
<Consequence tone="bad">
|
|
184
|
+
<Consequence tone="bad">{copy.removeStopsChargingNow}</Consequence>
|
|
180
185
|
) : null}
|
|
181
186
|
<Consequence>
|
|
182
|
-
{
|
|
187
|
+
{copy.removeRevokes(displayName)}
|
|
183
188
|
</Consequence>
|
|
184
189
|
<Consequence>
|
|
185
|
-
{
|
|
190
|
+
{copy.removeKeepsSettled(displayName)}
|
|
186
191
|
</Consequence>
|
|
187
|
-
<Consequence>
|
|
192
|
+
<Consequence>{copy.removeRestartsSetup}</Consequence>
|
|
188
193
|
</Stack>
|
|
189
194
|
</DialogContent>
|
|
190
195
|
<DialogActions sx={{ display: 'flex', flexDirection: 'column', gap: '8px', p: '18px 22px 20px' }}>
|
|
@@ -199,7 +204,7 @@ export function DisconnectDialog(props: {
|
|
|
199
204
|
sx={BTN_SECONDARY_SX}
|
|
200
205
|
data-testid="payments-pause-instead"
|
|
201
206
|
>
|
|
202
|
-
|
|
207
|
+
{copy.pauseInstead}
|
|
203
208
|
</Button>
|
|
204
209
|
) : null}
|
|
205
210
|
<Button
|
|
@@ -211,7 +216,7 @@ export function DisconnectDialog(props: {
|
|
|
211
216
|
data-testid="payments-disconnect-confirm-action"
|
|
212
217
|
sx={{ ...BTN_PRIMARY_SX, background: T.bad, '&:hover': { background: '#a51f1f' } }}
|
|
213
218
|
>
|
|
214
|
-
{props.busy ? <CircularProgress size={18} sx={{ color: '#fff' }} /> :
|
|
219
|
+
{props.busy ? <CircularProgress size={18} sx={{ color: '#fff' }} /> : copy.removeAction}
|
|
215
220
|
</Button>
|
|
216
221
|
<Button fullWidth onClick={props.onCancel} disabled={props.busy} sx={LINKISH_SX}>
|
|
217
222
|
Cancelar
|