@12-apps/payments-frontend 3.9.0 → 3.11.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 (73) 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 +117 -0
  5. package/src/card/cpf.ts +12 -5
  6. package/src/card/fields.tsx +17 -12
  7. package/src/card/format.ts +33 -17
  8. package/src/card/index.ts +3 -1
  9. package/src/card/pt-BR.ts +52 -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 +24 -19
  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/ProviderSetupGuide.tsx +3 -1
  26. package/src/components/ProviderStatusBar.tsx +24 -24
  27. package/src/components/SetupGuideSection.tsx +15 -6
  28. package/src/components/checkout/apple-pay-button.tsx +19 -9
  29. package/src/components/checkout/buyer-fields.ts +5 -3
  30. package/src/components/checkout/buyer-gate.ts +68 -0
  31. package/src/components/checkout/buyer-info-form.tsx +14 -15
  32. package/src/components/checkout/card-instruments.ts +31 -12
  33. package/src/components/checkout/card-view.tsx +11 -7
  34. package/src/components/checkout/checkout-flow.tsx +4 -1
  35. package/src/components/checkout/checkout-steps.tsx +3 -1
  36. package/src/components/checkout/client-context.tsx +28 -16
  37. package/src/components/checkout/client.ts +43 -16
  38. package/src/components/checkout/copy-context.tsx +83 -0
  39. package/src/components/checkout/google-pay-button.tsx +19 -7
  40. package/src/components/checkout/method-picker.tsx +34 -10
  41. package/src/components/checkout/payer-summary.tsx +8 -6
  42. package/src/components/checkout/payment-error-panel.tsx +8 -5
  43. package/src/components/checkout/pix-view.tsx +16 -9
  44. package/src/components/checkout/providers/hosted-link.tsx +26 -16
  45. package/src/components/checkout/pt-BR.ts +27 -0
  46. package/src/components/checkout/screens-copy.ts +201 -0
  47. package/src/components/checkout/screens-pt-BR.ts +101 -0
  48. package/src/components/checkout/transport.ts +34 -8
  49. package/src/components/checkout/use-card-checkout.ts +13 -5
  50. package/src/components/checkout/use-checkout-controller.ts +5 -48
  51. package/src/components/checkout/view-copy.ts +13 -0
  52. package/src/components/checkout/wallet-pane.tsx +13 -8
  53. package/src/components/checkout-payment-copy.ts +88 -0
  54. package/src/components/checkout-payment-pt-BR.ts +50 -0
  55. package/src/components/checkout-payment-types.ts +25 -0
  56. package/src/components/checkout-payment-views.tsx +225 -0
  57. package/src/components/connection-state.ts +22 -10
  58. package/src/components/credential-rules.ts +8 -3
  59. package/src/components/platform/ConnectApplicationPanel.tsx +6 -2
  60. package/src/components/platform/ConnectEnvironmentCard.tsx +19 -15
  61. package/src/components/platform/HomologacaoGuideCard.tsx +23 -19
  62. package/src/components/platform/HomologacaoOutcomeCard.tsx +24 -16
  63. package/src/components/platform/PlatformHomologacao.tsx +12 -8
  64. package/src/components/settings-copy-context.tsx +50 -0
  65. package/src/components/settings-copy.ts +312 -0
  66. package/src/components/settings-pt-BR.ts +172 -0
  67. package/src/flows/copy.ts +12 -0
  68. package/src/flows/create-payment-flows.tsx +8 -1
  69. package/src/flows/runtime.tsx +12 -4
  70. package/src/flows/screens-vault.tsx +10 -10
  71. package/src/flows/types.ts +5 -2
  72. package/src/flows/use-add-card.ts +10 -5
  73. package/src/index.ts +63 -0
