@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.
Files changed (72) hide show
  1. package/package.json +2 -2
  2. package/src/activation/charge-copy.ts +10 -0
  3. package/src/activation/use-activation-charge.ts +10 -8
  4. package/src/card/copy.ts +115 -0
  5. package/src/card/cpf.ts +12 -5
  6. package/src/card/fields.tsx +17 -12
  7. package/src/card/format.ts +32 -16
  8. package/src/card/index.ts +3 -1
  9. package/src/card/pt-BR.ts +51 -0
  10. package/src/card/stripe-token.ts +5 -6
  11. package/src/card/tokenize.ts +28 -32
  12. package/src/components/CheckoutPayment.tsx +37 -201
  13. package/src/components/ConfirmCredentialSave.tsx +8 -10
  14. package/src/components/ConnectionCard.tsx +23 -18
  15. package/src/components/ConnectionProbe.tsx +3 -1
  16. package/src/components/CredentialFieldStack.tsx +8 -2
  17. package/src/components/CredentialFields.tsx +13 -6
  18. package/src/components/CredentialFormAlerts.tsx +28 -19
  19. package/src/components/EnvironmentTabs.tsx +23 -15
  20. package/src/components/OAuthPanel.tsx +5 -6
  21. package/src/components/PaymentProviderSettings.tsx +45 -13
  22. package/src/components/ProviderConnection.tsx +37 -28
  23. package/src/components/ProviderList.tsx +6 -3
  24. package/src/components/ProviderPriorityList.tsx +18 -16
  25. package/src/components/ProviderStatusBar.tsx +24 -24
  26. package/src/components/SetupGuideSection.tsx +15 -6
  27. package/src/components/checkout/apple-pay-button.tsx +19 -9
  28. package/src/components/checkout/buyer-fields.ts +5 -3
  29. package/src/components/checkout/buyer-gate.ts +68 -0
  30. package/src/components/checkout/buyer-info-form.tsx +14 -15
  31. package/src/components/checkout/card-instruments.ts +31 -12
  32. package/src/components/checkout/card-view.tsx +11 -7
  33. package/src/components/checkout/checkout-flow.tsx +4 -1
  34. package/src/components/checkout/checkout-steps.tsx +3 -1
  35. package/src/components/checkout/client-context.tsx +28 -16
  36. package/src/components/checkout/client.ts +43 -16
  37. package/src/components/checkout/copy-context.tsx +83 -0
  38. package/src/components/checkout/google-pay-button.tsx +19 -7
  39. package/src/components/checkout/method-picker.tsx +34 -10
  40. package/src/components/checkout/payer-summary.tsx +8 -6
  41. package/src/components/checkout/payment-error-panel.tsx +8 -5
  42. package/src/components/checkout/pix-view.tsx +16 -9
  43. package/src/components/checkout/providers/hosted-link.tsx +26 -16
  44. package/src/components/checkout/pt-BR.ts +27 -0
  45. package/src/components/checkout/screens-copy.ts +201 -0
  46. package/src/components/checkout/screens-pt-BR.ts +101 -0
  47. package/src/components/checkout/transport.ts +34 -8
  48. package/src/components/checkout/use-card-checkout.ts +13 -5
  49. package/src/components/checkout/use-checkout-controller.ts +5 -48
  50. package/src/components/checkout/view-copy.ts +13 -0
  51. package/src/components/checkout/wallet-pane.tsx +13 -8
  52. package/src/components/checkout-payment-copy.ts +88 -0
  53. package/src/components/checkout-payment-pt-BR.ts +50 -0
  54. package/src/components/checkout-payment-types.ts +25 -0
  55. package/src/components/checkout-payment-views.tsx +225 -0
  56. package/src/components/connection-state.ts +22 -10
  57. package/src/components/credential-rules.ts +8 -3
  58. package/src/components/platform/ConnectApplicationPanel.tsx +6 -2
  59. package/src/components/platform/ConnectEnvironmentCard.tsx +19 -15
  60. package/src/components/platform/HomologacaoGuideCard.tsx +23 -19
  61. package/src/components/platform/HomologacaoOutcomeCard.tsx +24 -16
  62. package/src/components/platform/PlatformHomologacao.tsx +12 -8
  63. package/src/components/settings-copy-context.tsx +50 -0
  64. package/src/components/settings-copy.ts +304 -0
  65. package/src/components/settings-pt-BR.ts +169 -0
  66. package/src/flows/copy.ts +12 -0
  67. package/src/flows/create-payment-flows.tsx +8 -1
  68. package/src/flows/runtime.tsx +12 -4
  69. package/src/flows/screens-vault.tsx +10 -10
  70. package/src/flows/types.ts +5 -2
  71. package/src/flows/use-add-card.ts +10 -5
  72. 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.8.0",
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.16.0",
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;
@@ -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
- /** Verify the two CPF check digits (rejects all-same-digit sequences too). */
23
- function isValidCpf(value: string): boolean {
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 "CPF obrigatório.";
41
- return isValidCpf(value) ? undefined : "CPF inválido.";
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
  }
@@ -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="Cartão"
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: "Novo cartão", description: "Inserir outro cartão" },
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="Número do cartão"
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 !== "Cartão" ? (
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="Validade (MM/AA)"
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="CVV"
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="Nome impresso no cartão"
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="Salvar cartão para próximas compras"
209
+ label={copy.saveCard}
205
210
  checked={saveCard}
206
211
  onChange={(_event, checked) => onSaveCardChange(checked)}
207
212
  data-testid="save-card"
@@ -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 with no imports — the card form uses them to format input live and
5
- * to validate before tokenizing. They never transmit anything; the PAN is only
6
- * checked locally (Luhn + brand + length) then handed to the tokenizer.
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 = "Visa" | "Mastercard" | "Amex" | "Elo" | "Cartão";
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 "Cartão";
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(value: string): string | undefined {
79
+ export function validateCardNumber(
80
+ value: string,
81
+ copy: CardFieldCopy,
82
+ ): string | undefined {
75
83
  const digits = onlyDigits(value);
76
- if (!digits) return "Informe o número do cartão.";
77
- if (digits.length < 13) return "Número de cartão incompleto.";
78
- if (!luhnValid(digits)) return "Número de cartão inválido.";
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 ? "Informe o nome impresso no cartão." : undefined;
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(value: string, now: Date = new Date()): string | undefined {
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 "Mês inválido.";
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() ? "Cartão expirado." : undefined;
107
+ return endOfMonth.getTime() < now.getTime() ? copy.expired : undefined;
96
108
  }
97
109
 
98
- export function validateCvv(value: string, brand: CardBrand = "Cartão"): string | undefined {
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 : `CVV deve ter ${len} dígitos.`;
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
+ };
@@ -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("Validade inválida ou expirada.");
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("Não foi possível contatar o provedor do cartão. Verifique sua conexão.");
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
  }