@12-apps/payments-frontend 3.19.0 → 3.20.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/components/checkout/card-view.tsx +30 -28
- package/src/components/checkout/checkout-flow.tsx +40 -20
- package/src/components/checkout/en-US.ts +9 -0
- package/src/components/checkout/one-click.tsx +179 -0
- package/src/components/checkout/payment-status.tsx +130 -38
- package/src/components/checkout/pix-view.tsx +34 -12
- package/src/components/checkout/pt-BR.ts +7 -0
- package/src/components/checkout/screens-copy.ts +19 -0
- package/src/components/checkout/screens-en-US.ts +4 -0
- package/src/components/checkout/screens-pt-BR.ts +2 -0
- package/src/components/checkout/stalled-wait.tsx +58 -0
- package/src/components/checkout/use-card-checkout.ts +53 -11
- package/src/components/checkout/use-checkout-controller.ts +48 -13
- package/src/components/checkout/use-payment-polling.ts +250 -64
- package/src/components/checkout/use-wallet-charge.ts +20 -6
- package/src/components/checkout/view-copy.ts +15 -0
- package/src/components/checkout/wallet-pane.tsx +17 -16
- package/src/flows/copy.ts +19 -0
- package/src/flows/screens-hosted.tsx +72 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/payments-frontend",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.20.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"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.",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"storybook:build": "storybook build"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@12-apps/payments-backend": "^4.26.
|
|
25
|
+
"@12-apps/payments-backend": "^4.26.1",
|
|
26
26
|
"react-qr-code": "^2.2.0"
|
|
27
27
|
},
|
|
28
28
|
"peerDependencies": {
|
|
@@ -10,45 +10,47 @@ import {
|
|
|
10
10
|
|
|
11
11
|
import { useCheckoutCopy } from "./copy-context";
|
|
12
12
|
import { UNRESOLVED_CODE } from "./failure-codes";
|
|
13
|
+
import { StalledWait } from "./stalled-wait";
|
|
13
14
|
import type { CardChainLink } from "./method-capability";
|
|
14
15
|
import type { BuyerInfo, CheckoutOrder, OrderStatus } from "./types";
|
|
15
16
|
import { useCheckoutComponents } from "./ui";
|
|
16
|
-
import { useCardCheckout } from "./use-card-checkout";
|
|
17
|
+
import { useCardCheckout, type CardCheckout } from "./use-card-checkout";
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
|
-
* Post-submit confirmation state
|
|
20
|
-
*
|
|
21
|
-
* order stays AWAITING server-side and is
|
|
22
|
-
* backfill
|
|
20
|
+
* Post-submit confirmation state: timeout > error > spinner.
|
|
21
|
+
*
|
|
22
|
+
* The elapsed wall-clock wait leads (the order stays AWAITING server-side and is
|
|
23
|
+
* recoverable by webhook/reconcile/backfill, which is what its copy says), then
|
|
24
|
+
* a poll that cannot reach us — a warning saying we are STILL TRYING, where
|
|
25
|
+
* FUT-1144 found a danger Alert over a wait that had actually given up — then
|
|
26
|
+
* the ordinary bounded spinner.
|
|
27
|
+
*
|
|
28
|
+
* That order inverted with the meaning of the two flags. An error used to BE the
|
|
29
|
+
* ending; now the clock is, and a wait that failed its way to the clock carries
|
|
30
|
+
* both. "We keep trying" over a wait nothing is scheduled for is the lie.
|
|
23
31
|
*/
|
|
24
|
-
function SubmittedState({
|
|
25
|
-
|
|
26
|
-
pollTimedOut,
|
|
27
|
-
}: {
|
|
28
|
-
pollError: string | null;
|
|
29
|
-
pollTimedOut: boolean;
|
|
30
|
-
}): JSX.Element {
|
|
31
|
-
const { Alert, LoadingState } = useCheckoutComponents();
|
|
32
|
+
function SubmittedState({ card }: { card: CardCheckout }): JSX.Element {
|
|
33
|
+
const { LoadingState } = useCheckoutComponents();
|
|
32
34
|
const copy = useCheckoutCopy().screens.settling;
|
|
33
|
-
if (
|
|
35
|
+
if (card.pollTimedOut) {
|
|
34
36
|
return (
|
|
35
|
-
<
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
37
|
+
<StalledWait
|
|
38
|
+
title={copy.takingLonger}
|
|
39
|
+
description={copy.takingLongerHelp}
|
|
40
|
+
onCheckAgain={card.pollCheckAgain}
|
|
41
|
+
testId="card-poll-timeout"
|
|
42
|
+
actionTestId="card-check-again"
|
|
41
43
|
/>
|
|
42
44
|
);
|
|
43
45
|
}
|
|
44
|
-
if (
|
|
46
|
+
if (card.pollError) {
|
|
45
47
|
return (
|
|
46
|
-
<
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
<StalledWait
|
|
49
|
+
title={copy.connectionLost}
|
|
50
|
+
description={card.pollError}
|
|
51
|
+
onCheckAgain={card.pollCheckAgain}
|
|
52
|
+
testId="card-poll-error"
|
|
53
|
+
actionTestId="card-check-again"
|
|
52
54
|
/>
|
|
53
55
|
);
|
|
54
56
|
}
|
|
@@ -139,7 +141,7 @@ export function CardView({
|
|
|
139
141
|
const unresolved = cc.errorCode === UNRESOLVED_CODE;
|
|
140
142
|
|
|
141
143
|
if (cc.submitted) {
|
|
142
|
-
return <SubmittedState
|
|
144
|
+
return <SubmittedState card={cc} />;
|
|
143
145
|
}
|
|
144
146
|
|
|
145
147
|
return (
|
|
@@ -7,6 +7,7 @@ import { ArrowBackIcon } from "./icons";
|
|
|
7
7
|
import { PaymentStatus } from "./payment-status";
|
|
8
8
|
import type { BuyerInfo, CheckoutProviderConfig, SettlementCheckout } from "./types";
|
|
9
9
|
import { CheckoutCopyProvider } from "./copy-context";
|
|
10
|
+
import { OneClickProvider, useOneClick } from "./one-click";
|
|
10
11
|
import { CheckoutComponentsProvider, useCheckoutComponents, type CheckoutComponents } from "./ui";
|
|
11
12
|
import type { CheckoutViewCopy } from "./view-copy";
|
|
12
13
|
import { useCheckoutController, type CheckoutHostPorts } from "./use-checkout-controller";
|
|
@@ -59,6 +60,17 @@ export interface CheckoutFlowProps extends CheckoutHostPorts {
|
|
|
59
60
|
providerConfig?: CheckoutProviderConfig | null;
|
|
60
61
|
/** Scopes the saved-card list to the store being paid. */
|
|
61
62
|
tenantSlug?: string;
|
|
63
|
+
/**
|
|
64
|
+
* The buyer pressed a BUY button rather than opening a checkout — pay with
|
|
65
|
+
* their saved card and land them on Confirmação, with no tap in between.
|
|
66
|
+
*
|
|
67
|
+
* A REQUEST, never an instruction: it is honoured only where it can be, and
|
|
68
|
+
* degrades to the ordinary flow everywhere else — a store that finishes on
|
|
69
|
+
* the provider's page, a buyer with no CPF on file, a buyer with no saved
|
|
70
|
+
* card. See `./one-click.tsx` for the whole decision and why every clause
|
|
71
|
+
* narrows toward standing down.
|
|
72
|
+
*/
|
|
73
|
+
oneClick?: boolean;
|
|
62
74
|
/**
|
|
63
75
|
* The host's Apple Pay merchant-validation port (FUT-472): exchange the
|
|
64
76
|
* session's `validationURL` for an Apple merchant session, SERVER-SIDE.
|
|
@@ -162,18 +174,24 @@ function StatusStep({
|
|
|
162
174
|
onBackToMenu={c.goToMenu}
|
|
163
175
|
paidExtra={confirmationExtra}
|
|
164
176
|
awaitingTimedOut={c.resumeTimedOut}
|
|
177
|
+
// The resumed leg's own trouble, and the way out of it (FUT-1144). Both
|
|
178
|
+
// are inert for a checkout that never left this tab — nothing was parked,
|
|
179
|
+
// so nothing is being polled here.
|
|
180
|
+
awaitingError={c.resumeError}
|
|
181
|
+
onCheckAgain={c.resumeCheckAgain}
|
|
165
182
|
/>
|
|
166
183
|
);
|
|
167
184
|
}
|
|
168
185
|
|
|
169
186
|
function CheckoutFlowBody(props: Omit<CheckoutFlowProps, "components">): JSX.Element {
|
|
170
|
-
const { copy, cart, defaultBuyer, settlement, taxIdOnFile = false, providerConfig, tenantSlug, confirmationExtra, validateApplePayMerchant, ...ports } = props;
|
|
187
|
+
const { copy, cart, defaultBuyer, settlement, taxIdOnFile = false, providerConfig, tenantSlug, confirmationExtra, validateApplePayMerchant, oneClick = false, ...ports } = props;
|
|
171
188
|
// Resolved for NO method on purpose (FUT-595): the Dados step opens before
|
|
172
189
|
// the picker, and the form is filled once — so it asks for the union of what
|
|
173
190
|
// any chain member may need rather than re-opening after the choice. A chain
|
|
174
191
|
// that declares nothing degrades to CPF-required, never to "ask nothing".
|
|
175
192
|
const buyerFields = useMemo(() => buyerFieldsFor(providerConfig?.chain, null), [providerConfig]);
|
|
176
193
|
const c = useCheckoutController(ports, defaultBuyer, taxIdOnFile, buyerFields, tenantSlug);
|
|
194
|
+
const armed = useOneClick({ requested: oneClick, config: providerConfig, taxIdOnFile, step: c.step, method: c.method, setMethod: c.setMethod });
|
|
177
195
|
|
|
178
196
|
// A settlement settlement pays already-sent kitchen items — the cart is
|
|
179
197
|
// legitimately empty here, so the empty-cart guard only applies to cart mode.
|
|
@@ -210,25 +228,27 @@ function CheckoutFlowBody(props: Omit<CheckoutFlowProps, "components">): JSX.Ele
|
|
|
210
228
|
) : null}
|
|
211
229
|
|
|
212
230
|
{c.step === "payment" ? (
|
|
213
|
-
<
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
231
|
+
<OneClickProvider armed={armed}>
|
|
232
|
+
<PaymentStep
|
|
233
|
+
method={c.method}
|
|
234
|
+
onMethodChange={c.setMethod}
|
|
235
|
+
order={c.order}
|
|
236
|
+
buyer={c.buyer}
|
|
237
|
+
creating={c.creating}
|
|
238
|
+
createError={c.createError}
|
|
239
|
+
errorField={c.errorField}
|
|
240
|
+
errorCode={c.errorCode}
|
|
241
|
+
onGenerate={(chosen) => void c.startPayment(chosen)}
|
|
242
|
+
onUseEmail={c.payWithEmail}
|
|
243
|
+
// Set only for a skipped-Dados flow (the controller decides); the
|
|
244
|
+
// payer block hides itself when it is absent.
|
|
245
|
+
onEditBuyer={c.editBuyer}
|
|
246
|
+
providerConfig={providerConfig}
|
|
247
|
+
tenantSlug={tenantSlug}
|
|
248
|
+
validateApplePayMerchant={validateApplePayMerchant}
|
|
249
|
+
onResolved={c.handleResolved}
|
|
250
|
+
/>
|
|
251
|
+
</OneClickProvider>
|
|
232
252
|
) : null}
|
|
233
253
|
|
|
234
254
|
{c.step === "status" ? (
|
|
@@ -40,8 +40,17 @@ export const EN_US_PAYMENT_STATUS_COPY: PaymentStatusCopy = {
|
|
|
40
40
|
"If you have already paid, the order is confirmed as soon as the provider tells us — " +
|
|
41
41
|
"do not pay again. You can close this screen.",
|
|
42
42
|
},
|
|
43
|
+
awaitingUnreachable: {
|
|
44
|
+
heading: "We cannot reach the payment right now",
|
|
45
|
+
// "do not pay again" leads the second sentence for the same reason it
|
|
46
|
+
// leads `awaitingTimedOut`: a second payment is the expensive mistake.
|
|
47
|
+
support:
|
|
48
|
+
"We are still trying. If you have already paid, do not pay again — " +
|
|
49
|
+
"the order is confirmed as soon as the provider tells us.",
|
|
50
|
+
},
|
|
43
51
|
retryAction: "Try again",
|
|
44
52
|
regenerateAction: "Generate a new code",
|
|
53
|
+
checkAgainAction: "Check again",
|
|
45
54
|
backAction: "Back to the menu",
|
|
46
55
|
amountLabel: "Amount paid",
|
|
47
56
|
referenceLabel: "Order",
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ONE-CLICK checkout — the buyer who already decided (FUT-1070).
|
|
3
|
+
*
|
|
4
|
+
* A storefront can offer a BUY button beside a product or a past order: press
|
|
5
|
+
* it and the shopper expects to have bought, not to be handed a form. The
|
|
6
|
+
* whole flow that follows already exists — Pagamento raises the order, the
|
|
7
|
+
* card path charges a saved instrument, Confirmação reports the outcome — and
|
|
8
|
+
* every step of it is a tap the buyer has already made by pressing that
|
|
9
|
+
* button. So one-click makes those taps, in order, and reaches the same
|
|
10
|
+
* terminal screen through the same code the ordinary flow uses.
|
|
11
|
+
*
|
|
12
|
+
* That reuse is the design, not an economy. A second charge path would be a
|
|
13
|
+
* second answer to "what does paying with a saved card do", and the two would
|
|
14
|
+
* eventually disagree about failover instruments, the unresolved-charge rule,
|
|
15
|
+
* or the poll cap — each of which is money.
|
|
16
|
+
*
|
|
17
|
+
* ## It arms, or it stands down. It never guesses.
|
|
18
|
+
*
|
|
19
|
+
* `armedFor` is the whole decision, and every clause narrows toward
|
|
20
|
+
* NOT arming, because the failure directions are not symmetric: standing down
|
|
21
|
+
* costs a buyer the taps they would have made anyway, and arming wrongly
|
|
22
|
+
* charges a card nobody chose.
|
|
23
|
+
*
|
|
24
|
+
* - **No request** — the host did not ask. This is every ordinary checkout.
|
|
25
|
+
* - **No CPF on file** — the buyer still has a Dados step to fill (FUT-465),
|
|
26
|
+
* so there is no tap to skip and the flow opens where it always did. In
|
|
27
|
+
* practice a buyer with a saved card has one; a buyer without one is a buyer
|
|
28
|
+
* we have never charged.
|
|
29
|
+
* - **No protocol yet** (`config === null`, still loading or a fetch blip) —
|
|
30
|
+
* the ordinary flow fails OPEN here and renders a picker the server may
|
|
31
|
+
* refuse, which costs a tap. Arming on the same guess would raise a charge.
|
|
32
|
+
* - **The choice is not ours to ask** — a store that finishes on the
|
|
33
|
+
* provider's own page has no card path in this browser at all, and the one
|
|
34
|
+
* thing one-click must never do is redirect a checkout the moment it
|
|
35
|
+
* renders. This is the InfinitePay shape, and it is why a store on a hosted
|
|
36
|
+
* provider degrades to the ordinary hand-off screen with nothing else
|
|
37
|
+
* changed.
|
|
38
|
+
* - **No card path** — the chain declares no CARD entry this browser can mint
|
|
39
|
+
* or charge for.
|
|
40
|
+
*
|
|
41
|
+
* The last condition cannot be answered here at all: **whether the buyer has a
|
|
42
|
+
* saved card**. That list is fetched by the card path itself, scoped to the
|
|
43
|
+
* store, and asking for it twice would be two answers to one question. So an
|
|
44
|
+
* armed flow selects the card tile and the card view does the rest —
|
|
45
|
+
* {@link useOneClickPay} pays only once a SAVED card is the selection, which
|
|
46
|
+
* is a state the picker can only reach after the list came back non-empty. A
|
|
47
|
+
* buyer with no saved card therefore lands on Pagamento with the picker and
|
|
48
|
+
* the card form, which is exactly the ordinary step 2.
|
|
49
|
+
*/
|
|
50
|
+
import {
|
|
51
|
+
createContext,
|
|
52
|
+
useContext,
|
|
53
|
+
useEffect,
|
|
54
|
+
useRef,
|
|
55
|
+
type JSX,
|
|
56
|
+
type ReactNode,
|
|
57
|
+
} from "react";
|
|
58
|
+
|
|
59
|
+
import { cardPathAvailable } from "./method-capability";
|
|
60
|
+
import { methodChosenAtProvider } from "./providers/registry";
|
|
61
|
+
import type { CheckoutProviderConfig, PaymentMethod } from "./types";
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Whether the flow above this subtree is running as one-click.
|
|
65
|
+
*
|
|
66
|
+
* CONTEXT rather than a prop, because the consumer is the card path — four
|
|
67
|
+
* layers down, behind the published `ProviderCheckoutScreen` contract that
|
|
68
|
+
* every provider screen implements. Threading a flag through it would widen a
|
|
69
|
+
* contract that three screens share for the benefit of one, and would oblige
|
|
70
|
+
* an out-of-tree screen to forward a prop it has no use for.
|
|
71
|
+
*/
|
|
72
|
+
const OneClickContext = createContext(false);
|
|
73
|
+
|
|
74
|
+
/** Arm (or explicitly disarm) one-click for everything below. */
|
|
75
|
+
export function OneClickProvider({
|
|
76
|
+
armed,
|
|
77
|
+
children,
|
|
78
|
+
}: {
|
|
79
|
+
armed: boolean;
|
|
80
|
+
children: ReactNode;
|
|
81
|
+
}): JSX.Element {
|
|
82
|
+
return <OneClickContext.Provider value={armed}>{children}</OneClickContext.Provider>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Whether this subtree is a one-click checkout. `false` outside a provider. */
|
|
86
|
+
export function useOneClickArmed(): boolean {
|
|
87
|
+
return useContext(OneClickContext);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Can this store honour a one-click request right now? See the module comment
|
|
92
|
+
* for why every clause narrows toward `false`.
|
|
93
|
+
*
|
|
94
|
+
* `step` is what keeps the answer honest over TIME rather than only at mount:
|
|
95
|
+
* a resumed hosted return opens on Confirmação, and a buyer who walked back to
|
|
96
|
+
* Dados is a buyer who took over. Neither is a checkout that should still be
|
|
97
|
+
* charging on its own.
|
|
98
|
+
*/
|
|
99
|
+
function armedFor(input: OneClickFlow): boolean {
|
|
100
|
+
const { requested, config, taxIdOnFile, step } = input;
|
|
101
|
+
if (!requested || !taxIdOnFile || step !== "payment" || !config) return false;
|
|
102
|
+
if (methodChosenAtProvider(config.chain?.[0]?.checkoutScreen, config)) return false;
|
|
103
|
+
return cardPathAvailable(config);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** What the flow knows that decides whether one-click may run, and how it starts. */
|
|
107
|
+
interface OneClickFlow {
|
|
108
|
+
/** The host asked for one-click on this checkout. */
|
|
109
|
+
requested: boolean;
|
|
110
|
+
/** The store's published protocol; `null` while it is still unknown. */
|
|
111
|
+
config: CheckoutProviderConfig | null | undefined;
|
|
112
|
+
/** The buyer's CPF is already saved, so there is no Dados step to fill. */
|
|
113
|
+
taxIdOnFile: boolean;
|
|
114
|
+
/** Which step the flow is showing — one-click only ever runs on Pagamento. */
|
|
115
|
+
step: string;
|
|
116
|
+
/** The method currently selected, or `null` before any choice. */
|
|
117
|
+
method: PaymentMethod | null;
|
|
118
|
+
/** Selecting a method — the same event a picker tile press is. */
|
|
119
|
+
setMethod: (method: PaymentMethod) => void;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Arm one-click for this render, and take the card tile for the buyer ONCE
|
|
124
|
+
* when it is armed.
|
|
125
|
+
*
|
|
126
|
+
* Selecting a method is what raises the order (`useAutoRaiseOrder`), so that
|
|
127
|
+
* one call starts everything downstream — and it is the same event a tile
|
|
128
|
+
* press is, which is why nothing else in the flow has to change.
|
|
129
|
+
*
|
|
130
|
+
* Once only, by ref. `setMethod` clears any order raised for a previous
|
|
131
|
+
* method, so a re-fire would discard a live charge and raise a second; and a
|
|
132
|
+
* buyer who switches to PIX after this ran must be allowed to stay there.
|
|
133
|
+
*/
|
|
134
|
+
export function useOneClick(flow: OneClickFlow): boolean {
|
|
135
|
+
const armed = armedFor(flow);
|
|
136
|
+
const { method, setMethod } = flow;
|
|
137
|
+
const taken = useRef(false);
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
if (!armed || taken.current || method !== null) return;
|
|
140
|
+
taken.current = true;
|
|
141
|
+
setMethod("CARD");
|
|
142
|
+
}, [armed, method, setMethod]);
|
|
143
|
+
return armed;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Press "Pagar" for the buyer, ONCE, when a saved card is what is selected.
|
|
148
|
+
*
|
|
149
|
+
* `ready` is the caller's whole precondition and is deliberately narrow: a
|
|
150
|
+
* SAVED card is the current selection and no charge is in flight, has landed,
|
|
151
|
+
* or has already failed. It can only become true after the instrument list
|
|
152
|
+
* came back with something, which is what makes "the buyer has no saved card"
|
|
153
|
+
* a silent stand-down rather than a branch.
|
|
154
|
+
*
|
|
155
|
+
* The submit is held in a ref because `handlePay` is rebuilt every render;
|
|
156
|
+
* depending on it directly would re-run this effect constantly and leave the
|
|
157
|
+
* once-only guard as the only thing between a shopper and a second charge.
|
|
158
|
+
* One guard for one job: the ref fires it, `fired` decides whether it may.
|
|
159
|
+
*
|
|
160
|
+
* A DECLINE is terminal for one-click, and that is the point of listing
|
|
161
|
+
* `error` in `ready`: the buyer's own "Pagar R$ …" comes back under the
|
|
162
|
+
* refusal, and retrying a declined card automatically is how a shopper gets
|
|
163
|
+
* three identical declines they never asked for.
|
|
164
|
+
*/
|
|
165
|
+
export function useOneClickPay(input: {
|
|
166
|
+
armed: boolean;
|
|
167
|
+
ready: boolean;
|
|
168
|
+
pay: () => Promise<void>;
|
|
169
|
+
}): void {
|
|
170
|
+
const { armed, ready } = input;
|
|
171
|
+
const submit = useRef(input.pay);
|
|
172
|
+
submit.current = input.pay;
|
|
173
|
+
const fired = useRef(false);
|
|
174
|
+
useEffect(() => {
|
|
175
|
+
if (!armed || !ready || fired.current) return;
|
|
176
|
+
fired.current = true;
|
|
177
|
+
void submit.current();
|
|
178
|
+
}, [armed, ready]);
|
|
179
|
+
}
|
|
@@ -4,7 +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
|
+
import type { PaymentStatusCopy, StatusOutcomeCopy } from "./view-copy";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* The last screen of checkout.
|
|
@@ -79,37 +79,65 @@ function orderReference(orderId: string): string {
|
|
|
79
79
|
return orderId.replace(/-/g, "").slice(0, 8).toUpperCase();
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/** How the wait itself is going, when it has not resolved into an outcome. */
|
|
83
|
+
interface WaitState {
|
|
84
|
+
/** The bounded wall-clock wait elapsed — nothing further is scheduled. */
|
|
85
|
+
timedOut: boolean;
|
|
86
|
+
/** The last poll failed, and the wait is still running (FUT-1144). */
|
|
87
|
+
unreachable: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Which of AWAITING's three faces this is.
|
|
92
|
+
*
|
|
93
|
+
* STOPPED beats STILL TRYING, and the order is the whole honesty of the screen.
|
|
94
|
+
* A wait that ran its clock out while failing carries BOTH flags — the last
|
|
95
|
+
* poll's error is still the last thing that happened — and saying "we keep
|
|
96
|
+
* trying" over a wait nothing is scheduled for is precisely the lie this ticket
|
|
97
|
+
* exists to remove. The elapsed state is also the one carrying "não pague de
|
|
98
|
+
* novo", which is the sentence that matters most when we have stopped looking.
|
|
99
|
+
*
|
|
100
|
+
* Both keep AWAITING's neutral clock icon and take WARNING's tone: the order is
|
|
101
|
+
* not resolved, and calm-but-alert is the visual for that.
|
|
102
|
+
*/
|
|
103
|
+
function awaitingFace(
|
|
104
|
+
copy: PaymentStatusCopy,
|
|
105
|
+
wait: WaitState,
|
|
106
|
+
): { outcome: StatusOutcomeCopy; tone: OutcomeVisual["tone"]; testId: string } | null {
|
|
107
|
+
if (wait.timedOut) {
|
|
108
|
+
return { outcome: copy.awaitingTimedOut, tone: "warning", testId: "payment-awaiting-timeout" };
|
|
109
|
+
}
|
|
110
|
+
if (wait.unreachable) {
|
|
111
|
+
return { outcome: copy.awaitingUnreachable, tone: "warning", testId: "payment-awaiting-unreachable" };
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
82
116
|
/** The headline block: icon, outcome, and one supporting line. */
|
|
83
117
|
function OutcomeHero({
|
|
84
118
|
copy,
|
|
85
119
|
status,
|
|
86
|
-
|
|
120
|
+
wait,
|
|
87
121
|
}: {
|
|
88
122
|
copy: PaymentStatusCopy;
|
|
89
123
|
status: OrderStatus;
|
|
90
|
-
|
|
124
|
+
wait: WaitState;
|
|
91
125
|
}): JSX.Element {
|
|
92
126
|
const { Text } = useCheckoutComponents();
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const visual = timedOutWait
|
|
97
|
-
? { icon: OUTCOME_VISUAL.AWAITING_PAYMENT.icon, tone: "warning" as const }
|
|
127
|
+
const face = status === "AWAITING_PAYMENT" ? awaitingFace(copy, wait) : null;
|
|
128
|
+
const visual = face
|
|
129
|
+
? { icon: OUTCOME_VISUAL.AWAITING_PAYMENT.icon, tone: face.tone }
|
|
98
130
|
: OUTCOME_VISUAL[status];
|
|
99
|
-
const outcome =
|
|
131
|
+
const outcome = face ? face.outcome : copy[OUTCOME_COPY_KEY[status]];
|
|
100
132
|
return (
|
|
101
133
|
<Box
|
|
102
134
|
// `payment-paid` is load-bearing for the storefront journeys — it is how
|
|
103
|
-
// they assert the buyer actually got there.
|
|
135
|
+
// they assert the buyer actually got there. Each unsettled wait gets its
|
|
104
136
|
// OWN id rather than reusing `payment-awaiting_payment`: a test that
|
|
105
|
-
// cannot tell "still asking" from "stopped asking"
|
|
106
|
-
// pass against the
|
|
137
|
+
// cannot tell "still asking" from "stopped asking" from "cannot reach the
|
|
138
|
+
// payment" is a test that would pass against the spinner this replaced.
|
|
107
139
|
data-testid={
|
|
108
|
-
|
|
109
|
-
? "payment-awaiting-timeout"
|
|
110
|
-
: status === "PAID"
|
|
111
|
-
? "payment-paid"
|
|
112
|
-
: `payment-${status.toLowerCase()}`
|
|
140
|
+
face ? face.testId : status === "PAID" ? "payment-paid" : `payment-${status.toLowerCase()}`
|
|
113
141
|
}
|
|
114
142
|
sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 1, textAlign: "center" }}
|
|
115
143
|
>
|
|
@@ -176,23 +204,41 @@ function PaidFacts({
|
|
|
176
204
|
);
|
|
177
205
|
}
|
|
178
206
|
|
|
179
|
-
/** The next-action row: retry / regenerate
|
|
207
|
+
/** The next-action row: retry / regenerate / check-again, always back-to-menu. */
|
|
180
208
|
function StatusActions({
|
|
181
209
|
copy,
|
|
182
210
|
status,
|
|
183
211
|
onRetry,
|
|
184
212
|
onRegenerate,
|
|
213
|
+
onCheckAgain,
|
|
185
214
|
onBackToMenu,
|
|
186
215
|
}: {
|
|
187
216
|
copy: PaymentStatusCopy;
|
|
188
217
|
status: OrderStatus;
|
|
189
218
|
onRetry?: () => void;
|
|
190
219
|
onRegenerate?: () => void;
|
|
220
|
+
/**
|
|
221
|
+
* Offered only while the wait is unsettled AND not visibly working — the
|
|
222
|
+
* caller decides that; here it is simply present or absent. A button under a
|
|
223
|
+
* healthy spinner would invite a tap that changes nothing.
|
|
224
|
+
*/
|
|
225
|
+
onCheckAgain?: () => void;
|
|
191
226
|
onBackToMenu: () => void;
|
|
192
227
|
}): JSX.Element {
|
|
193
228
|
const { Button } = useCheckoutComponents();
|
|
194
229
|
return (
|
|
195
230
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
|
231
|
+
{onCheckAgain ? (
|
|
232
|
+
<Button
|
|
233
|
+
variant="solid"
|
|
234
|
+
color="primary"
|
|
235
|
+
size="lg"
|
|
236
|
+
onClick={onCheckAgain}
|
|
237
|
+
dataTestId="payment-check-again"
|
|
238
|
+
>
|
|
239
|
+
{copy.checkAgainAction}
|
|
240
|
+
</Button>
|
|
241
|
+
) : null}
|
|
196
242
|
{status === "FAILED" && onRetry ? (
|
|
197
243
|
<Button variant="solid" color="primary" size="lg" onClick={onRetry} dataTestId="payment-retry">
|
|
198
244
|
{copy.retryAction}
|
|
@@ -218,18 +264,8 @@ function StatusActions({
|
|
|
218
264
|
);
|
|
219
265
|
}
|
|
220
266
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
status,
|
|
224
|
-
totalLabel,
|
|
225
|
-
orderId,
|
|
226
|
-
buyerEmail,
|
|
227
|
-
onRetry,
|
|
228
|
-
onRegenerate,
|
|
229
|
-
onBackToMenu,
|
|
230
|
-
paidExtra,
|
|
231
|
-
awaitingTimedOut = false,
|
|
232
|
-
}: {
|
|
267
|
+
/** What the last screen of checkout is handed. */
|
|
268
|
+
interface PaymentStatusProps {
|
|
233
269
|
/** Every sentence and label this screen renders — the HOST's words. */
|
|
234
270
|
copy: PaymentStatusCopy;
|
|
235
271
|
status: OrderStatus | null;
|
|
@@ -249,14 +285,69 @@ export function PaymentStatus({
|
|
|
249
285
|
*/
|
|
250
286
|
paidExtra?: ReactNode;
|
|
251
287
|
/**
|
|
252
|
-
* The wait has been given up on
|
|
253
|
-
*
|
|
254
|
-
*
|
|
288
|
+
* The wait has been given up on. Only meaningful while AWAITING_PAYMENT;
|
|
289
|
+
* every other status has already resolved, so a stale flag cannot change what
|
|
290
|
+
* a settled screen says.
|
|
255
291
|
*/
|
|
256
292
|
awaitingTimedOut?: boolean;
|
|
257
|
-
|
|
293
|
+
/**
|
|
294
|
+
* The wait's last poll failed (FUT-1144). Same scope rule as
|
|
295
|
+
* {@link awaitingTimedOut}, and it YIELDS to it: a wait that failed its way to
|
|
296
|
+
* the wall clock has both, and the honest thing to say then is that we have
|
|
297
|
+
* stopped asking.
|
|
298
|
+
*/
|
|
299
|
+
awaitingError?: string | null;
|
|
300
|
+
/**
|
|
301
|
+
* Ask now. Rendered ONLY while the automatic wait is not visibly working —
|
|
302
|
+
* unreachable, or elapsed — so the buyer always has something to press when
|
|
303
|
+
* the spinner cannot honestly stand for progress, and nothing extra to think
|
|
304
|
+
* about when it can.
|
|
305
|
+
*/
|
|
306
|
+
onCheckAgain?: () => void;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* The wait has stopped LOOKING like progress: it cannot reach us, or it has run
|
|
311
|
+
* its clock out. Either way the spinner would be a lie and the buyer is owed
|
|
312
|
+
* something to press. Scoped to AWAITING because every other status has already
|
|
313
|
+
* resolved, so a stale flag cannot change what a settled screen says.
|
|
314
|
+
*/
|
|
315
|
+
function isStalled(status: OrderStatus, wait: WaitState): boolean {
|
|
316
|
+
if (status !== "AWAITING_PAYMENT") return false;
|
|
317
|
+
return wait.timedOut || wait.unreachable;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* The check-again action, but only where it can honestly be offered — a button
|
|
322
|
+
* under a healthy spinner invites a tap that changes nothing.
|
|
323
|
+
*/
|
|
324
|
+
function offeredCheckAgain(
|
|
325
|
+
stalled: boolean,
|
|
326
|
+
onCheckAgain: (() => void) | undefined,
|
|
327
|
+
): (() => void) | undefined {
|
|
328
|
+
return stalled ? onCheckAgain : undefined;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function PaymentStatus({
|
|
332
|
+
copy,
|
|
333
|
+
status,
|
|
334
|
+
totalLabel,
|
|
335
|
+
orderId,
|
|
336
|
+
buyerEmail,
|
|
337
|
+
onRetry,
|
|
338
|
+
onRegenerate,
|
|
339
|
+
onBackToMenu,
|
|
340
|
+
paidExtra,
|
|
341
|
+
awaitingTimedOut = false,
|
|
342
|
+
awaitingError = null,
|
|
343
|
+
onCheckAgain,
|
|
344
|
+
}: PaymentStatusProps): JSX.Element {
|
|
258
345
|
const { LoadingState } = useCheckoutComponents();
|
|
259
346
|
const effective: OrderStatus = status ?? "AWAITING_PAYMENT";
|
|
347
|
+
const wait: WaitState = { timedOut: awaitingTimedOut, unreachable: awaitingError !== null };
|
|
348
|
+
const stalled = isStalled(effective, wait);
|
|
349
|
+
const paid = effective === "PAID";
|
|
350
|
+
const spinning = effective === "AWAITING_PAYMENT" && !stalled;
|
|
260
351
|
|
|
261
352
|
return (
|
|
262
353
|
<Box
|
|
@@ -265,15 +356,15 @@ export function PaymentStatus({
|
|
|
265
356
|
data-timed-out={awaitingTimedOut ? "true" : undefined}
|
|
266
357
|
sx={{ display: "flex", flexDirection: "column", gap: 3, alignItems: "stretch", py: 2 }}
|
|
267
358
|
>
|
|
268
|
-
<OutcomeHero copy={copy} status={effective}
|
|
359
|
+
<OutcomeHero copy={copy} status={effective} wait={wait} />
|
|
269
360
|
|
|
270
|
-
{
|
|
361
|
+
{paid ? (
|
|
271
362
|
<PaidFacts copy={copy} totalLabel={totalLabel} orderId={orderId} buyerEmail={buyerEmail} />
|
|
272
363
|
) : null}
|
|
273
364
|
|
|
274
|
-
{
|
|
365
|
+
{paid ? paidExtra : null}
|
|
275
366
|
|
|
276
|
-
{
|
|
367
|
+
{spinning ? (
|
|
277
368
|
<LoadingState variant="spinner" size="md" message="" dataTestId="payment-pending" />
|
|
278
369
|
) : null}
|
|
279
370
|
|
|
@@ -282,6 +373,7 @@ export function PaymentStatus({
|
|
|
282
373
|
status={effective}
|
|
283
374
|
onRetry={onRetry}
|
|
284
375
|
onRegenerate={onRegenerate}
|
|
376
|
+
onCheckAgain={offeredCheckAgain(stalled, onCheckAgain)}
|
|
285
377
|
onBackToMenu={onBackToMenu}
|
|
286
378
|
/>
|
|
287
379
|
</Box>
|