@@ -0,0 +1,68 @@
1
+ /**
2
+ * What the "Continuar" gate objects to (FUT-595), split out of
3
+ * `use-checkout-controller.ts`.
4
+ *
5
+ * Its own module because the answer is a pure function of what the store's
6
+ * chain declared, the buyer's form, and the words a host chose — nothing in
7
+ * here touches React, a network or the controller's state machine, and the
8
+ * controller was over the file-size gate carrying it.
9
+ */
10
+ import { buyerFormComplete } from "./buyer-info-form";
11
+ import type { CheckoutValidationCopy } from "./screens-copy";
12
+ import type { BuyerField, BuyerInfo, CheckoutCustomerField } from "./types";
13
+
14
+ /**
15
+ * The message shown when a declared field is missing or malformed — the HOST's
16
+ * words (FUT-760), keyed by the field the chain declared.
17
+ *
18
+ * Built from the copy rather than held as a module constant: a frozen table
19
+ * could only carry the origin host's Portuguese, and it would reach every
20
+ * adopter's buyer on the very first field they got wrong.
21
+ */
22
+ function fieldComplaints(copy: CheckoutValidationCopy): Record<string, string> {
23
+ return {
24
+ taxId: copy.taxIdInvalid,
25
+ name: copy.nameRequired,
26
+ email: copy.emailInvalid,
27
+ phone: copy.phoneInvalid,
28
+ };
29
+ }
30
+
31
+ /** Which input to highlight for a declared key. */
32
+ const FIELD_INPUT: Record<string, BuyerField> = {
33
+ taxId: "cpf",
34
+ name: "name",
35
+ email: "email",
36
+ phone: "phone",
37
+ };
38
+
39
+ /**
40
+ * What the "Continuar" gate objects to, or undefined to advance.
41
+ *
42
+ * The fields come from the chain's own declaration (FUT-595) — absent, they
43
+ * degrade to CPF-required, which is exactly what this gate has always demanded.
44
+ *
45
+ * A blank CPF is only an error when the store has NO CPF for this buyer. With
46
+ * one on file the field starts empty by design (the client is never sent the
47
+ * saved CPF), so demanding one here trapped a returning buyer who opened Dados
48
+ * through "Alterar" and changed their mind: they could not reach Pagamento
49
+ * again, and back only led out to the menu. Leaving it blank means "charge me
50
+ * as before", which is exactly what the server's `resolveBuyerTaxId` does.
51
+ */
52
+ export function buyerGateError(
53
+ copy: CheckoutValidationCopy,
54
+ buyer: BuyerInfo,
55
+ fields: readonly CheckoutCustomerField[],
56
+ taxIdOnFile: boolean,
57
+ ): { message: string; field: BuyerField } | undefined {
58
+ const effective = taxIdOnFile
59
+ ? fields.filter((field) => !(field.key === "taxId" && !buyer.taxId?.trim()))
60
+ : fields;
61
+ const offending = buyerFormComplete(buyer, effective);
62
+ if (!offending) return undefined;
63
+ return {
64
+ message: fieldComplaints(copy)[offending.key] ?? copy.required,
65
+ field: FIELD_INPUT[offending.key] ?? "cpf",
66
+ };
67
+ }
68
+
@@ -6,6 +6,8 @@ import { formatCpf, validateCpf } from "../../card";
6
6
  import { fieldSatisfied } from "./buyer-fields";
7
7
  import type { BuyerField, BuyerInfo, CheckoutCustomerField } from "./types";
8
8
  import { useCheckoutComponents } from "./ui";
9
+ import type { CardFieldCopy } from "../../card/copy";
10
+ import { useCheckoutCopy, type BuyerInfoCopy } from "./copy-context";
9
11
 
10
12
  const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
11
13
 
@@ -62,18 +64,20 @@ function deriveErrors(
62
64
  value: BuyerInfo,
63
65
  fieldError: FieldError,
64
66
  required: ReadonlySet<InputKey>,
67
+ copy: BuyerInfoCopy,
68
+ cardCopy: CardFieldCopy,
65
69
  ): BuyerFieldErrors {
66
70
  const override = (field: BuyerField): string | undefined =>
67
71
  fieldError?.field === field ? fieldError.message : undefined;
68
72
  const localEmail =
69
- value.email && !EMAIL_PATTERN.test(value.email) ? "E-mail inválido." : undefined;
73
+ value.email && !EMAIL_PATTERN.test(value.email) ? copy.emailInvalid : undefined;
70
74
  const missing = (key: InputKey, message: string): string | undefined =>
71
75
  required.has(key) && !valueOf(value, key).trim() ? message : undefined;
72
76
  return {
73
- cpf: override("cpf") ?? (value.taxId ? validateCpf(value.taxId) : undefined),
74
- email: override("email") ?? localEmail ?? missing("email", "E-mail obrigatório."),
75
- name: override("name") ?? missing("name", "Nome obrigatório."),
76
- phone: override("phone") ?? missing("phone", "Telefone obrigatório."),
77
+ cpf: override("cpf") ?? (value.taxId ? validateCpf(value.taxId, cardCopy) : undefined),
78
+ email: override("email") ?? localEmail ?? missing("email", copy.emailRequired),
79
+ name: override("name") ?? missing("name", copy.nameRequired),
80
+ phone: override("phone") ?? missing("phone", copy.phoneRequired),
77
81
  };
78
82
  }
79
83
 
