@12-apps/payments-frontend 3.8.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/payments-frontend",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.10.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"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.",
|
|
6
6
|
"exports": {
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"storybook:build": "storybook build"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@12-apps/payments-backend": "^4.
|
|
20
|
+
"@12-apps/payments-backend": "^4.18.0",
|
|
21
21
|
"react-qr-code": "^2.2.0"
|
|
22
22
|
},
|
|
23
23
|
"peerDependencies": {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { CardCopy } from '../card/copy';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* The three sentences the card activation charge has to put on screen itself
|
|
3
5
|
* (FUT-763).
|
|
@@ -8,6 +10,14 @@
|
|
|
8
10
|
* product's voice reaches every adopter. No defaults, and the field is required.
|
|
9
11
|
*/
|
|
10
12
|
export interface ActivationChargeCopy {
|
|
13
|
+
/**
|
|
14
|
+
* The card form's own words — its labels, its field-level refusals, and
|
|
15
|
+
* everything a tokenizer can fail with.
|
|
16
|
+
*
|
|
17
|
+
* The activation charge IS a card form, so it carries the same port the
|
|
18
|
+
* buyer checkout does rather than a second one shaped almost like it.
|
|
19
|
+
*/
|
|
20
|
+
card: CardCopy;
|
|
11
21
|
/**
|
|
12
22
|
* No tokenizer is registered for this provider, so nothing can be encrypted
|
|
13
23
|
* and there is no charge to make.
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
} from '../card';
|
|
19
19
|
|
|
20
20
|
import type { ActivationChargeCopy } from './charge-copy';
|
|
21
|
+
import type { CardCopy } from '../card/copy';
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* The activation charge for a provider whose payer pays HERE (FUT-463, moved
|
|
@@ -166,16 +167,17 @@ function useActivationProbe(verifyChargeUrl: string): ActivationProbe {
|
|
|
166
167
|
}
|
|
167
168
|
|
|
168
169
|
/** Local validation — nothing reaches the provider until the card is well-formed. */
|
|
169
|
-
function validateAll(card: CardDetails, cpf: string) {
|
|
170
|
+
function validateAll(card: CardDetails, cpf: string, copy: CardCopy) {
|
|
170
171
|
const brand = detectBrand(onlyDigits(card.number));
|
|
172
|
+
const fields = copy.fields;
|
|
171
173
|
return {
|
|
172
174
|
fieldErrors: {
|
|
173
|
-
number: validateCardNumber(card.number),
|
|
174
|
-
holder: validateHolder(card.holder),
|
|
175
|
-
expiry: validateExpiry(card.expiry),
|
|
176
|
-
cvv: validateCvv(card.cvv, brand),
|
|
175
|
+
number: validateCardNumber(card.number, fields),
|
|
176
|
+
holder: validateHolder(card.holder, fields),
|
|
177
|
+
expiry: validateExpiry(card.expiry, fields),
|
|
178
|
+
cvv: validateCvv(card.cvv, fields, brand),
|
|
177
179
|
} satisfies CardFieldErrors,
|
|
178
|
-
cpfError: validateCpf(cpf),
|
|
180
|
+
cpfError: validateCpf(cpf, fields),
|
|
179
181
|
};
|
|
180
182
|
}
|
|
181
183
|
|
|
@@ -205,7 +207,7 @@ async function runCharge(request: ChargeRequest): Promise<ActivationChargeState>
|
|
|
205
207
|
};
|
|
206
208
|
}
|
|
207
209
|
|
|
208
|
-
const tokenized = await tokenizeCard(request.card, request.publicKey, tokenizer);
|
|
210
|
+
const tokenized = await tokenizeCard(request.card, request.publicKey, request.copy.card, tokenizer);
|
|
209
211
|
if (!tokenized.ok) return { kind: 'failed', reason: tokenized.error };
|
|
210
212
|
|
|
211
213
|
try {
|
|
@@ -259,7 +261,7 @@ export function useActivationCharge(options: ActivationChargeOptions): Activatio
|
|
|
259
261
|
const { card, cpf, setFieldErrors, setCpfError, clear } = form;
|
|
260
262
|
|
|
261
263
|
const submit = useCallback(async () => {
|
|
262
|
-
const validation = validateAll(card, cpf);
|
|
264
|
+
const validation = validateAll(card, cpf, options.copy.card);
|
|
263
265
|
setFieldErrors(validation.fieldErrors);
|
|
264
266
|
setCpfError(validation.cpfError);
|
|
265
267
|
if (Object.values(validation.fieldErrors).some(Boolean) || validation.cpfError) return;
|
package/src/card/copy.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every sentence the card form and its tokenizers put in front of a BUYER
|
|
3
|
+
* (FUT-760).
|
|
4
|
+
*
|
|
5
|
+
* Required, with no defaults, which is this package's own doctrine
|
|
6
|
+
* (`components/checkout/view-copy.ts` states it): a default in the origin
|
|
7
|
+
* host's language reads as finished to the next host right up until a shopper
|
|
8
|
+
* sees it.
|
|
9
|
+
*
|
|
10
|
+
* The split is the one the whole port draws. WHICH refusal this is — a number
|
|
11
|
+
* that fails Luhn, an expiry already past, a CVV of the wrong length for the
|
|
12
|
+
* detected network, an SDK that never loaded, a provider that refused the
|
|
13
|
+
* card outright — is knowledge of card processing, and it stays here. The
|
|
14
|
+
* words are the host's.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The card networks this form can recognise from a number's prefix.
|
|
19
|
+
*
|
|
20
|
+
* `Unknown` used to be spelled `"Cartão"` — a Portuguese word inside an
|
|
21
|
+
* exported TYPE UNION, so it reached every adopter's types and, through
|
|
22
|
+
* `CardBrand`, their API surface. A string leak is cosmetic; a union member is
|
|
23
|
+
* structural, and it propagates. The brand's LABEL is
|
|
24
|
+
* {@link CardFieldCopy.unknownBrand} now, and the union names the fact.
|
|
25
|
+
*/
|
|
26
|
+
export type CardBrand = 'Visa' | 'Mastercard' | 'Amex' | 'Elo' | 'Unknown';
|
|
27
|
+
|
|
28
|
+
/** The card form's labels and its field-level refusals. */
|
|
29
|
+
export interface CardFieldCopy {
|
|
30
|
+
/** The adornment shown when the number matches no network we know. */
|
|
31
|
+
unknownBrand: string;
|
|
32
|
+
numberLabel: string;
|
|
33
|
+
numberRequired: string;
|
|
34
|
+
numberIncomplete: string;
|
|
35
|
+
numberInvalid: string;
|
|
36
|
+
holderLabel: string;
|
|
37
|
+
holderRequired: string;
|
|
38
|
+
/**
|
|
39
|
+
* The expiry field's label, whose parenthetical spells the ORDER — "MM/AA"
|
|
40
|
+
* in Portuguese, "MM/YY" in English. Unaccented, so no diacritic scan ever
|
|
41
|
+
* flagged it, and getting it wrong makes a buyer type the year first.
|
|
42
|
+
*/
|
|
43
|
+
expiryLabel: string;
|
|
44
|
+
cvvLabel: string;
|
|
45
|
+
/** A month outside 1–12 — a typo, not an expiry. */
|
|
46
|
+
monthInvalid: string;
|
|
47
|
+
/** A well-formed date that has already passed. */
|
|
48
|
+
expired: string;
|
|
49
|
+
/**
|
|
50
|
+
* Either of the two, from a tokenizer that cannot tell them apart: it reads
|
|
51
|
+
* the expiry as one string and only learns it is unusable.
|
|
52
|
+
*/
|
|
53
|
+
expiryInvalid: string;
|
|
54
|
+
cvvInvalid: string;
|
|
55
|
+
/**
|
|
56
|
+
* The CVV is the wrong length for the detected network — Amex wants four,
|
|
57
|
+
* everyone else three.
|
|
58
|
+
*
|
|
59
|
+
* A function, because where the number sits in the sentence is the
|
|
60
|
+
* translator's call, and some languages agree the noun with it.
|
|
61
|
+
*/
|
|
62
|
+
cvvDigits(length: number): string;
|
|
63
|
+
cpfRequired: string;
|
|
64
|
+
cpfInvalid: string;
|
|
65
|
+
/** The saved-cards radio group's own label. */
|
|
66
|
+
savedCardsLabel: string;
|
|
67
|
+
/** The trailing option that opens the new-card form. */
|
|
68
|
+
newCard: string;
|
|
69
|
+
newCardDescription: string;
|
|
70
|
+
/** The "keep this card" checkbox. */
|
|
71
|
+
saveCard: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** What can go wrong between "Pagar" and a token, in the buyer's words. */
|
|
75
|
+
export interface CardTokenizeCopy {
|
|
76
|
+
/** The provider's browser SDK never loaded. */
|
|
77
|
+
sdkUnavailable: string;
|
|
78
|
+
/** The SDK loaded and refused the card data it was handed. */
|
|
79
|
+
cardNotProcessed: string;
|
|
80
|
+
/** We could not reach the tokenizing provider at all. */
|
|
81
|
+
providerUnreachable: string;
|
|
82
|
+
/**
|
|
83
|
+
* The tokenizer was reached and simply did not answer in time.
|
|
84
|
+
*
|
|
85
|
+
* Separate from unreachable because the buyer's next move differs: an
|
|
86
|
+
* outage is worth retrying now, a timeout usually means trying the other
|
|
87
|
+
* method. Unaccented in pt-BR, so only a reading found it.
|
|
88
|
+
*/
|
|
89
|
+
providerTimedOut: string;
|
|
90
|
+
/**
|
|
91
|
+
* The provider answered, refusing the card's details.
|
|
92
|
+
*
|
|
93
|
+
* Takes the status AND the provider's own truncated answer, because both
|
|
94
|
+
* are what a human reads back to support — this failure surfaces on the
|
|
95
|
+
* activation screen, and carrying the raw response there is deliberate.
|
|
96
|
+
* Where either sits in the sentence is the translator's call.
|
|
97
|
+
*/
|
|
98
|
+
providerRefused(status: number, response: string): string;
|
|
99
|
+
/**
|
|
100
|
+
* This store has no card-encryption key, so the browser cannot encrypt
|
|
101
|
+
* anything. A configuration fault, phrased for the person who hit it.
|
|
102
|
+
*/
|
|
103
|
+
noPublicKey: string;
|
|
104
|
+
/**
|
|
105
|
+
* Card is off for this store entirely — the sentence that has to offer the
|
|
106
|
+
* buyer somewhere else to go, because there is nothing they can retry.
|
|
107
|
+
*/
|
|
108
|
+
cardUnavailable: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Both halves, for a host wiring the card form in one place. */
|
|
112
|
+
export interface CardCopy {
|
|
113
|
+
fields: CardFieldCopy;
|
|
114
|
+
tokenize: CardTokenizeCopy;
|
|
115
|
+
}
|
package/src/card/cpf.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { CardFieldCopy } from "./copy";
|
|
1
2
|
import { onlyDigits } from "./format";
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -19,8 +20,14 @@ export function formatCpf(value: string): string {
|
|
|
19
20
|
return out;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
/**
|
|
23
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Verify the two CPF check digits (rejects all-same-digit sequences too).
|
|
25
|
+
*
|
|
26
|
+
* Exported as the PREDICATE beside {@link validateCpf}'s sentence, because a
|
|
27
|
+
* caller that only needs the verdict should not have to hold the words to get
|
|
28
|
+
* it — `buyer-fields.ts` gates a field on this and threw the message away.
|
|
29
|
+
*/
|
|
30
|
+
export function isValidCpf(value: string): boolean {
|
|
24
31
|
const d = onlyDigits(value);
|
|
25
32
|
if (d.length !== 11) return false;
|
|
26
33
|
if (/^(\d)\1{10}$/.test(d)) return false;
|
|
@@ -36,7 +43,7 @@ function isValidCpf(value: string): boolean {
|
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
/** Field validator: returns an error message, or `undefined` when valid. */
|
|
39
|
-
export function validateCpf(value: string): string | undefined {
|
|
40
|
-
if (!onlyDigits(value)) return
|
|
41
|
-
return isValidCpf(value) ? undefined :
|
|
46
|
+
export function validateCpf(value: string, copy: CardFieldCopy): string | undefined {
|
|
47
|
+
if (!onlyDigits(value)) return copy.cpfRequired;
|
|
48
|
+
return isValidCpf(value) ? undefined : copy.cpfInvalid;
|
|
42
49
|
}
|
package/src/card/fields.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Box } from "@mui/material";
|
|
2
2
|
import type { JSX } from "react";
|
|
3
3
|
|
|
4
|
+
import { useCheckoutCopy } from "../components/checkout/copy-context";
|
|
4
5
|
import { useCheckoutComponents } from "../components/checkout/ui";
|
|
5
6
|
|
|
6
7
|
import {
|
|
@@ -30,9 +31,10 @@ export function SavedCardsPicker({
|
|
|
30
31
|
onSelect: (id: string) => void;
|
|
31
32
|
}): JSX.Element {
|
|
32
33
|
const { RadioGroup } = useCheckoutComponents();
|
|
34
|
+
const copy = useCheckoutCopy().card.fields;
|
|
33
35
|
return (
|
|
34
36
|
<RadioGroup
|
|
35
|
-
label=
|
|
37
|
+
label={copy.savedCardsLabel}
|
|
36
38
|
value={selection}
|
|
37
39
|
onChange={(_event, next) => onSelect(next)}
|
|
38
40
|
options={[
|
|
@@ -41,7 +43,7 @@ export function SavedCardsPicker({
|
|
|
41
43
|
label: `${saved.brand} •••• ${saved.last4}`,
|
|
42
44
|
description: `Validade ${String(saved.expMonth).padStart(2, "0")}/${saved.expYear}`,
|
|
43
45
|
})),
|
|
44
|
-
{ value: NEW_CARD, label:
|
|
46
|
+
{ value: NEW_CARD, label: copy.newCard, description: copy.newCardDescription },
|
|
45
47
|
]}
|
|
46
48
|
dataTestId="saved-cards"
|
|
47
49
|
/>
|
|
@@ -63,9 +65,10 @@ function CardNumberInput({
|
|
|
63
65
|
setFieldErrors: SetErrors;
|
|
64
66
|
}): JSX.Element {
|
|
65
67
|
const { Input, Text } = useCheckoutComponents();
|
|
68
|
+
const copy = useCheckoutCopy().card.fields;
|
|
66
69
|
return (
|
|
67
70
|
<Input
|
|
68
|
-
label=
|
|
71
|
+
label={copy.numberLabel}
|
|
69
72
|
type="text"
|
|
70
73
|
inputMode="numeric"
|
|
71
74
|
variant="outlined"
|
|
@@ -77,14 +80,14 @@ function CardNumberInput({
|
|
|
77
80
|
error={Boolean(fieldErrors.number)}
|
|
78
81
|
helperText={fieldErrors.number}
|
|
79
82
|
endAdornment={
|
|
80
|
-
brand !== "
|
|
83
|
+
brand !== "Unknown" ? (
|
|
81
84
|
<Text variant="caption" size="xs" color="secondary" as="span">
|
|
82
85
|
{brand}
|
|
83
86
|
</Text>
|
|
84
87
|
) : undefined
|
|
85
88
|
}
|
|
86
89
|
onChange={(event) => setCard((prev) => ({ ...prev, number: formatCardNumber(event.target.value) }))}
|
|
87
|
-
onBlur={() => setFieldErrors((prev) => ({ ...prev, number: validateCardNumber(card.number) }))}
|
|
90
|
+
onBlur={() => setFieldErrors((prev) => ({ ...prev, number: validateCardNumber(card.number, copy) }))}
|
|
88
91
|
data-testid="card-number"
|
|
89
92
|
/>
|
|
90
93
|
);
|
|
@@ -105,10 +108,11 @@ function ExpiryCvvFields({
|
|
|
105
108
|
setFieldErrors: SetErrors;
|
|
106
109
|
}): JSX.Element {
|
|
107
110
|
const { Input } = useCheckoutComponents();
|
|
111
|
+
const copy = useCheckoutCopy().card.fields;
|
|
108
112
|
return (
|
|
109
113
|
<Box sx={{ display: "flex", gap: 2 }}>
|
|
110
114
|
<Input
|
|
111
|
-
label=
|
|
115
|
+
label={copy.expiryLabel}
|
|
112
116
|
type="text"
|
|
113
117
|
inputMode="numeric"
|
|
114
118
|
placeholder="MM/AA"
|
|
@@ -120,11 +124,11 @@ function ExpiryCvvFields({
|
|
|
120
124
|
error={Boolean(fieldErrors.expiry)}
|
|
121
125
|
helperText={fieldErrors.expiry}
|
|
122
126
|
onChange={(event) => setCard((prev) => ({ ...prev, expiry: formatExpiry(event.target.value) }))}
|
|
123
|
-
onBlur={() => setFieldErrors((prev) => ({ ...prev, expiry: validateExpiry(card.expiry) }))}
|
|
127
|
+
onBlur={() => setFieldErrors((prev) => ({ ...prev, expiry: validateExpiry(card.expiry, copy) }))}
|
|
124
128
|
data-testid="card-expiry"
|
|
125
129
|
/>
|
|
126
130
|
<Input
|
|
127
|
-
label=
|
|
131
|
+
label={copy.cvvLabel}
|
|
128
132
|
type="text"
|
|
129
133
|
inputMode="numeric"
|
|
130
134
|
variant="outlined"
|
|
@@ -136,7 +140,7 @@ function ExpiryCvvFields({
|
|
|
136
140
|
error={Boolean(fieldErrors.cvv)}
|
|
137
141
|
helperText={fieldErrors.cvv}
|
|
138
142
|
onChange={(event) => setCard((prev) => ({ ...prev, cvv: formatCvv(event.target.value) }))}
|
|
139
|
-
onBlur={() => setFieldErrors((prev) => ({ ...prev, cvv: validateCvv(card.cvv, brand) }))}
|
|
143
|
+
onBlur={() => setFieldErrors((prev) => ({ ...prev, cvv: validateCvv(card.cvv, copy, brand) }))}
|
|
140
144
|
data-testid="card-cvv"
|
|
141
145
|
/>
|
|
142
146
|
</Box>
|
|
@@ -169,6 +173,7 @@ export function NewCardForm({
|
|
|
169
173
|
onSaveCardChange?: (checked: boolean) => void;
|
|
170
174
|
}): JSX.Element {
|
|
171
175
|
const { Input, Checkbox } = useCheckoutComponents();
|
|
176
|
+
const copy = useCheckoutCopy().card.fields;
|
|
172
177
|
return (
|
|
173
178
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
|
174
179
|
<CardNumberInput
|
|
@@ -179,7 +184,7 @@ export function NewCardForm({
|
|
|
179
184
|
setFieldErrors={setFieldErrors}
|
|
180
185
|
/>
|
|
181
186
|
<Input
|
|
182
|
-
label=
|
|
187
|
+
label={copy.holderLabel}
|
|
183
188
|
type="text"
|
|
184
189
|
variant="outlined"
|
|
185
190
|
size="md"
|
|
@@ -189,7 +194,7 @@ export function NewCardForm({
|
|
|
189
194
|
error={Boolean(fieldErrors.holder)}
|
|
190
195
|
helperText={fieldErrors.holder}
|
|
191
196
|
onChange={(event) => setCard((prev) => ({ ...prev, holder: event.target.value }))}
|
|
192
|
-
onBlur={() => setFieldErrors((prev) => ({ ...prev, holder: validateHolder(card.holder) }))}
|
|
197
|
+
onBlur={() => setFieldErrors((prev) => ({ ...prev, holder: validateHolder(card.holder, copy) }))}
|
|
193
198
|
data-testid="card-holder"
|
|
194
199
|
/>
|
|
195
200
|
<ExpiryCvvFields
|
|
@@ -201,7 +206,7 @@ export function NewCardForm({
|
|
|
201
206
|
/>
|
|
202
207
|
{onSaveCardChange ? (
|
|
203
208
|
<Checkbox
|
|
204
|
-
label=
|
|
209
|
+
label={copy.saveCard}
|
|
205
210
|
checked={saveCard}
|
|
206
211
|
onChange={(_event, checked) => onSaveCardChange(checked)}
|
|
207
212
|
data-testid="save-card"
|
package/src/card/format.ts
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
|
+
import type { CardBrand, CardFieldCopy } from "./copy";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Client-side card formatting + validation (FUT-58).
|
|
3
5
|
*
|
|
4
|
-
* Pure helpers
|
|
5
|
-
*
|
|
6
|
-
*
|
|
6
|
+
* Pure helpers — the card form uses them to format input live and to validate
|
|
7
|
+
* before tokenizing. They never transmit anything; the PAN is only checked
|
|
8
|
+
* locally (Luhn + brand + length) then handed to the tokenizer.
|
|
9
|
+
*
|
|
10
|
+
* The validators take the host's words (FUT-760). WHICH refusal a value earns
|
|
11
|
+
* is the rule, and it stays here; the sentence is not ours.
|
|
7
12
|
*/
|
|
8
13
|
|
|
9
|
-
export type CardBrand
|
|
14
|
+
export type { CardBrand } from "./copy";
|
|
10
15
|
|
|
11
16
|
/** Strip everything but digits. */
|
|
12
17
|
export function onlyDigits(value: string): string {
|
|
@@ -19,7 +24,7 @@ export function detectBrand(digits: string): CardBrand {
|
|
|
19
24
|
if (/^(5[1-5]|2[2-7])/.test(digits)) return "Mastercard";
|
|
20
25
|
if (/^3[47]/.test(digits)) return "Amex";
|
|
21
26
|
if (/^6/.test(digits)) return "Elo";
|
|
22
|
-
return "
|
|
27
|
+
return "Unknown";
|
|
23
28
|
}
|
|
24
29
|
|
|
25
30
|
/** Expected CVV length for a brand (Amex uses 4, everyone else 3). */
|
|
@@ -71,33 +76,44 @@ function luhnValid(digits: string): boolean {
|
|
|
71
76
|
}
|
|
72
77
|
|
|
73
78
|
/** @returns an error message, or `undefined` when valid. */
|
|
74
|
-
export function validateCardNumber(
|
|
79
|
+
export function validateCardNumber(
|
|
80
|
+
value: string,
|
|
81
|
+
copy: CardFieldCopy,
|
|
82
|
+
): string | undefined {
|
|
75
83
|
const digits = onlyDigits(value);
|
|
76
|
-
if (!digits) return
|
|
77
|
-
if (digits.length < 13) return
|
|
78
|
-
if (!luhnValid(digits)) return
|
|
84
|
+
if (!digits) return copy.numberRequired;
|
|
85
|
+
if (digits.length < 13) return copy.numberIncomplete;
|
|
86
|
+
if (!luhnValid(digits)) return copy.numberInvalid;
|
|
79
87
|
return undefined;
|
|
80
88
|
}
|
|
81
89
|
|
|
82
|
-
export function validateHolder(value: string): string | undefined {
|
|
83
|
-
return value.trim().length < 2 ?
|
|
90
|
+
export function validateHolder(value: string, copy: CardFieldCopy): string | undefined {
|
|
91
|
+
return value.trim().length < 2 ? copy.holderRequired : undefined;
|
|
84
92
|
}
|
|
85
93
|
|
|
86
|
-
export function validateExpiry(
|
|
94
|
+
export function validateExpiry(
|
|
95
|
+
value: string,
|
|
96
|
+
copy: CardFieldCopy,
|
|
97
|
+
now: Date = new Date(),
|
|
98
|
+
): string | undefined {
|
|
87
99
|
const match = /^(\d{2})\/(\d{2})$/.exec(value.trim());
|
|
88
100
|
if (!match) return "Validade incompleta (MM/AA).";
|
|
89
101
|
const mm = match[1];
|
|
90
102
|
const yy = match[2];
|
|
91
103
|
if (mm === undefined || yy === undefined) return "Validade incompleta (MM/AA).";
|
|
92
104
|
const month = Number(mm);
|
|
93
|
-
if (month < 1 || month > 12) return
|
|
105
|
+
if (month < 1 || month > 12) return copy.monthInvalid;
|
|
94
106
|
const endOfMonth = new Date(2000 + Number(yy), month, 0, 23, 59, 59, 999);
|
|
95
|
-
return endOfMonth.getTime() < now.getTime() ?
|
|
107
|
+
return endOfMonth.getTime() < now.getTime() ? copy.expired : undefined;
|
|
96
108
|
}
|
|
97
109
|
|
|
98
|
-
export function validateCvv(
|
|
110
|
+
export function validateCvv(
|
|
111
|
+
value: string,
|
|
112
|
+
copy: CardFieldCopy,
|
|
113
|
+
brand: CardBrand = "Unknown",
|
|
114
|
+
): string | undefined {
|
|
99
115
|
const digits = onlyDigits(value);
|
|
100
116
|
if (!digits) return "Informe o CVV.";
|
|
101
117
|
const len = cvvLength(brand);
|
|
102
|
-
return digits.length === len ? undefined :
|
|
118
|
+
return digits.length === len ? undefined : copy.cvvDigits(len);
|
|
103
119
|
}
|
package/src/card/index.ts
CHANGED
|
@@ -27,7 +27,7 @@ export {
|
|
|
27
27
|
type CardBrand,
|
|
28
28
|
} from "./format";
|
|
29
29
|
|
|
30
|
-
export { formatCpf, validateCpf } from "./cpf";
|
|
30
|
+
export { formatCpf, isValidCpf, validateCpf } from "./cpf";
|
|
31
31
|
|
|
32
32
|
export { CardPayBar, NewCardForm, SavedCardsPicker } from "./fields";
|
|
33
33
|
|
|
@@ -40,3 +40,5 @@ export {
|
|
|
40
40
|
} from "./tokenize";
|
|
41
41
|
|
|
42
42
|
export { NEW_CARD, type CardDetails, type CardFieldErrors, type CardToken, type SavedCard } from "./types";
|
|
43
|
+
export type { CardCopy, CardFieldCopy, CardTokenizeCopy } from "./copy";
|
|
44
|
+
export { PT_BR_CARD_COPY } from "./pt-BR";
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { CardCopy } from './copy';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The pt-BR pack for the card form — a NAMED constant a host passes by hand,
|
|
5
|
+
* never a default.
|
|
6
|
+
*
|
|
7
|
+
* The filename is what exempts this file from the copy-portability gate:
|
|
8
|
+
* Portuguese may ship, it may not be silent. Every sentence is VERBATIM what
|
|
9
|
+
* the form used to render, so a host adopting it sees no change on screen —
|
|
10
|
+
* what changes is that the words are chosen in a diff.
|
|
11
|
+
*/
|
|
12
|
+
export const PT_BR_CARD_COPY: CardCopy = {
|
|
13
|
+
fields: {
|
|
14
|
+
unknownBrand: 'Cartão',
|
|
15
|
+
numberLabel: 'Número do cartão',
|
|
16
|
+
numberRequired: 'Informe o número do cartão.',
|
|
17
|
+
numberIncomplete: 'Número de cartão incompleto.',
|
|
18
|
+
numberInvalid: 'Número de cartão inválido.',
|
|
19
|
+
holderLabel: 'Nome impresso no cartão',
|
|
20
|
+
holderRequired: 'Informe o nome impresso no cartão.',
|
|
21
|
+
expiryLabel: 'Validade (MM/AA)',
|
|
22
|
+
cvvLabel: 'CVV',
|
|
23
|
+
monthInvalid: 'Mês inválido.',
|
|
24
|
+
expired: 'Cartão expirado.',
|
|
25
|
+
expiryInvalid: 'Validade inválida ou expirada.',
|
|
26
|
+
cvvInvalid: 'CVV inválido.',
|
|
27
|
+
cvvDigits: (length) => `CVV deve ter ${length} dígitos.`,
|
|
28
|
+
cpfRequired: 'CPF obrigatório.',
|
|
29
|
+
cpfInvalid: 'CPF inválido.',
|
|
30
|
+
savedCardsLabel: 'Cartão',
|
|
31
|
+
newCard: 'Novo cartão',
|
|
32
|
+
newCardDescription: 'Inserir outro cartão',
|
|
33
|
+
saveCard: 'Salvar cartão para próximas compras',
|
|
34
|
+
},
|
|
35
|
+
tokenize: {
|
|
36
|
+
sdkUnavailable: 'Não foi possível carregar o meio de pagamento. Recarregue a página.',
|
|
37
|
+
cardNotProcessed:
|
|
38
|
+
'Não foi possível processar o cartão. Verifique os dados e tente novamente.',
|
|
39
|
+
providerUnreachable:
|
|
40
|
+
'Não foi possível contatar o provedor do cartão. Verifique sua conexão.',
|
|
41
|
+
providerTimedOut: 'O provedor do cartão não respondeu a tempo.',
|
|
42
|
+
providerRefused: (status, response) =>
|
|
43
|
+
`O provedor recusou os dados do cartão (HTTP ${status}). ` +
|
|
44
|
+
`Resposta: ${response}`,
|
|
45
|
+
noPublicKey:
|
|
46
|
+
'A chave pública do cartão não está disponível para esta loja. ' +
|
|
47
|
+
'Reconecte o provedor e tente novamente.',
|
|
48
|
+
cardUnavailable:
|
|
49
|
+
'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.',
|
|
50
|
+
},
|
|
51
|
+
};
|
package/src/card/stripe-token.ts
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import { err, ok, type Result } from "../result";
|
|
20
20
|
|
|
21
21
|
import type { CardDetails, CardToken } from "./types";
|
|
22
|
+
import type { CardCopy } from "./copy";
|
|
22
23
|
|
|
23
24
|
/** Stripe payment-methods endpoint — the publishable key authenticates. */
|
|
24
25
|
const STRIPE_PAYMENT_METHODS_URL = "https://api.stripe.com/v1/payment_methods";
|
|
@@ -37,11 +38,12 @@ export async function tokenizeWithStripe(
|
|
|
37
38
|
publicKey: string,
|
|
38
39
|
brand: string,
|
|
39
40
|
last4: string,
|
|
41
|
+
copy: CardCopy,
|
|
40
42
|
/** Deadline for the round trip; an abort reads as "could not contact". */
|
|
41
43
|
signal?: AbortSignal,
|
|
42
44
|
): Promise<Result<CardToken>> {
|
|
43
45
|
const match = /^(\d{2})\/(\d{2})$/.exec(card.expiry.trim());
|
|
44
|
-
if (!match) return err(
|
|
46
|
+
if (!match) return err(copy.fields.expiryInvalid);
|
|
45
47
|
|
|
46
48
|
// Stripe's API speaks form-encoding, not JSON.
|
|
47
49
|
const body = new URLSearchParams({
|
|
@@ -65,7 +67,7 @@ export async function tokenizeWithStripe(
|
|
|
65
67
|
signal,
|
|
66
68
|
});
|
|
67
69
|
} catch {
|
|
68
|
-
return err(
|
|
70
|
+
return err(copy.tokenize.providerUnreachable);
|
|
69
71
|
}
|
|
70
72
|
|
|
71
73
|
const parsed = (await response.json().catch(() => null)) as {
|
|
@@ -75,10 +77,7 @@ export async function tokenizeWithStripe(
|
|
|
75
77
|
if (!response.ok || typeof parsed?.id !== "string") {
|
|
76
78
|
const reason =
|
|
77
79
|
typeof parsed?.error?.message === "string" ? parsed.error.message : JSON.stringify(parsed);
|
|
78
|
-
return err(
|
|
79
|
-
`O provedor recusou os dados do cartão (HTTP ${response.status}). ` +
|
|
80
|
-
`Resposta: ${String(reason).slice(0, 300)}`,
|
|
81
|
-
);
|
|
80
|
+
return err(copy.tokenize.providerRefused(response.status, String(reason).slice(0, 300)));
|
|
82
81
|
}
|
|
83
82
|
return ok({ token: parsed.id, brand, last4 });
|
|
84
83
|
}
|