@12-apps/payments-frontend 3.5.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 +4 -4
- package/src/components/checkout/checkout-flow.tsx +56 -23
- package/src/components/checkout/checkout-steps.tsx +60 -31
- package/src/components/checkout/method-capability.ts +27 -0
- package/src/components/checkout/method-choice.ts +64 -0
- package/src/components/checkout/payment-status.tsx +50 -62
- package/src/components/checkout/payments-unavailable.tsx +14 -5
- package/src/components/checkout/providers/capability-default.tsx +1 -35
- package/src/components/checkout/providers/hands-over.ts +43 -0
- package/src/components/checkout/providers/hosted-link.tsx +113 -12
- package/src/components/checkout/providers/registry.ts +30 -0
- package/src/components/checkout/providers/types.ts +19 -1
- package/src/components/checkout/pt-BR.ts +74 -0
- package/src/components/checkout/types.ts +11 -0
- package/src/components/checkout/view-copy.ts +94 -0
- package/src/flows/copy.ts +27 -3
- package/src/flows/create-payment-flows.tsx +1 -0
- package/src/flows/screens-buyer.tsx +6 -4
- package/src/flows/screens-pay.tsx +1 -0
- package/src/index.ts +14 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/payments-frontend",
|
|
3
|
-
"version": "3.
|
|
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.
|
|
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.
|
|
32
|
-
"@12-apps/typescript-config": "^1.20.
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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" ?
|
|
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={
|
|
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
|
-
<
|
|
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
|
);
|
|
@@ -3,12 +3,7 @@ import { useEffect, useRef, type JSX, type ReactNode } from "react";
|
|
|
3
3
|
|
|
4
4
|
import { BuyerInfoForm } from "./buyer-info-form";
|
|
5
5
|
import { LockOutlinedIcon } from "./icons";
|
|
6
|
-
import {
|
|
7
|
-
cardPathAvailable,
|
|
8
|
-
offeredMethods,
|
|
9
|
-
selectableMethods,
|
|
10
|
-
usePreselectSoleMethod,
|
|
11
|
-
} from "./method-capability";
|
|
6
|
+
import { useMethodChoice } from "./method-choice";
|
|
12
7
|
import { MethodPicker } from "./method-picker";
|
|
13
8
|
import { PaymentErrorPanel } from "./payment-error-panel";
|
|
14
9
|
import { PayerSummary } from "./payer-summary";
|
|
@@ -22,6 +17,7 @@ import type {
|
|
|
22
17
|
OrderStatus,
|
|
23
18
|
PaymentMethod,
|
|
24
19
|
} from "./types";
|
|
20
|
+
import type { DadosStepCopy, EmptyCartCopy } from "./view-copy";
|
|
25
21
|
import { useCheckoutComponents } from "./ui";
|
|
26
22
|
|
|
27
23
|
/**
|
|
@@ -42,8 +38,8 @@ function useAutoRaiseOrder(
|
|
|
42
38
|
requestedFor.current = null;
|
|
43
39
|
return;
|
|
44
40
|
}
|
|
45
|
-
// No method chosen yet ⇒ show only the picker
|
|
46
|
-
//
|
|
41
|
+
// No method chosen yet ⇒ show only the picker (or, for a hand-off store,
|
|
42
|
+
// its "Seguir para o pagamento"); raise the order once the buyer commits.
|
|
47
43
|
if (!method || creating || createError || requestedFor.current === method) return;
|
|
48
44
|
requestedFor.current = method;
|
|
49
45
|
onGenerate(method);
|
|
@@ -70,6 +66,8 @@ function PaymentBody({
|
|
|
70
66
|
method,
|
|
71
67
|
tenantSlug,
|
|
72
68
|
onResolved,
|
|
69
|
+
onStart,
|
|
70
|
+
creating,
|
|
73
71
|
pollIntervalMs,
|
|
74
72
|
validateApplePayMerchant,
|
|
75
73
|
}: {
|
|
@@ -79,6 +77,9 @@ function PaymentBody({
|
|
|
79
77
|
method: PaymentMethod | null;
|
|
80
78
|
tenantSlug?: string;
|
|
81
79
|
onResolved: (status: OrderStatus) => void;
|
|
80
|
+
/** Set only when the shell hid its picker — see {@link PaymentStep}. */
|
|
81
|
+
onStart?: () => void;
|
|
82
|
+
creating: boolean;
|
|
82
83
|
pollIntervalMs?: number;
|
|
83
84
|
validateApplePayMerchant?: (validationURL: string) => Promise<unknown>;
|
|
84
85
|
}): JSX.Element | null {
|
|
@@ -91,6 +92,8 @@ function PaymentBody({
|
|
|
91
92
|
method={method}
|
|
92
93
|
tenantSlug={tenantSlug}
|
|
93
94
|
onResolved={onResolved}
|
|
95
|
+
onStart={onStart}
|
|
96
|
+
creating={creating}
|
|
94
97
|
pollIntervalMs={pollIntervalMs}
|
|
95
98
|
validateApplePayMerchant={validateApplePayMerchant}
|
|
96
99
|
/>
|
|
@@ -98,15 +101,15 @@ function PaymentBody({
|
|
|
98
101
|
}
|
|
99
102
|
|
|
100
103
|
/** Empty-cart state shown when there's nothing to check out. */
|
|
101
|
-
export function EmptyCart({ onBack }: { onBack: () => void }): JSX.Element {
|
|
104
|
+
export function EmptyCart({ copy, onBack }: { copy: EmptyCartCopy; onBack: () => void }): JSX.Element {
|
|
102
105
|
const { Button, Text } = useCheckoutComponents();
|
|
103
106
|
return (
|
|
104
107
|
<Box data-testid="checkout-empty" sx={{ py: 8, textAlign: "center", display: "flex", flexDirection: "column", gap: 2, alignItems: "center" }}>
|
|
105
108
|
<Text variant="heading" size="md" as="p">
|
|
106
|
-
|
|
109
|
+
{copy.title}
|
|
107
110
|
</Text>
|
|
108
111
|
<Button variant="solid" color="primary" size="md" onClick={onBack} dataTestId="checkout-empty-back">
|
|
109
|
-
|
|
112
|
+
{copy.action}
|
|
110
113
|
</Button>
|
|
111
114
|
</Box>
|
|
112
115
|
);
|
|
@@ -156,6 +159,7 @@ function PayBarTotal({
|
|
|
156
159
|
* — no charge yet.
|
|
157
160
|
*/
|
|
158
161
|
export function DadosStep({
|
|
162
|
+
copy,
|
|
159
163
|
buyer,
|
|
160
164
|
onBuyerChange,
|
|
161
165
|
saveProfile,
|
|
@@ -168,6 +172,8 @@ export function DadosStep({
|
|
|
168
172
|
discountLines,
|
|
169
173
|
totalOverride,
|
|
170
174
|
}: {
|
|
175
|
+
/** The step's own sentences — the HOST's words (see `./view-copy`). */
|
|
176
|
+
copy: DadosStepCopy;
|
|
171
177
|
buyer: BuyerInfo;
|
|
172
178
|
onBuyerChange: (buyer: BuyerInfo) => void;
|
|
173
179
|
saveProfile: boolean;
|
|
@@ -204,12 +210,13 @@ export function DadosStep({
|
|
|
204
210
|
<Checkbox
|
|
205
211
|
checked={saveProfile}
|
|
206
212
|
onChange={(_event, checked) => onSaveProfileChange(checked)}
|
|
207
|
-
label=
|
|
213
|
+
label={copy.saveProfile}
|
|
208
214
|
data-testid="buyer-save-profile"
|
|
209
215
|
/>
|
|
210
216
|
</Box>
|
|
211
217
|
|
|
212
218
|
<DadosPayBar
|
|
219
|
+
copy={copy}
|
|
213
220
|
totalLabel={totalLabel}
|
|
214
221
|
totalItems={totalItems}
|
|
215
222
|
createError={createError}
|
|
@@ -225,12 +232,14 @@ export function DadosStep({
|
|
|
225
232
|
|
|
226
233
|
/** The sticky "Continuar" bar: the refusal, the money, and the one action. */
|
|
227
234
|
function DadosPayBar({
|
|
235
|
+
copy,
|
|
228
236
|
totalLabel,
|
|
229
237
|
totalItems,
|
|
230
238
|
createError,
|
|
231
239
|
onContinue,
|
|
232
240
|
children,
|
|
233
241
|
}: {
|
|
242
|
+
copy: DadosStepCopy;
|
|
234
243
|
totalLabel: string;
|
|
235
244
|
totalItems: number;
|
|
236
245
|
createError: string | null;
|
|
@@ -242,20 +251,22 @@ function DadosPayBar({
|
|
|
242
251
|
<ActionBar dataTestId="checkout-pay-bar">
|
|
243
252
|
<Box sx={{ width: "100%", display: "flex", flexDirection: "column", gap: 1.5 }}>
|
|
244
253
|
{createError ? (
|
|
245
|
-
<Alert variant="danger" title=
|
|
254
|
+
<Alert variant="danger" title={copy.cannotContinueTitle} description={createError} showIcon data-testid="checkout-error" />
|
|
246
255
|
) : null}
|
|
247
256
|
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
|
|
248
257
|
<PayBarTotal totalLabel={totalLabel} totalItems={totalItems}>{children}</PayBarTotal>
|
|
249
258
|
<Box sx={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 0.5, minWidth: 0 }}>
|
|
250
259
|
<Button variant="solid" color="primary" size="lg" fullWidth onClick={onContinue} dataTestId="checkout-continue">
|
|
251
|
-
|
|
260
|
+
{copy.continueAction}
|
|
252
261
|
</Button>
|
|
253
|
-
|
|
254
|
-
<
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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}
|
|
259
270
|
</Box>
|
|
260
271
|
</Box>
|
|
261
272
|
</Box>
|
|
@@ -303,6 +314,17 @@ interface PaymentStepProps {
|
|
|
303
314
|
* Step 2 "Pagamento" — pick PIX or card and pay on the SAME page. Selecting a
|
|
304
315
|
* method auto-raises its order and reveals its UI (PIX QR / card form) with no
|
|
305
316
|
* intermediate tap; switching method clears the previous order (controller).
|
|
317
|
+
*
|
|
318
|
+
* ## Unless the choice is not ours to ask
|
|
319
|
+
*
|
|
320
|
+
* A store that finishes checkout on the provider's own page gets NO picker
|
|
321
|
+
* here (`methodChosenAtProvider`). Its screen renders a single "Seguir para o
|
|
322
|
+
* pagamento" instead, and pressing it selects the store's hand-off method —
|
|
323
|
+
* which is the same event a tile press is, so the auto-raise, the error panel
|
|
324
|
+
* and the retry below all keep working unchanged. Preselection is suppressed
|
|
325
|
+
* for the same flow, and deliberately: it exists to spare a buyer a tap that
|
|
326
|
+
* buys them nothing, but here the tap is the buyer's consent to LEAVE, and
|
|
327
|
+
* taking it for them would redirect a checkout the moment it rendered.
|
|
306
328
|
*/
|
|
307
329
|
export function PaymentStep({
|
|
308
330
|
method,
|
|
@@ -323,35 +345,42 @@ export function PaymentStep({
|
|
|
323
345
|
onResolved,
|
|
324
346
|
}: PaymentStepProps): JSX.Element {
|
|
325
347
|
const { LoadingState } = useCheckoutComponents();
|
|
326
|
-
const
|
|
327
|
-
const
|
|
348
|
+
const config = providerConfig ?? null;
|
|
349
|
+
const choice = useMethodChoice(config, method, onMethodChange);
|
|
328
350
|
useAutoRaiseOrder(order, method, creating, createError, onGenerate);
|
|
329
|
-
usePreselectSoleMethod(selectableMethods(offered, cardUnavailable), method, onMethodChange);
|
|
330
351
|
|
|
331
352
|
return (
|
|
332
353
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 3 }}>
|
|
333
354
|
{/* Self-hiding: renders only for a flow whose Dados step was skipped. */}
|
|
334
355
|
<PayerSummary name={buyer.name} taxId={buyer.taxId} onEdit={onEditBuyer} />
|
|
335
356
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
357
|
+
{choice.atProvider ? null : (
|
|
358
|
+
<MethodPicker
|
|
359
|
+
value={method}
|
|
360
|
+
onChange={onMethodChange}
|
|
361
|
+
cardUnavailable={choice.cardUnavailable}
|
|
362
|
+
offered={choice.offered}
|
|
363
|
+
/>
|
|
364
|
+
)}
|
|
342
365
|
|
|
343
366
|
<PaymentBody
|
|
344
367
|
order={order}
|
|
345
368
|
buyer={buyer}
|
|
346
|
-
providerConfig={
|
|
369
|
+
providerConfig={config}
|
|
347
370
|
method={method}
|
|
348
371
|
tenantSlug={tenantSlug}
|
|
349
372
|
onResolved={onResolved}
|
|
373
|
+
onStart={choice.onStart}
|
|
374
|
+
creating={creating}
|
|
350
375
|
pollIntervalMs={pollIntervalMs}
|
|
351
376
|
validateApplePayMerchant={validateApplePayMerchant}
|
|
352
377
|
/>
|
|
353
378
|
|
|
354
|
-
{
|
|
379
|
+
{/* The shell's own busy spinner, SUPPRESSED for a hand-off screen: that
|
|
380
|
+
screen renders its own "Preparando o pagamento" while the charge is
|
|
381
|
+
raised, and two stacked spinners saying the same thing is what the
|
|
382
|
+
buyer actually saw. */}
|
|
383
|
+
{!order && creating && !choice.atProvider ? (
|
|
355
384
|
<LoadingState variant="spinner" size="md" message="Gerando pagamento…" dataTestId="payment-generating" />
|
|
356
385
|
) : null}
|
|
357
386
|
|
|
@@ -216,6 +216,33 @@ export function selectableMethods(
|
|
|
216
216
|
);
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
+
/**
|
|
220
|
+
* The method a HAND-OFF checkout raises on the buyer's behalf, when the buyer
|
|
221
|
+
* was never asked (see `providers/registry.ts`'s `methodChosenAtProvider`).
|
|
222
|
+
*
|
|
223
|
+
* A charge still has to be raised with SOME method — that is the wire's shape,
|
|
224
|
+
* and the link is minted by raising it — but for a hand-off store the value is
|
|
225
|
+
* provisional by construction: the buyer picks for real on the provider's page,
|
|
226
|
+
* and the settlement reports back which one they actually used. So the choice
|
|
227
|
+
* here is about which request the SERVER will honour, not about what the buyer
|
|
228
|
+
* gets.
|
|
229
|
+
*
|
|
230
|
+
* PIX when the chain offers it, because PIX is the one method whose first
|
|
231
|
+
* charge is always raised immediately: a CARD request at a store that can
|
|
232
|
+
* tokenize somewhere in its chain is answered with the bare order instead, and
|
|
233
|
+
* there is no hand-off link on that answer to send anyone anywhere. Otherwise
|
|
234
|
+
* the chain's first declared method, since a store that cannot PIX would have
|
|
235
|
+
* the walk refuse a PIX charge outright.
|
|
236
|
+
*
|
|
237
|
+
* `null` offered — still loading, or a fetch blip — cannot reach here: the
|
|
238
|
+
* picker is only hidden for a store whose config already said it hands over.
|
|
239
|
+
* PIX is the safe reading of it anyway, for the reason above.
|
|
240
|
+
*/
|
|
241
|
+
export function handOffMethod(offered: PaymentMethod[] | null): PaymentMethod {
|
|
242
|
+
if (!offered || offered.includes("PIX")) return "PIX";
|
|
243
|
+
return offered[0] ?? "PIX";
|
|
244
|
+
}
|
|
245
|
+
|
|
219
246
|
/**
|
|
220
247
|
* A SOLE remaining method is not a choice — take it (FUT-697 review, widened by
|
|
221
248
|
* FUT-741).
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHO asks the buyer PIX-or-card, and what the Pagamento step does about it.
|
|
3
|
+
*
|
|
4
|
+
* Its own module because the answer is a set of derived facts that must move
|
|
5
|
+
* together: a picker rendered for a store that hands the buyer over, or a
|
|
6
|
+
* hand-off CTA rendered beside a picker, are both a checkout asking one
|
|
7
|
+
* question twice — which is the defect this seam exists to make impossible.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
cardPathAvailable,
|
|
11
|
+
handOffMethod,
|
|
12
|
+
offeredMethods,
|
|
13
|
+
selectableMethods,
|
|
14
|
+
usePreselectSoleMethod,
|
|
15
|
+
} from "./method-capability";
|
|
16
|
+
import { methodChosenAtProvider } from "./providers/registry";
|
|
17
|
+
import type { CheckoutProviderConfig, PaymentMethod } from "./types";
|
|
18
|
+
|
|
19
|
+
/** What the Pagamento step needs to know about WHO asks the buyer for a method. */
|
|
20
|
+
interface MethodChoice {
|
|
21
|
+
/** The store's active provider has no card path in this browser. */
|
|
22
|
+
cardUnavailable: boolean;
|
|
23
|
+
/** The methods the chain declares it can charge, or `null` while unknown. */
|
|
24
|
+
offered: PaymentMethod[] | null;
|
|
25
|
+
/** The choice is made on the provider's page ⇒ render no picker here. */
|
|
26
|
+
atProvider: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* The hand-off screen's "start paying" port, or `undefined` when the picker
|
|
29
|
+
* is on the page and owns that job instead. It SELECTS the store's hand-off
|
|
30
|
+
* method, which is the same event a tile press is — so the auto-raise, the
|
|
31
|
+
* error panel and its retry all keep working with no second code path.
|
|
32
|
+
*/
|
|
33
|
+
onStart?: () => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve who asks the buyer PIX-or-card, and preselect a sole method when the
|
|
38
|
+
* question is ours to ask.
|
|
39
|
+
*/
|
|
40
|
+
export function useMethodChoice(
|
|
41
|
+
config: CheckoutProviderConfig | null,
|
|
42
|
+
method: PaymentMethod | null,
|
|
43
|
+
onMethodChange: (method: PaymentMethod) => void,
|
|
44
|
+
): MethodChoice {
|
|
45
|
+
const cardUnavailable = !cardPathAvailable(config);
|
|
46
|
+
const offered = offeredMethods(config);
|
|
47
|
+
const atProvider = methodChosenAtProvider(config?.chain?.[0]?.checkoutScreen, config);
|
|
48
|
+
// Nothing to preselect when the screen owns the choice: the whole point of
|
|
49
|
+
// its button is that the buyer presses it. Preselection exists to spare them
|
|
50
|
+
// a tap that buys them nothing, and here the tap is their consent to LEAVE —
|
|
51
|
+
// taking it for them would redirect a checkout the moment it rendered.
|
|
52
|
+
usePreselectSoleMethod(
|
|
53
|
+
atProvider ? [] : selectableMethods(offered, cardUnavailable),
|
|
54
|
+
method,
|
|
55
|
+
onMethodChange,
|
|
56
|
+
);
|
|
57
|
+
if (!atProvider) return { cardUnavailable, offered, atProvider };
|
|
58
|
+
return {
|
|
59
|
+
cardUnavailable,
|
|
60
|
+
offered,
|
|
61
|
+
atProvider,
|
|
62
|
+
onStart: () => onMethodChange(handOffMethod(offered)),
|
|
63
|
+
};
|
|
64
|
+
}
|