@@ -82,17 +86,11 @@ function deriveErrors(
82
86
  * declared. It used to name the CPF unconditionally, which is wrong the moment
83
87
  * a store's provider wants something else (or nothing).
84
88
  */
85
- function instructionFor(required: ReadonlySet<InputKey>): string {
89
+ function instructionFor(required: ReadonlySet<InputKey>, copy: BuyerInfoCopy): string {
86
90
  const names = FIELD_ORDER.filter((key) => required.has(key)).map((key) =>
87
91
  key === "taxId" ? "CPF" : INPUTS[key].label.toLowerCase(),
88
92
  );
89
- if (names.length === 0) {
90
- return "Nome, e-mail e telefone são opcionais — usados apenas para o comprovante.";
91
- }
92
- return (
93
- `Informe seu ${names.join(", ")} (${names.length === 1 ? "obrigatório" : "obrigatórios"} ` +
94
- "para o pagamento). Os demais campos são opcionais — usados apenas para o comprovante."
95
- );
93
+ return copy.fieldsHint(names);
96
94
  }
97
95
 
98
96
  /** Which inputs to render, and which of them are required. */
@@ -184,12 +182,13 @@ export function BuyerInfoForm({
184
182
  }): JSX.Element {
185
183
  const { Text } = useCheckoutComponents();
186
184
  const { shown, required } = resolveShape(fields);
187
- const errors = deriveErrors(value, fieldError ?? null, required);
185
+ const copy = useCheckoutCopy();
186
+ const errors = deriveErrors(value, fieldError ?? null, required, copy.buyer, copy.card.fields);
188
187
 
189
188
  return (
190
189
  <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
191
190
  <Text variant="caption" size="xs" color="secondary" as="p">
192
- {instructionFor(required)}
191
+ {instructionFor(required, copy.buyer)}
193
192
  </Text>
194
193
 
195
194
  {shown.map((key) => (
@@ -7,16 +7,18 @@ import {
7
7
  } from "../../card";
8
8
  import { err, ok, type Result } from "../../result";
9
9
 
10
- import { refreshCardPublicKey } from "./client";
11
10
  import type { CardChainLink } from "./method-capability";
12
11
  import type { SavedCardMeta } from "./types";
12
+ import type { CardCopy } from "../../card/copy";
13
13
 
14
14
  /**
15
15
  * The order-scoped key refresh, as a parameter (FUT-741).
16
16
  *
17
17
  * The self-heal is a call to OUR OWN mount, so it has to go through whichever
18
- * transport the surrounding checkout was bound to. Defaulted to the unbound
19
- * module function, which is exactly what it always called.
18
+ * transport the surrounding checkout was bound to. Required since FUT-760: the
19
+ * default was the unbound module call, and that now needs words this module
20
+ * has no business choosing. Every real caller already passed the bound
21
+ * `client.refreshBrowserKey`.
20
22
  */
21
23
  export type RefreshBrowserKey = (input: {
22
24
  orderId: string;
@@ -51,15 +53,16 @@ async function tokenizeNewCard(
51
53
  orderId: string,
52
54
  onKeyRefreshed: (key: string) => void,
53
55
  refreshKey: RefreshBrowserKey,
56
+ copy: CardCopy,
54
57
  ): Promise<Result<CardToken>> {
55
- const first = await tokenizeForCheckout(card, config);
58
+ const first = await tokenizeForCheckout(card, config, copy);
56
59
  if (first.ok || !config.publicKey) return first;
57
60
  if (config.provider === null || tokenizerFor(config.provider) !== "pagbank-sdk") return first;
58
61
 
59
62
  const refreshed = await refreshKey({ orderId });
60
63
  if (refreshed.ok && refreshed.data.publicKey && refreshed.data.publicKey !== config.publicKey) {
61
64
  onKeyRefreshed(refreshed.data.publicKey);
62
- return tokenizeForCheckout(card, { ...config, publicKey: refreshed.data.publicKey });
65
+ return tokenizeForCheckout(card, { ...config, publicKey: refreshed.data.publicKey }, copy);
63
66
  }
64
67
  return first;
65
68
  }
@@ -92,6 +95,7 @@ async function mintChainInstruments(
92
95
  card: CardDetails,
93
96
  chain: readonly CardChainLink[],
94
97
  timeoutMs: number,
98
+ copy: CardCopy,
95
99
  ): Promise<Record<string, CardToken>> {
96
100
  const results = await Promise.all(
97
101
  chain.map(async (link) => {
@@ -100,7 +104,7 @@ async function mintChainInstruments(
100
104
  // stub mode and an error everywhere else. It still travels in `chain`,
101
105
  // because the walk will reach it.
102
106
  if (!link.provider || !link.mintable) return null;
103
- const tokenized = await mintWithDeadline(card, link, timeoutMs);
107
+ const tokenized = await mintWithDeadline(card, link, timeoutMs, copy);
104
108
  return tokenized.ok ? ([link.provider, tokenized.data] as const) : null;
105
109
  }),
106
110
  );
@@ -125,6 +129,7 @@ async function mintWithDeadline(
125
129
  card: CardDetails,
126
130
  link: CardChainLink,
127
131
  timeoutMs: number,
132
+ copy: CardCopy,
128
133
  ): Promise<Result<CardToken>> {
129
134
  const controller = new AbortController();
130
135
  let timer: ReturnType<typeof setTimeout> | undefined;
@@ -133,11 +138,14 @@ async function mintWithDeadline(
133
138
  const deadline = new Promise<Result<CardToken>>((resolve) => {
134
139
  timer = setTimeout(() => {
135
140
  controller.abort();
136
- resolve(err("O provedor do cartão não respondeu a tempo."));
141
+ resolve(err(copy.tokenize.providerTimedOut));
137
142
  }, timeoutMs);
138
143
  });
139
144
  try {
140
- return await Promise.race([tokenizeForCheckout(card, link, controller.signal), deadline]);
145
+ return await Promise.race([
146
+ tokenizeForCheckout(card, link, copy, controller.signal),
147
+ deadline,
148
+ ]);
141
149
  } finally {
142
150
  clearTimeout(timer);
143
151
  }
@@ -197,12 +205,20 @@ async function mintEveryEntry(input: {
197
205
  onKeyRefreshed: (key: string) => void;
198
206
  refreshKey: RefreshBrowserKey;
199
207
  timeoutMs: number;
208
+ copy: CardCopy;
200
209
  }): Promise<{ headToken: Result<CardToken>; minted: Record<string, CardToken> }> {
201
210
  const head = mintingConfig(input.config, input.entries);
202
211
  const rest = input.entries.filter((link) => link.provider !== head.provider);
203
212
  const [headToken, tail] = await Promise.all([
204
- tokenizeNewCard(input.card, head, input.orderId, input.onKeyRefreshed, input.refreshKey),
205
- mintChainInstruments(input.card, rest, input.timeoutMs),
213
+ tokenizeNewCard(
214
+ input.card,
215
+ head,
216
+ input.orderId,
217
+ input.onKeyRefreshed,
218
+ input.refreshKey,
219
+ input.copy,
220
+ ),
221
+ mintChainInstruments(input.card, rest, input.timeoutMs, input.copy),
206
222
  ]);
207
223
  // A failure in the tail is not fatal: that provider is simply one the walk
208
224
  // will skip.
@@ -226,10 +242,12 @@ export async function resolveNewCardToken(
226
242
  onKeyRefreshed: (key: string) => void,
227
243
  saveCard: boolean,
228
244
  chain: readonly CardChainLink[],
245
+ /** The words a failed mint reports with — the host's (FUT-760). */
246
+ copy: CardCopy,
247
+ /** The bound key refresh (FUT-741) — the surrounding checkout's transport. */
248
+ refreshKey: RefreshBrowserKey,
229
249
  /** Per-entry mint deadline. Overridable so tests need not wait it out. */
230
250
  timeoutMs: number = MINT_TIMEOUT_MS,
231
- /** The bound key refresh (FUT-741); defaults to the unbound module call. */
232
- refreshKey: RefreshBrowserKey = refreshCardPublicKey,
233
251
  ): Promise<Result<CardInstruments>> {
234
252
  // No chain served (an older host, or a fetch blip): the active provider
235
253
  // alone, exactly the pre-FUT-563 behaviour.
@@ -242,6 +260,7 @@ export async function resolveNewCardToken(
242
260
  onKeyRefreshed,
243
261
  refreshKey,
244
262
  timeoutMs,
263
+ copy,
245
264
  });
246
265
 
247
266
  // Refused only when NO entry could be minted for. While one still can, the
@@ -8,6 +8,7 @@ import {
8
8
  type CardTokenizationConfig,
9
9
  } from "../../card";
10
10
 
11
+ import { useCheckoutCopy } from "./copy-context";
11
12
  import { UNRESOLVED_CODE } from "./failure-codes";
12
13
  import type { CardChainLink } from "./method-capability";
13
14
  import type { BuyerInfo, CheckoutOrder, OrderStatus } from "./types";
@@ -28,11 +29,12 @@ function SubmittedState({
28
29
  pollTimedOut: boolean;
29
30
  }): JSX.Element {
30
31
  const { Alert, LoadingState } = useCheckoutComponents();
32
+ const copy = useCheckoutCopy().screens.settling;
31
33
  if (pollError) {
32
34
  return (
33
35
  <Alert
34
36
  variant="danger"
35
- title="Não foi possível confirmar o pagamento"
37
+ title={copy.cannotConfirm}
36
38
  description={pollError}
37
39
  showIcon
38
40
  data-testid="card-poll-error"
@@ -43,8 +45,8 @@ function SubmittedState({
43
45
  return (
44
46
  <Alert
45
47
  variant="warning"
46
- title="O pagamento está demorando mais que o esperado"
47
- description="Você pode aguardar ou verificar seu pedido em instantes — não realize um novo pagamento."
48
+ title={copy.takingLonger}
49
+ description={copy.takingLongerHelp}
48
50
  showIcon
49
51
  data-testid="card-poll-timeout"
50
52
  />
@@ -54,7 +56,7 @@ function SubmittedState({
54
56
  <LoadingState
55
57
  variant="spinner"
56
58
  size="md"
57
- message="Processando pagamento…"
59
+ message={copy.processing}
58
60
  dataTestId="card-processing"
59
61
  />
60
62
  );
@@ -76,11 +78,12 @@ function ChargeFailure({
76
78
  unresolved: boolean;
77
79
  }): JSX.Element {
78
80
  const { Alert } = useCheckoutComponents();
81
+ const copy = useCheckoutCopy().screens.settling;
79
82
  if (unresolved) {
80
83
  return (
81
84
  <Alert
82
85
  variant="warning"
83
- title="Estamos confirmando seu pagamento"
86
+ title={copy.confirming}
84
87
  description={message}
85
88
  showIcon
86
89
  data-testid="card-unresolved"
@@ -88,7 +91,7 @@ function ChargeFailure({
88
91
  );
89
92
  }
90
93
  return (
91
- <Alert variant="danger" title="Não foi possível pagar" description={message} showIcon data-testid="card-error" />
94
+ <Alert variant="danger" title={copy.cannotPay} description={message} showIcon data-testid="card-error" />
92
95
  );
93
96
  }
94
97
 
@@ -127,6 +130,7 @@ export function CardView({
127
130
  pollIntervalMs?: number;
128
131
  }): JSX.Element {
129
132
  const { Text } = useCheckoutComponents();
133
+ const copy = useCheckoutCopy().screens.card;
130
134
  const cc = useCardCheckout(order, buyer, providerConfig, onResolved, pollIntervalMs, tenantSlug, providerChain);
131
135
  // A charge NOBODY can confirm yet is not a decline (FUT-563). Some provider
132
136
  // may be holding the buyer's money, so it gets its own presentation: the
@@ -141,7 +145,7 @@ export function CardView({
141
145
  return (
142
146
  <Box data-testid="card-view" sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
143
147
  <Text variant="heading" size="md" weight="bold" as="h2">
144
- Pague com cartão
148
+ {copy.heading}
145
149
  </Text>
146
150
 
147
151
  {cc.savedCards.length > 0 ? (
@@ -6,6 +6,7 @@ import { DadosStep, EmptyCart, PaymentStep } from "./checkout-steps";
6
6
  import { ArrowBackIcon } from "./icons";
7
7
  import { PaymentStatus } from "./payment-status";
8
8
  import type { BuyerInfo, CheckoutProviderConfig, SettlementCheckout } from "./types";
9
+ import { CheckoutCopyProvider } from "./copy-context";
9
10
  import { CheckoutComponentsProvider, useCheckoutComponents, type CheckoutComponents } from "./ui";
10
11
  import type { CheckoutViewCopy } from "./view-copy";
11
12
  import { useCheckoutController, type CheckoutHostPorts } from "./use-checkout-controller";
@@ -245,7 +246,9 @@ function CheckoutFlowBody(props: Omit<CheckoutFlowProps, "components">): JSX.Ele
245
246
  export function CheckoutFlow({ components, ...props }: CheckoutFlowProps): JSX.Element {
246
247
  return (
247
248
  <CheckoutComponentsProvider components={components}>
248
- <CheckoutFlowBody {...props} />
249
+ <CheckoutCopyProvider copy={props.copy.screens}>
250
+ <CheckoutFlowBody {...props} />
251
+ </CheckoutCopyProvider>
249
252
  </CheckoutComponentsProvider>
250
253
  );
251
254
  }
@@ -2,6 +2,7 @@ import { Box } from "@mui/material";
2
2
  import { useEffect, useRef, type JSX, type ReactNode } from "react";
3
3
 
4
4
  import { BuyerInfoForm } from "./buyer-info-form";
5
+ import { useCheckoutCopy } from "./copy-context";
5
6
  import { LockOutlinedIcon } from "./icons";
6
7
  import { useMethodChoice } from "./method-choice";
7
8
  import { MethodPicker } from "./method-picker";
@@ -345,6 +346,7 @@ export function PaymentStep({
345
346
  onResolved,
346
347
  }: PaymentStepProps): JSX.Element {
347
348
  const { LoadingState } = useCheckoutComponents();
349
+ const screens = useCheckoutCopy().screens;
348
350
  const config = providerConfig ?? null;
349
351
  const choice = useMethodChoice(config, method, onMethodChange);
350
352
  useAutoRaiseOrder(order, method, creating, createError, onGenerate);
@@ -381,7 +383,7 @@ export function PaymentStep({
381
383
  raised, and two stacked spinners saying the same thing is what the
382
384
  buyer actually saw. */}
383
385
  {!order && creating && !choice.atProvider ? (
384
- <LoadingState variant="spinner" size="md" message="Gerando pagamento…" dataTestId="payment-generating" />
386
+ <LoadingState variant="spinner" size="md" message={screens.generatingPayment} dataTestId="payment-generating" />
385
387
  ) : null}
386
388
 
387
389
  {!order && method && createError ? (
@@ -15,8 +15,14 @@
15
15
  *
16
16
  * The default is built from the `client.ts` bindings LAZILY (inside each
17
17
  * arrow), so a suite that `vi.mock`s that module still intercepts the call.
18
+ *
19
+ * Since FUT-760 that default also needs the transport's WORDS, and it takes
20
+ * them from the checkout's copy context — which is why {@link
21
+ * useCheckoutClientApi} builds it instead of holding it as a module constant.
22
+ * No screen passes them: a screen already has the copy provider above it, or
23
+ * it would not render at all.
18
24
  */
19
- import { createContext, useContext, type JSX, type ReactNode } from "react";
25
+ import { createContext, useContext, useMemo, type JSX, type ReactNode } from "react";
20
26
 
21
27
  import {
22
28
  chargeCard,
@@ -26,22 +32,26 @@ import {
26
32
  pollOrderStatus,
27
33
  refreshCardPublicKey,
28
34
  } from "./client";
35
+ import { useCheckoutCopy } from "./copy-context";
36
+ import type { CheckoutTransportCopy } from "./screens-copy";
29
37
  import { createCheckoutClient, type CheckoutClient } from "./transport";
30
38
 
31
39
  /** The unbound client: `/api/checkout` on the ambient `fetch`. */
32
- const DEFAULT_CLIENT: CheckoutClient = {
33
- getConfig: (tenantSlug) => fetchCheckoutConfig(tenantSlug),
34
- getStatus: (ref) => pollOrderStatus(ref),
35
- charge: (input) => chargeCard(input),
36
- chargeWallet: (input) => chargeWallet(input),
37
- listInstruments: (tenantSlug) => listSavedCards(tenantSlug),
38
- // The vault pair (FUT-183) has no `client.ts` free function to bind — it is
39
- // newer than that module. Built lazily from the default transport instead,
40
- // which is the same wire: `/api/checkout`, ambient `fetch` resolved per call.
41
- beginVault: () => createCheckoutClient().beginVault(),
42
- completeVault: (input) => createCheckoutClient().completeVault(input),
43
- refreshBrowserKey: (input) => refreshCardPublicKey(input),
44
- };
40
+ function unboundClient(copy: CheckoutTransportCopy): CheckoutClient {
41
+ return {
42
+ getConfig: (tenantSlug) => fetchCheckoutConfig(tenantSlug, copy),
43
+ getStatus: (ref) => pollOrderStatus(ref, copy),
44
+ charge: (input) => chargeCard(input, copy),
45
+ chargeWallet: (input) => chargeWallet(input, copy),
46
+ listInstruments: (tenantSlug) => listSavedCards(copy, tenantSlug),
47
+ // The vault pair (FUT-183) has no `client.ts` free function to bind it is
48
+ // newer than that module. Built lazily from the default transport instead,
49
+ // which is the same wire: `/api/checkout`, ambient `fetch` resolved per call.
50
+ beginVault: () => createCheckoutClient({ copy }).beginVault(),
51
+ completeVault: (input) => createCheckoutClient({ copy }).completeVault(input),
52
+ refreshBrowserKey: (input) => refreshCardPublicKey(input, copy),
53
+ };
54
+ }
45
55
 
46
56
  const CheckoutClientContext = createContext<CheckoutClient | null>(null);
47
57
 
@@ -58,7 +68,9 @@ export function CheckoutClientProvider({
58
68
  );
59
69
  }
60
70
 
61
- /** The bound client, or the module default when no provider sits above. */
71
+ /** The bound client, or the unbound one when no provider sits above. */
62
72
  export function useCheckoutClientApi(): CheckoutClient {
63
- return useContext(CheckoutClientContext) ?? DEFAULT_CLIENT;
73
+ const provided = useContext(CheckoutClientContext);
74
+ const copy = useCheckoutCopy().screens.transport;
75
+ return useMemo(() => provided ?? unboundClient(copy), [provided, copy]);
64
76
  }
@@ -12,6 +12,12 @@
12
12
  * injected `fetch` reaches for `createPaymentFlows({ transport })` instead;
13
13
  * nothing about these functions changes when it does.
14
14
  *
15
+ * Each takes the transport's COPY (FUT-760). Unbound is about the mount, not
16
+ * about the language: a wire failure still has to say something to a buyer,
17
+ * and the one thing this package must not do is pick those words itself. The
18
+ * caller is `client-context.tsx`, which reads them from the checkout's copy
19
+ * context — so no screen passes them by hand.
20
+ *
15
21
  * Order CREATION and the buyer-profile save are deliberately NOT here: both
16
22
  * are host domain (the cart, the account) and reach the flow as ports
17
23
  * (`createOrder` / `saveBuyerContact` on `CheckoutFlowProps`).
@@ -24,6 +30,7 @@
24
30
  import type { SavedCard } from "../../card";
25
31
  import type { Result } from "../../result";
26
32
 
33
+ import type { CheckoutTransportCopy } from "./screens-copy";
27
34
  import { createCheckoutClient } from "./transport";
28
35
  import type {
29
36
  ChargeCardInput,
@@ -34,14 +41,23 @@ import type {
34
41
  } from "./types";
35
42
 
36
43
  /**
37
- * The default binding: `/api/checkout` on the ambient `fetch`. Built once, but
38
- * it resolves `fetch` per call, so a suite that stubs the global still wins.
44
+ * The default binding: `/api/checkout` on the ambient `fetch`, saying what the
45
+ * caller's copy says when the wire fails.
46
+ *
47
+ * Built per call rather than once, because the words are now an argument. It
48
+ * costs one closure and resolves `fetch` per call either way, so a suite that
49
+ * stubs the global still wins.
39
50
  */
40
- const defaultClient = createCheckoutClient();
51
+ function defaultClient(copy: CheckoutTransportCopy) {
52
+ return createCheckoutClient({ copy });
53
+ }
41
54
 
42
55
  /** Poll an order's reconciled status (async provider webhook confirmation). */
43
- export async function pollOrderStatus(orderId: string): Promise<Result<OrderStatus>> {
44
- return defaultClient.getStatus(orderId);
56
+ export async function pollOrderStatus(
57
+ orderId: string,
58
+ copy: CheckoutTransportCopy,
59
+ ): Promise<Result<OrderStatus>> {
60
+ return defaultClient(copy).getStatus(orderId);
45
61
  }
46
62
 
47
63
  /**
@@ -52,8 +68,9 @@ export async function pollOrderStatus(orderId: string): Promise<Result<OrderStat
52
68
  */
53
69
  export async function fetchCheckoutConfig(
54
70
  tenantSlug: string,
71
+ copy: CheckoutTransportCopy,
55
72
  ): Promise<Result<CheckoutProviderConfig>> {
56
- return defaultClient.getConfig(tenantSlug);
73
+ return defaultClient(copy).getConfig(tenantSlug);
57
74
  }
58
75
 
59
76
  /**
@@ -63,10 +80,11 @@ export async function fetchCheckoutConfig(
63
80
  * (the web page resolved it server-side) and for the FUT-174 rotated-key
64
81
  * self-heal retry.
65
82
  */
66
- export async function refreshCardPublicKey(input: {
67
- orderId: string;
68
- }): Promise<Result<{ publicKey: string | null }>> {
69
- return defaultClient.refreshBrowserKey(input);
83
+ export async function refreshCardPublicKey(
84
+ input: { orderId: string },
85
+ copy: CheckoutTransportCopy,
86
+ ): Promise<Result<{ publicKey: string | null }>> {
87
+ return defaultClient(copy).refreshBrowserKey(input);
70
88
  }
71
89
 
72
90
  /**
@@ -74,8 +92,11 @@ export async function refreshCardPublicKey(input: {
74
92
  * `hostedCheckoutUrl` when the provider demands the buyer finish on its own
75
93
  * page (3-D Secure, FUT-698) — the caller then hands the buyer over.
76
94
  */
77
- export async function chargeCard(input: ChargeCardInput): Promise<Result<ChargeOutcome>> {
78
- return defaultClient.charge(input);
95
+ export async function chargeCard(
96
+ input: ChargeCardInput,
97
+ copy: CheckoutTransportCopy,
98
+ ): Promise<Result<ChargeOutcome>> {
99
+ return defaultClient(copy).charge(input);
79
100
  }
80
101
 
81
102
  /**
@@ -83,11 +104,17 @@ export async function chargeCard(input: ChargeCardInput): Promise<Result<ChargeO
83
104
  * `/charge` route as {@link chargeCard}, carrying `wallet: { type, key }` in
84
105
  * place of a card token.
85
106
  */
86
- export async function chargeWallet(input: ChargeWalletInput): Promise<Result<ChargeOutcome>> {
87
- return defaultClient.chargeWallet(input);
107
+ export async function chargeWallet(
108
+ input: ChargeWalletInput,
109
+ copy: CheckoutTransportCopy,
110
+ ): Promise<Result<ChargeOutcome>> {
111
+ return defaultClient(copy).chargeWallet(input);
88
112
  }
89
113
 
90
114
  /** List saved cards available for reuse (empty on any error — non-blocking). */
91
- export async function listSavedCards(tenantSlug?: string): Promise<SavedCard[]> {
92
- return defaultClient.listInstruments(tenantSlug);
115
+ export async function listSavedCards(
116
+ copy: CheckoutTransportCopy,
117
+ tenantSlug?: string,
118
+ ): Promise<SavedCard[]> {
119
+ return defaultClient(copy).listInstruments(tenantSlug);
93
120
  }
@@ -0,0 +1,83 @@
1
+ 'use client';
2
+
3
+ import { createContext, useContext, type JSX, type ReactNode } from 'react';
4
+
5
+ import type { CardCopy } from '../../card/copy';
6
+ import type { CheckoutScreensCopy } from './screens-copy';
7
+
8
+ /**
9
+ * The buyer checkout's words, for the screens too deep to reach by prop
10
+ * (FUT-760).
11
+ *
12
+ * The existing `CheckoutViewCopy` is a required PROP, and that is still the
13
+ * right shape for the flow's own screens — a host wiring the checkout passes
14
+ * it at the one call that mounts them. This context exists for the parts that
15
+ * prop cannot reach without threading it through four intermediate
16
+ * components: the card fields, the wallet panes, the method tiles.
17
+ *
18
+ * It sits beside `CheckoutComponentsProvider` because the two answer the same
19
+ * kind of question — a host's design system there, a host's language here —
20
+ * and a screen that has one always has the other.
21
+ */
22
+ export interface CheckoutCopy {
23
+ /** The card form and its tokenizers. */
24
+ card: CardCopy;
25
+ /** The buyer-details step's own fields and their refusals. */
26
+ buyer: BuyerInfoCopy;
27
+ /**
28
+ * Everything inside the steps — the method tiles, the PIX and card panes,
29
+ * the wallet buttons, the hosted handover, and the refusals any of them can
30
+ * produce.
31
+ */
32
+ screens: CheckoutScreensCopy;
33
+ }
34
+
35
+ /**
36
+ * The buyer-details step: what it asks for, and what it says when the answer
37
+ * will not do.
38
+ */
39
+ export interface BuyerInfoCopy {
40
+ emailInvalid: string;
41
+ emailRequired: string;
42
+ nameRequired: string;
43
+ phoneRequired: string;
44
+ /**
45
+ * The hint above the fields, built from what the store's provider actually
46
+ * DEMANDS — which fields those are is the package's answer, and it changes
47
+ * per store.
48
+ *
49
+ * A function over the required field names, not a template with a slot: the
50
+ * sentence agrees its adjective with how many there are ("obrigatório" /
51
+ * "obrigatórios"), and a `{fields}` hole would have this package deciding
52
+ * that agreement for every language.
53
+ */
54
+ fieldsHint(requiredFieldNames: readonly string[]): string;
55
+ }
56
+
57
+ const CheckoutCopyContext = createContext<CheckoutCopy | null>(null);
58
+
59
+ export function CheckoutCopyProvider({
60
+ copy,
61
+ children,
62
+ }: {
63
+ copy: CheckoutCopy;
64
+ children: ReactNode;
65
+ }): JSX.Element {
66
+ return <CheckoutCopyContext.Provider value={copy}>{children}</CheckoutCopyContext.Provider>;
67
+ }
68
+
69
+ /**
70
+ * The words this checkout renders — THROWS outside a provider rather than
71
+ * falling back.
72
+ *
73
+ * A fallback could only be the origin host's Portuguese, handed silently to
74
+ * the next adopter's shopper. Failing at the mount is the whole point: it is
75
+ * the one moment a host can still be told it forgot.
76
+ */
77
+ export function useCheckoutCopy(): CheckoutCopy {
78
+ const copy = useContext(CheckoutCopyContext);
79
+ if (!copy) {
80
+ throw new Error('useCheckoutCopy must be rendered inside a <CheckoutCopyProvider>');
81
+ }
82
+ return copy;
83
+ }