@12-apps/payments-frontend 3.6.0 → 3.7.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/payments-frontend",
3
- "version": "3.6.0",
3
+ "version": "3.7.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.14.0",
20
+ "@12-apps/payments-backend": "^4.15.0",
21
21
  "react-qr-code": "^2.2.0"
22
22
  },
23
23
  "peerDependencies": {
@@ -28,8 +28,8 @@
28
28
  "react-dom": ">=19.0.0"
29
29
  },
30
30
  "devDependencies": {
31
- "@12-apps/eslint-config": "^1.21.0",
32
- "@12-apps/typescript-config": "^1.20.0",
31
+ "@12-apps/eslint-config": "^1.21.1",
32
+ "@12-apps/typescript-config": "^1.20.1",
33
33
  "@emotion/react": "^11.14.0",
34
34
  "@emotion/styled": "^11.14.0",
35
35
  "@mui/material": "^6.5.0",
@@ -7,13 +7,17 @@ import { ArrowBackIcon } from "./icons";
7
7
  import { PaymentStatus } from "./payment-status";
8
8
  import type { BuyerInfo, CheckoutProviderConfig, SettlementCheckout } from "./types";
9
9
  import { CheckoutComponentsProvider, useCheckoutComponents, type CheckoutComponents } from "./ui";
10
+ import type { CheckoutViewCopy } from "./view-copy";
10
11
  import { useCheckoutController, type CheckoutHostPorts } from "./use-checkout-controller";
11
12
 
12
- const STEPPER_STEPS = [
13
- { id: "dados", label: "Dados" },
14
- { id: "payment", label: "Pagamento" },
15
- { id: "status", label: "Confirmação" },
16
- ];
13
+ /** Step ids are the flow's own contract; the labels beside them are host copy. */
14
+ function stepperSteps(copy: CheckoutViewCopy): { id: string; label: string }[] {
15
+ return [
16
+ { id: "dados", label: copy.steps.dados },
17
+ { id: "payment", label: copy.steps.payment },
18
+ { id: "status", label: copy.steps.status },
19
+ ];
20
+ }
17
21
 
18
22
  /** What the flow reads off the host's cart — display facts, never money math. */
19
23
  export interface CheckoutCartView {
@@ -35,6 +39,14 @@ export interface CheckoutCartView {
35
39
  * pixels render through the slot contract (`components`, see `ui.tsx`).
36
40
  */
37
41
  export interface CheckoutFlowProps extends CheckoutHostPorts {
42
+ /**
43
+ * Every sentence the flow's own chrome renders — stepper labels, the Dados
44
+ * step, the empty cart, the confirmation screen. REQUIRED, with no default:
45
+ * a pt-BR host passes `PT_BR_CHECKOUT_VIEW_COPY` from the package root by
46
+ * hand, so choosing Portuguese is a line in the host's diff, never a
47
+ * silence (FUT-760's doctrine, finally applied to the legacy flow too).
48
+ */
49
+ copy: CheckoutViewCopy;
38
50
  /** The host's cart, reduced to what the flow displays. */
39
51
  cart: CheckoutCartView;
40
52
  defaultBuyer?: BuyerInfo;
@@ -97,23 +109,23 @@ function statusTotalLabel(
97
109
  * per-line remove. Offering it here put a destructive control next to the form
98
110
  * a buyer is filling in, one tap away from the field they are typing into.
99
111
  */
100
- function CheckoutHeader({ step, onBack }: { step: string; onBack: () => void }): JSX.Element {
112
+ function CheckoutHeader({ copy, step, onBack }: { copy: CheckoutViewCopy; step: string; onBack: () => void }): JSX.Element {
101
113
  const { Button } = useCheckoutComponents();
102
114
  return (
103
115
  <Box sx={{ minHeight: 36, display: "flex", alignItems: "center", gap: 1 }}>
104
116
  <Button variant="text" color="neutral" size="sm" icon={<ArrowBackIcon fontSize="small" />} iconPosition="left" onClick={onBack} dataTestId="checkout-back">
105
- {step === "dados" ? "Continuar comprando" : "Voltar"}
117
+ {step === "dados" ? copy.dados.keepShopping : copy.dados.back}
106
118
  </Button>
107
119
  </Box>
108
120
  );
109
121
  }
110
122
 
111
123
  /** Compact 50px progress header so the form stays above the fold on mobile. */
112
- function ProgressHeader({ step, completed }: { step: string; completed: Set<string> }): JSX.Element {
124
+ function ProgressHeader({ copy, step, completed }: { copy: CheckoutViewCopy; step: string; completed: Set<string> }): JSX.Element {
113
125
  const { Stepper } = useCheckoutComponents();
114
126
  return (
115
127
  <Box sx={{ height: 50, display: "flex", flexDirection: "column", justifyContent: "center" }}>
116
- <Stepper steps={STEPPER_STEPS} activeId={step} completed={completed} orientation="horizontal" size="sm" data-testid="checkout-stepper" />
128
+ <Stepper steps={stepperSteps(copy)} activeId={step} completed={completed} orientation="horizontal" size="sm" data-testid="checkout-stepper" />
117
129
  </Box>
118
130
  );
119
131
  }
@@ -124,8 +136,37 @@ function ProgressHeader({ step, completed }: { step: string; completed: Set<stri
124
136
  * chrome (reduced to {@link CheckoutCartView} here), and the provider SDK +
125
137
  * card public key are loaded lazily by the card path (order-scoped REST).
126
138
  */
139
+ /** Step 3, with the controller's facts mapped onto the status screen. */
140
+ function StatusStep({
141
+ copy,
142
+ c,
143
+ settlement,
144
+ cart,
145
+ confirmationExtra,
146
+ }: {
147
+ copy: CheckoutViewCopy;
148
+ c: ReturnType<typeof useCheckoutController>;
149
+ settlement: SettlementCheckout | null | undefined;
150
+ cart: CheckoutCartView;
151
+ confirmationExtra: ReactNode;
152
+ }): JSX.Element {
153
+ return (
154
+ <PaymentStatus
155
+ copy={copy.status}
156
+ status={c.finalStatus}
157
+ totalLabel={statusTotalLabel(c.order, settlement, cart)}
158
+ {...confirmationFacts(c.order, c.buyer)}
159
+ onRetry={c.retry}
160
+ onRegenerate={() => { c.setStep("payment"); void c.startPayment("PIX"); }}
161
+ onBackToMenu={c.goToMenu}
162
+ paidExtra={confirmationExtra}
163
+ awaitingTimedOut={c.resumeTimedOut}
164
+ />
165
+ );
166
+ }
167
+
127
168
  function CheckoutFlowBody(props: Omit<CheckoutFlowProps, "components">): JSX.Element {
128
- const { cart, defaultBuyer, settlement, taxIdOnFile = false, providerConfig, tenantSlug, confirmationExtra, validateApplePayMerchant, ...ports } = props;
169
+ const { copy, cart, defaultBuyer, settlement, taxIdOnFile = false, providerConfig, tenantSlug, confirmationExtra, validateApplePayMerchant, ...ports } = props;
129
170
  // Resolved for NO method on purpose (FUT-595): the Dados step opens before
130
171
  // the picker, and the form is filled once — so it asks for the union of what
131
172
  // any chain member may need rather than re-opening after the choice. A chain
@@ -141,17 +182,18 @@ function CheckoutFlowBody(props: Omit<CheckoutFlowProps, "components">): JSX.Ele
141
182
  // method picker. It holds until an order exists — once one does, its lines are
142
183
  // snapshotted server-side and the cart no longer speaks for it.
143
184
  if (!settlement && cart.empty && !c.order && c.step !== "status") {
144
- return <EmptyCart onBack={c.goToMenu} />;
185
+ return <EmptyCart copy={copy.emptyCart} onBack={c.goToMenu} />;
145
186
  }
146
187
 
147
188
  return (
148
189
  <Box sx={{ display: "flex", flexDirection: "column", gap: { xs: 2, sm: 3 } }}>
149
- <CheckoutHeader step={c.step} onBack={c.back} />
190
+ <CheckoutHeader copy={copy} step={c.step} onBack={c.back} />
150
191
 
151
- <ProgressHeader step={c.step} completed={c.completed} />
192
+ <ProgressHeader copy={copy} step={c.step} completed={c.completed} />
152
193
 
153
194
  {c.step === "dados" ? (
154
195
  <DadosStep
196
+ copy={copy.dados}
155
197
  buyer={c.buyer}
156
198
  onBuyerChange={c.setBuyer}
157
199
  saveProfile={c.saveProfile}
@@ -189,16 +231,7 @@ function CheckoutFlowBody(props: Omit<CheckoutFlowProps, "components">): JSX.Ele
189
231
  ) : null}
190
232
 
191
233
  {c.step === "status" ? (
192
- <PaymentStatus
193
- status={c.finalStatus}
194
- totalLabel={statusTotalLabel(c.order, settlement, cart)}
195
- {...confirmationFacts(c.order, c.buyer)}
196
- onRetry={c.retry}
197
- onRegenerate={() => { c.setStep("payment"); void c.startPayment("PIX"); }}
198
- onBackToMenu={c.goToMenu}
199
- paidExtra={confirmationExtra}
200
- awaitingTimedOut={c.resumeTimedOut}
201
- />
234
+ <StatusStep copy={copy} c={c} settlement={settlement} cart={cart} confirmationExtra={confirmationExtra} />
202
235
  ) : null}
203
236
  </Box>
204
237
  );
@@ -17,6 +17,7 @@ import type {
17
17
  OrderStatus,
18
18
  PaymentMethod,
19
19
  } from "./types";
20
+ import type { DadosStepCopy, EmptyCartCopy } from "./view-copy";
20
21
  import { useCheckoutComponents } from "./ui";
21
22
 
22
23
  /**
@@ -100,15 +101,15 @@ function PaymentBody({
100
101
  }
101
102
 
102
103
  /** Empty-cart state shown when there's nothing to check out. */
103
- export function EmptyCart({ onBack }: { onBack: () => void }): JSX.Element {
104
+ export function EmptyCart({ copy, onBack }: { copy: EmptyCartCopy; onBack: () => void }): JSX.Element {
104
105
  const { Button, Text } = useCheckoutComponents();
105
106
  return (
106
107
  <Box data-testid="checkout-empty" sx={{ py: 8, textAlign: "center", display: "flex", flexDirection: "column", gap: 2, alignItems: "center" }}>
107
108
  <Text variant="heading" size="md" as="p">
108
- Seu carrinho está vazio.
109
+ {copy.title}
109
110
  </Text>
110
111
  <Button variant="solid" color="primary" size="md" onClick={onBack} dataTestId="checkout-empty-back">
111
- Ver cardápio
112
+ {copy.action}
112
113
  </Button>
113
114
  </Box>
114
115
  );
@@ -158,6 +159,7 @@ function PayBarTotal({
158
159
  * — no charge yet.
159
160
  */
160
161
  export function DadosStep({
162
+ copy,
161
163
  buyer,
162
164
  onBuyerChange,
163
165
  saveProfile,
@@ -170,6 +172,8 @@ export function DadosStep({
170
172
  discountLines,
171
173
  totalOverride,
172
174
  }: {
175
+ /** The step's own sentences — the HOST's words (see `./view-copy`). */
176
+ copy: DadosStepCopy;
173
177
  buyer: BuyerInfo;
174
178
  onBuyerChange: (buyer: BuyerInfo) => void;
175
179
  saveProfile: boolean;
@@ -206,12 +210,13 @@ export function DadosStep({
206
210
  <Checkbox
207
211
  checked={saveProfile}
208
212
  onChange={(_event, checked) => onSaveProfileChange(checked)}
209
- label="Salvar meus dados para a próxima compra"
213
+ label={copy.saveProfile}
210
214
  data-testid="buyer-save-profile"
211
215
  />
212
216
  </Box>
213
217
 
214
218
  <DadosPayBar
219
+ copy={copy}
215
220
  totalLabel={totalLabel}
216
221
  totalItems={totalItems}
217
222
  createError={createError}
@@ -227,12 +232,14 @@ export function DadosStep({
227
232
 
228
233
  /** The sticky "Continuar" bar: the refusal, the money, and the one action. */
229
234
  function DadosPayBar({
235
+ copy,
230
236
  totalLabel,
231
237
  totalItems,
232
238
  createError,
233
239
  onContinue,
234
240
  children,
235
241
  }: {
242
+ copy: DadosStepCopy;
236
243
  totalLabel: string;
237
244
  totalItems: number;
238
245
  createError: string | null;
@@ -244,20 +251,22 @@ function DadosPayBar({
244
251
  <ActionBar dataTestId="checkout-pay-bar">
245
252
  <Box sx={{ width: "100%", display: "flex", flexDirection: "column", gap: 1.5 }}>
246
253
  {createError ? (
247
- <Alert variant="danger" title="Não foi possível continuar" description={createError} showIcon data-testid="checkout-error" />
254
+ <Alert variant="danger" title={copy.cannotContinueTitle} description={createError} showIcon data-testid="checkout-error" />
248
255
  ) : null}
249
256
  <Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
250
257
  <PayBarTotal totalLabel={totalLabel} totalItems={totalItems}>{children}</PayBarTotal>
251
258
  <Box sx={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 0.5, minWidth: 0 }}>
252
259
  <Button variant="solid" color="primary" size="lg" fullWidth onClick={onContinue} dataTestId="checkout-continue">
253
- Continuar
260
+ {copy.continueAction}
254
261
  </Button>
255
- <Box sx={{ display: "flex", alignItems: "center", gap: 0.5, color: "text.secondary" }}>
256
- <LockOutlinedIcon sx={{ fontSize: 13 }} />
257
- <Text variant="caption" size="xs" color="secondary" as="span">
258
- Pagamento seguro
259
- </Text>
260
- </Box>
262
+ {copy.secureNotice ? (
263
+ <Box sx={{ display: "flex", alignItems: "center", gap: 0.5, color: "text.secondary" }}>
264
+ <LockOutlinedIcon sx={{ fontSize: 13 }} />
265
+ <Text variant="caption" size="xs" color="secondary" as="span">
266
+ {copy.secureNotice}
267
+ </Text>
268
+ </Box>
269
+ ) : null}
261
270
  </Box>
262
271
  </Box>
263
272
  </Box>
@@ -4,6 +4,7 @@ import type { JSX, ReactNode } from "react";
4
4
  import { CheckCircleOutlineIcon, ErrorOutlineIcon, ScheduleIcon } from "./icons";
5
5
  import type { OrderStatus } from "./types";
6
6
  import { useCheckoutComponents } from "./ui";
7
+ import type { PaymentStatusCopy } from "./view-copy";
7
8
 
8
9
  /**
9
10
  * The last screen of checkout.
@@ -28,67 +29,38 @@ import { useCheckoutComponents } from "./ui";
28
29
  * screen says and how it is arranged has changed.
29
30
  */
30
31
 
31
- /** Everything the hero block needs, per outcome. */
32
- interface Outcome {
33
- heading: string;
34
- support: string;
32
+ /**
33
+ * The per-outcome VISUAL grammar — icon and semantic tone. The heading and
34
+ * supporting line beside them come from {@link PaymentStatusCopy}: an icon is
35
+ * the component's own vocabulary, a sentence never is. (The FAILED support
36
+ * line's job — say "nothing was charged" plainly and first — and the
37
+ * timed-out wait's "do not pay again" now live with the host's words, where
38
+ * FUT-556's reasoning is documented on the copy port.)
39
+ */
40
+ interface OutcomeVisual {
35
41
  icon: JSX.Element;
36
42
  /** Semantic theme token — never a raw colour. */
37
43
  tone: "success" | "danger" | "warning" | "neutral";
38
44
  }
39
45
 
40
- const OUTCOME: Record<OrderStatus, Outcome> = {
41
- PAID: {
42
- heading: "Pedido confirmado",
43
- support: "Recebemos seu pagamento e registramos o pedido.",
44
- icon: <CheckCircleOutlineIcon fontSize="large" />,
45
- tone: "success",
46
- },
47
- AWAITING_PAYMENT: {
48
- heading: "Confirmando seu pagamento",
49
- support: "Isso costuma levar alguns segundos. Pode deixar esta tela aberta.",
50
- icon: <ScheduleIcon fontSize="large" />,
51
- tone: "neutral",
52
- },
53
- FAILED: {
54
- heading: "Pagamento não concluído",
55
- // Said plainly and first: the fear on this screen is having been charged
56
- // for an order that failed.
57
- support: "Nenhum valor foi cobrado. Você pode tentar novamente.",
58
- icon: <ErrorOutlineIcon fontSize="large" />,
59
- tone: "danger",
60
- },
61
- EXPIRED: {
62
- heading: "O código expirou",
63
- support: "Nenhum valor foi cobrado. Gere um novo código para continuar.",
64
- icon: <ScheduleIcon fontSize="large" />,
65
- tone: "warning",
66
- },
46
+ const OUTCOME_VISUAL: Record<OrderStatus, OutcomeVisual> = {
47
+ PAID: { icon: <CheckCircleOutlineIcon fontSize="large" />, tone: "success" },
48
+ AWAITING_PAYMENT: { icon: <ScheduleIcon fontSize="large" />, tone: "neutral" },
49
+ FAILED: { icon: <ErrorOutlineIcon fontSize="large" />, tone: "danger" },
50
+ EXPIRED: { icon: <ScheduleIcon fontSize="large" />, tone: "warning" },
67
51
  };
68
52
 
69
- /**
70
- * What the screen says once it has stopped asking (FUT-556).
71
- *
72
- * NOT an `OrderStatus`: the order really is still AWAITING_PAYMENT and may yet
73
- * settle — the scheduled reconciliation keeps asking the provider long after
74
- * this tab is gone. What ran out is the WAIT, not the order, and saying
75
- * otherwise would be the more expensive lie: a buyer told "não concluído" who
76
- * has in fact paid will either pay twice or call the store.
77
- *
78
- * So it leads with what is true and unglamorous — we have not heard yet — and
79
- * spends its remaining words on the one instruction that matters, which is not
80
- * to pay again.
81
- */
82
- const AWAITING_TIMED_OUT: Outcome = {
83
- heading: "Ainda não recebemos a confirmação",
84
- support:
85
- "Se você já pagou, o pedido é confirmado assim que a operadora avisar — " +
86
- "não pague de novo. Você pode fechar esta tela.",
87
- icon: <ScheduleIcon fontSize="large" />,
88
- tone: "warning",
53
+ const OUTCOME_COPY_KEY: Record<OrderStatus, keyof Pick<
54
+ PaymentStatusCopy,
55
+ "paid" | "awaiting" | "failed" | "expired"
56
+ >> = {
57
+ PAID: "paid",
58
+ AWAITING_PAYMENT: "awaiting",
59
+ FAILED: "failed",
60
+ EXPIRED: "expired",
89
61
  };
90
62
 
91
- const TONE_COLOR: Record<Outcome["tone"], string> = {
63
+ const TONE_COLOR: Record<OutcomeVisual["tone"], string> = {
92
64
  success: "success.main",
93
65
  danger: "error.main",
94
66
  warning: "warning.main",
@@ -109,14 +81,22 @@ function orderReference(orderId: string): string {
109
81
 
110
82
  /** The headline block: icon, outcome, and one supporting line. */
111
83
  function OutcomeHero({
84
+ copy,
112
85
  status,
113
86
  timedOut = false,
114
87
  }: {
88
+ copy: PaymentStatusCopy;
115
89
  status: OrderStatus;
116
90
  timedOut?: boolean;
117
91
  }): JSX.Element {
118
92
  const { Text } = useCheckoutComponents();
119
- const outcome = timedOut && status === "AWAITING_PAYMENT" ? AWAITING_TIMED_OUT : OUTCOME[status];
93
+ const timedOutWait = timedOut && status === "AWAITING_PAYMENT";
94
+ // The timed-out wait keeps AWAITING's neutral clock icon but WARNING's tone:
95
+ // the order is not resolved, and calm-but-alert is the visual for that.
96
+ const visual = timedOutWait
97
+ ? { icon: OUTCOME_VISUAL.AWAITING_PAYMENT.icon, tone: "warning" as const }
98
+ : OUTCOME_VISUAL[status];
99
+ const outcome = timedOutWait ? copy.awaitingTimedOut : copy[OUTCOME_COPY_KEY[status]];
120
100
  return (
121
101
  <Box
122
102
  // `payment-paid` is load-bearing for the storefront journeys — it is how
@@ -133,7 +113,7 @@ function OutcomeHero({
133
113
  }
134
114
  sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 1, textAlign: "center" }}
135
115
  >
136
- <Box sx={{ color: TONE_COLOR[outcome.tone], display: "flex" }}>{outcome.icon}</Box>
116
+ <Box sx={{ color: TONE_COLOR[visual.tone], display: "flex" }}>{visual.icon}</Box>
137
117
  <Text variant="heading" size="md" weight="bold" as="h2">
138
118
  {outcome.heading}
139
119
  </Text>
@@ -165,10 +145,12 @@ function Fact({ label, value, testId }: { label: string; value: string; testId?:
165
145
  * outcome these facts are either untrue or not yet knowable.
166
146
  */
167
147
  function PaidFacts({
148
+ copy,
168
149
  totalLabel,
169
150
  orderId,
170
151
  buyerEmail,
171
152
  }: {
153
+ copy: PaymentStatusCopy;
172
154
  totalLabel: string;
173
155
  orderId?: string;
174
156
  buyerEmail?: string;
@@ -185,22 +167,24 @@ function PaidFacts({
185
167
  bgcolor: "action.hover",
186
168
  }}
187
169
  >
188
- <Fact label="Valor pago" value={totalLabel} testId="payment-amount" />
170
+ <Fact label={copy.amountLabel} value={totalLabel} testId="payment-amount" />
189
171
  {orderId ? (
190
- <Fact label="Pedido" value={`#${orderReference(orderId)}`} testId="payment-reference" />
172
+ <Fact label={copy.referenceLabel} value={`#${orderReference(orderId)}`} testId="payment-reference" />
191
173
  ) : null}
192
- {buyerEmail ? <Fact label="Comprovante enviado para" value={buyerEmail} /> : null}
174
+ {buyerEmail ? <Fact label={copy.receiptEmailLabel} value={buyerEmail} /> : null}
193
175
  </Box>
194
176
  );
195
177
  }
196
178
 
197
179
  /** The next-action row: retry / regenerate for failures, always back-to-menu. */
198
180
  function StatusActions({
181
+ copy,
199
182
  status,
200
183
  onRetry,
201
184
  onRegenerate,
202
185
  onBackToMenu,
203
186
  }: {
187
+ copy: PaymentStatusCopy;
204
188
  status: OrderStatus;
205
189
  onRetry?: () => void;
206
190
  onRegenerate?: () => void;
@@ -211,12 +195,12 @@ function StatusActions({
211
195
  <Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
212
196
  {status === "FAILED" && onRetry ? (
213
197
  <Button variant="solid" color="primary" size="lg" onClick={onRetry} dataTestId="payment-retry">
214
- Tentar novamente
198
+ {copy.retryAction}
215
199
  </Button>
216
200
  ) : null}
217
201
  {status === "EXPIRED" && onRegenerate ? (
218
202
  <Button variant="solid" color="primary" size="lg" onClick={onRegenerate} dataTestId="payment-regenerate">
219
- Gerar novo código
203
+ {copy.regenerateAction}
220
204
  </Button>
221
205
  ) : null}
222
206
  <Button
@@ -228,13 +212,14 @@ function StatusActions({
228
212
  onClick={onBackToMenu}
229
213
  dataTestId="payment-back-to-menu"
230
214
  >
231
- Voltar ao cardápio
215
+ {copy.backAction}
232
216
  </Button>
233
217
  </Box>
234
218
  );
235
219
  }
236
220
 
237
221
  export function PaymentStatus({
222
+ copy,
238
223
  status,
239
224
  totalLabel,
240
225
  orderId,
@@ -245,6 +230,8 @@ export function PaymentStatus({
245
230
  paidExtra,
246
231
  awaitingTimedOut = false,
247
232
  }: {
233
+ /** Every sentence and label this screen renders — the HOST's words. */
234
+ copy: PaymentStatusCopy;
248
235
  status: OrderStatus | null;
249
236
  totalLabel: string;
250
237
  /** The created order, when there is one — absent before the charge is raised. */
@@ -278,10 +265,10 @@ export function PaymentStatus({
278
265
  data-timed-out={awaitingTimedOut ? "true" : undefined}
279
266
  sx={{ display: "flex", flexDirection: "column", gap: 3, alignItems: "stretch", py: 2 }}
280
267
  >
281
- <OutcomeHero status={effective} timedOut={awaitingTimedOut} />
268
+ <OutcomeHero copy={copy} status={effective} timedOut={awaitingTimedOut} />
282
269
 
283
270
  {effective === "PAID" ? (
284
- <PaidFacts totalLabel={totalLabel} orderId={orderId} buyerEmail={buyerEmail} />
271
+ <PaidFacts copy={copy} totalLabel={totalLabel} orderId={orderId} buyerEmail={buyerEmail} />
285
272
  ) : null}
286
273
 
287
274
  {effective === "PAID" ? paidExtra : null}
@@ -291,6 +278,7 @@ export function PaymentStatus({
291
278
  ) : null}
292
279
 
293
280
  <StatusActions
281
+ copy={copy}
294
282
  status={effective}
295
283
  onRetry={onRetry}
296
284
  onRegenerate={onRegenerate}
@@ -23,8 +23,16 @@ import { Box } from "@mui/material";
23
23
  import type { JSX } from "react";
24
24
 
25
25
  import { useCheckoutComponents } from "./ui";
26
+ import type { PaymentsUnavailableCopy } from "./view-copy";
26
27
 
27
28
  interface PaymentsUnavailableProps {
29
+ /**
30
+ * Every sentence this screen can say — the HOST's words (see
31
+ * `./view-copy`). Until 6.0.0 the two remedies and the button were compiled
32
+ * in, in one application's Portuguese, and a host that dutifully passed the
33
+ * flows copy port still got them.
34
+ */
35
+ copy: PaymentsUnavailableCopy;
28
36
  /**
29
37
  * Whether THIS shopper can settle with a waiter — mesas on AND a mesa of
30
38
  * their own. Decides which remedy is offered.
@@ -37,6 +45,7 @@ interface PaymentsUnavailableProps {
37
45
  }
38
46
 
39
47
  export function PaymentsUnavailable({
48
+ copy,
40
49
  waiterAvailable,
41
50
  onCallWaiter,
42
51
  calling = false,
@@ -47,8 +56,8 @@ export function PaymentsUnavailable({
47
56
  <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }} data-testid="checkout-payments-disabled">
48
57
  <Alert
49
58
  variant="info"
50
- title="Pagamento online indisponível"
51
- description="Esta loja não recebe pagamentos pelo site. Combine o pagamento diretamente com a loja para concluir seu pedido."
59
+ title={copy.title}
60
+ description={copy.body}
52
61
  showIcon
53
62
  />
54
63
  </Box>
@@ -59,8 +68,8 @@ export function PaymentsUnavailable({
59
68
  <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }} data-testid="checkout-call-waiter">
60
69
  <Alert
61
70
  variant="info"
62
- title="Pagamento com o garçom"
63
- description="Esta loja não recebe pagamentos pelo site. Chame o garçom para fechar a conta na mesa."
71
+ title={copy.remedyTitle}
72
+ description={copy.remedyBody}
64
73
  showIcon
65
74
  />
66
75
  {onCallWaiter ? (
@@ -71,7 +80,7 @@ export function PaymentsUnavailable({
71
80
  onClick={onCallWaiter}
72
81
  dataTestId="checkout-call-waiter-button"
73
82
  >
74
- {calling ? "Chamando..." : "Chamar garçom"}
83
+ {calling ? copy.callingAction : copy.callAction}
75
84
  </Button>
76
85
  ) : null}
77
86
  </Box>
@@ -0,0 +1,74 @@
1
+ import type {
2
+ CheckoutViewCopy,
3
+ PaymentsUnavailableCopy,
4
+ PaymentStatusCopy,
5
+ } from "./view-copy";
6
+
7
+ /**
8
+ * The pt-BR pack — the exact sentences these views compiled in until 6.0.0,
9
+ * now NAMED exports a host passes by hand, never defaults. The filename is
10
+ * what exempts this file from the copy-portability gate: Portuguese may ship,
11
+ * it may not be silent.
12
+ */
13
+ export const PT_BR_PAYMENTS_UNAVAILABLE_COPY: PaymentsUnavailableCopy = {
14
+ title: "Pagamento online indisponível",
15
+ body: "Esta loja não recebe pagamentos pelo site. Combine o pagamento diretamente com a loja para concluir seu pedido.",
16
+ remedyTitle: "Pagamento com o garçom",
17
+ remedyBody: "Esta loja não recebe pagamentos pelo site. Chame o garçom para fechar a conta na mesa.",
18
+ callAction: "Chamar garçom",
19
+ callingAction: "Chamando...",
20
+ };
21
+
22
+ export const PT_BR_PAYMENT_STATUS_COPY: PaymentStatusCopy = {
23
+ paid: {
24
+ heading: "Pedido confirmado",
25
+ support: "Recebemos seu pagamento e já registramos o pedido.",
26
+ },
27
+ awaiting: {
28
+ heading: "Confirmando seu pagamento",
29
+ support: "Isso costuma levar alguns segundos. Pode deixar esta tela aberta.",
30
+ },
31
+ failed: {
32
+ // Said plainly and first: the fear on this screen is having been charged
33
+ // for an order that failed.
34
+ heading: "Pagamento não concluído",
35
+ support: "Nenhum valor foi cobrado. Você pode tentar novamente.",
36
+ },
37
+ expired: {
38
+ heading: "O código expirou",
39
+ support: "Nenhum valor foi cobrado. Gere um novo código para continuar.",
40
+ },
41
+ awaitingTimedOut: {
42
+ heading: "Ainda não recebemos a confirmação",
43
+ support:
44
+ "Se você já pagou, o pedido é confirmado assim que a operadora avisar — " +
45
+ "não pague de novo. Você pode fechar esta tela.",
46
+ },
47
+ retryAction: "Tentar novamente",
48
+ regenerateAction: "Gerar novo código",
49
+ backAction: "Voltar ao cardápio",
50
+ amountLabel: "Valor pago",
51
+ referenceLabel: "Pedido",
52
+ receiptEmailLabel: "Comprovante enviado para",
53
+ };
54
+
55
+ export const PT_BR_CHECKOUT_VIEW_COPY: CheckoutViewCopy = {
56
+ steps: {
57
+ dados: "Dados",
58
+ payment: "Pagamento",
59
+ status: "Confirmação",
60
+ },
61
+ dados: {
62
+ saveProfile: "Salvar meus dados para a próxima compra",
63
+ cannotContinueTitle: "Não foi possível continuar",
64
+ continueAction: "Continuar",
65
+ secureNotice: "Pagamento seguro",
66
+ keepShopping: "Continuar comprando",
67
+ back: "Voltar",
68
+ },
69
+ emptyCart: {
70
+ title: "Seu carrinho está vazio.",
71
+ action: "Ver cardápio",
72
+ },
73
+ status: PT_BR_PAYMENT_STATUS_COPY,
74
+ };
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Every string the legacy checkout views render — required props, with NO
3
+ * defaults, deliberately (the payments extraction's own doctrine, FUT-760):
4
+ * a default in the origin host's language reads as finished to the next host
5
+ * right up until a buyer sees it. These three views proved the point — the
6
+ * flows factory's copy port existed, was REQUIRED, declared
7
+ * `unavailableWithRemedyTitle` and `emptyCartAction`, and the views simply
8
+ * never read it, so a host that dutifully passed copy still rendered another
9
+ * product's voice.
10
+ *
11
+ * A pt-BR host imports {@link PT_BR_CHECKOUT_VIEW_COPY} from `./pt-BR` (the
12
+ * package root re-exports it) and passes it by hand — one reviewable line,
13
+ * never a silence.
14
+ */
15
+
16
+ /** The screen shown when the store cannot take money online. */
17
+ export interface PaymentsUnavailableCopy {
18
+ /** No remedy: the store simply does not charge online. */
19
+ title: string;
20
+ body: string;
21
+ /** A remedy exists (the origin host: the waiter; a clinic: the front desk). */
22
+ remedyTitle: string;
23
+ remedyBody: string;
24
+ /** The remedy button, idle and in flight. */
25
+ callAction: string;
26
+ callingAction: string;
27
+ }
28
+
29
+ /** The stepper's three labels, in flow order. */
30
+ export interface CheckoutStepperCopy {
31
+ dados: string;
32
+ payment: string;
33
+ status: string;
34
+ }
35
+
36
+ /** The buyer-details step: its bar, its refusal, and the slim header. */
37
+ export interface DadosStepCopy {
38
+ /** The save-my-details checkbox. */
39
+ saveProfile: string;
40
+ /** The refusal Alert's title (its body is the server's own sentence). */
41
+ cannotContinueTitle: string;
42
+ /** The sticky bar's one action. */
43
+ continueAction: string;
44
+ /**
45
+ * The reassurance caption under it. OPTIONAL, on the flows port's
46
+ * `secureNotice` precedent: a host that has not written the sentence gets
47
+ * NO caption rather than someone else's.
48
+ */
49
+ secureNotice?: string;
50
+ /** The slim header's back control, on Dados and after it. */
51
+ keepShopping: string;
52
+ back: string;
53
+ }
54
+
55
+ /** Nothing to check out (cart mode only). */
56
+ export interface EmptyCartCopy {
57
+ title: string;
58
+ action: string;
59
+ }
60
+
61
+ /** One outcome's headline and its single supporting line. */
62
+ export interface StatusOutcomeCopy {
63
+ heading: string;
64
+ support: string;
65
+ }
66
+
67
+ /**
68
+ * The confirmation screen. Icons and tones stay the component's — they are
69
+ * visual grammar, not language; every SENTENCE is the host's, including the
70
+ * timed-out wait's "do not pay again", which is the one instruction on this
71
+ * screen that matters (FUT-556).
72
+ */
73
+ export interface PaymentStatusCopy {
74
+ paid: StatusOutcomeCopy;
75
+ awaiting: StatusOutcomeCopy;
76
+ failed: StatusOutcomeCopy;
77
+ expired: StatusOutcomeCopy;
78
+ awaitingTimedOut: StatusOutcomeCopy;
79
+ retryAction: string;
80
+ regenerateAction: string;
81
+ backAction: string;
82
+ /** The paid receipt's three row labels. */
83
+ amountLabel: string;
84
+ referenceLabel: string;
85
+ receiptEmailLabel: string;
86
+ }
87
+
88
+ /** What the legacy `CheckoutFlow` itself renders and must be handed. */
89
+ export interface CheckoutViewCopy {
90
+ steps: CheckoutStepperCopy;
91
+ dados: DadosStepCopy;
92
+ emptyCart: EmptyCartCopy;
93
+ status: PaymentStatusCopy;
94
+ }
package/src/flows/copy.ts CHANGED
@@ -18,8 +18,20 @@
18
18
  * the port and nothing else.
19
19
  */
20
20
 
21
+ import type { CheckoutViewCopy } from "../components/checkout/view-copy";
22
+
23
+ export type { CheckoutViewCopy };
24
+
21
25
  /** Every buyer-facing string the factory's own screens render. */
22
26
  export interface CheckoutCopyFE {
27
+ /**
28
+ * Everything the wrapped `CheckoutFlow` itself renders — stepper labels,
29
+ * the Dados step, the empty cart, the whole confirmation screen. FUT-741
30
+ * scoped those screens out as "already carried their own product copy";
31
+ * that copy was one application's Portuguese, so the scope-out WAS the
32
+ * leak. A pt-BR host passes `PT_BR_CHECKOUT_VIEW_COPY` here by hand.
33
+ */
34
+ views: CheckoutViewCopy;
23
35
  /** No provider connected AND no host remedy: the store simply does not charge. */
24
36
  unavailableTitle: string;
25
37
  unavailableBody: string;
@@ -47,11 +59,23 @@ export interface CheckoutCopyFE {
47
59
  * part only a host can own.
48
60
  */
49
61
  returnTimedOut?: string;
50
- /** Nothing to check out (cart mode only). */
51
- emptyCartTitle: string;
52
- emptyCartAction: string;
53
62
  /** The Dados step's primary action. */
54
63
  continueAction: string;
64
+ /**
65
+ * The reassurance caption under that action.
66
+ *
67
+ * The screen rendered "Pagamento seguro" inline — one line below a sibling
68
+ * reading `runtime.copy.continueAction`, so the port was already there and
69
+ * already required; this string simply never asked it. That is the shape
70
+ * FUT-760 left behind: defaults removed from the CONFIG surface without the
71
+ * rendering being rewired, so a host that dutifully passes copy still gets
72
+ * the origin product's voice.
73
+ *
74
+ * OPTIONAL, on the {@link CheckoutCopyFE.returnTimedOut} precedent: a host
75
+ * that has not written the sentence gets NO caption rather than someone
76
+ * else's. Reassurance nobody chose is worth less than silence.
77
+ */
78
+ secureNotice?: string;
55
79
  /** The add-card screen (FUT-183): putting a card on file outside a purchase. */
56
80
  addCardTitle: string;
57
81
  addCardAction: string;
@@ -85,6 +85,7 @@ function buildCheckout(
85
85
 
86
86
  return (
87
87
  <CheckoutFlow
88
+ copy={runtime.copy.views}
88
89
  cart={cart}
89
90
  createOrder={createOrder}
90
91
  saveBuyerContact={ports.saveBuyerContact}
@@ -87,9 +87,11 @@ function buildBuyerDetails(runtime: FlowsRuntime): CheckoutScreens["BuyerDetails
87
87
  {runtime.copy.continueAction}
88
88
  </Button>
89
89
  </Box>
90
- <Text variant="caption" size="xs" color="secondary" as="p">
91
- Pagamento seguro
92
- </Text>
90
+ {runtime.copy.secureNotice ? (
91
+ <Text variant="caption" size="xs" color="secondary" as="p">
92
+ {runtime.copy.secureNotice}
93
+ </Text>
94
+ ) : null}
93
95
  </Box>
94
96
  );
95
97
  }
@@ -155,7 +157,7 @@ function buildEmptyCart(runtime: FlowsRuntime): CheckoutScreens["EmptyCart"] {
155
157
  return function EmptyCart() {
156
158
  return (
157
159
  <FlowsShell runtime={runtime}>
158
- <EmptyCartView onBack={runtime.config.ports.exitToCatalog} />
160
+ <EmptyCartView copy={runtime.copy.views.emptyCart} onBack={runtime.config.ports.exitToCatalog} />
159
161
  </FlowsShell>
160
162
  );
161
163
  };
@@ -82,6 +82,7 @@ function buildPaymentStatus(runtime: FlowsRuntime): CheckoutScreens["PaymentStat
82
82
  return (
83
83
  <FlowsShell runtime={runtime}>
84
84
  <PaymentStatusView
85
+ copy={runtime.copy.views.status}
85
86
  status={status}
86
87
  totalLabel={payable?.totalLabel ?? ""}
87
88
  orderId={payable?.orderId}
package/src/index.ts CHANGED
@@ -85,6 +85,20 @@ export {
85
85
  } from './components/checkout/transport';
86
86
  export { type CheckoutHostPorts } from './components/checkout/use-checkout-controller';
87
87
  export { PaymentsUnavailable } from './components/checkout/payments-unavailable';
88
+ export type {
89
+ CheckoutStepperCopy,
90
+ CheckoutViewCopy,
91
+ DadosStepCopy,
92
+ EmptyCartCopy,
93
+ PaymentStatusCopy,
94
+ PaymentsUnavailableCopy,
95
+ StatusOutcomeCopy,
96
+ } from './components/checkout/view-copy';
97
+ export {
98
+ PT_BR_CHECKOUT_VIEW_COPY,
99
+ PT_BR_PAYMENTS_UNAVAILABLE_COPY,
100
+ PT_BR_PAYMENT_STATUS_COPY,
101
+ } from './components/checkout/pt-BR';
88
102
  export { fetchCheckoutConfig } from './components/checkout/client';
89
103
  /**
90
104
  * The `sessionStorage` key the hosted-checkout return leg parks the raised