@12-apps/payments-frontend 3.21.3 → 3.22.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 +2 -2
- package/src/components/checkout/basket.ts +85 -0
- package/src/components/checkout/card-outcome.ts +81 -0
- package/src/components/checkout/card-view.tsx +62 -22
- package/src/components/checkout/checkout-actions.ts +341 -0
- package/src/components/checkout/checkout-flow.tsx +112 -18
- package/src/components/checkout/checkout-steps.tsx +149 -174
- package/src/components/checkout/checkout-totals.tsx +51 -0
- package/src/components/checkout/client-context.tsx +3 -0
- package/src/components/checkout/dados-step.tsx +141 -0
- package/src/components/checkout/decline.ts +48 -0
- package/src/components/checkout/en-US.ts +33 -0
- package/src/components/checkout/hosted-return.ts +190 -206
- package/src/components/checkout/hosted-store.ts +269 -0
- package/src/components/checkout/payment-status-parts.tsx +311 -0
- package/src/components/checkout/payment-status.tsx +69 -264
- package/src/components/checkout/poll-loop.ts +10 -12
- package/src/components/checkout/poll-rearm.ts +86 -0
- package/src/components/checkout/providers/types.ts +20 -3
- package/src/components/checkout/pt-BR.ts +33 -0
- package/src/components/checkout/screens-copy.ts +14 -0
- package/src/components/checkout/screens-en-US.ts +1 -0
- package/src/components/checkout/screens-pt-BR.ts +3 -0
- package/src/components/checkout/transport.ts +21 -1
- package/src/components/checkout/types.ts +24 -0
- package/src/components/checkout/use-card-checkout.ts +31 -31
- package/src/components/checkout/use-checkout-controller.ts +51 -271
- package/src/components/checkout/use-hosted-resume.ts +326 -0
- package/src/components/checkout/view-copy.ts +32 -0
- package/src/components/checkout/wallet-pane.tsx +3 -0
- package/src/flows/create-payment-flows.tsx +7 -0
- package/src/flows/screens-hosted.tsx +19 -2
- package/src/index.ts +22 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/payments-frontend",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.22.0",
|
|
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.
|
|
25
|
+
"@12-apps/payments-backend": "^4.27.0",
|
|
26
26
|
"react-qr-code": "^2.2.0"
|
|
27
27
|
},
|
|
28
28
|
"peerDependencies": {
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHAT IS IN THE BASKET, as one comparable fact (FUT-1213).
|
|
3
|
+
*
|
|
4
|
+
* A parked checkout has to be able to ask "is this still the basket the order
|
|
5
|
+
* was raised from?" when the buyer comes back — and the answer cannot be the
|
|
6
|
+
* cart's ID. Emptying a cart ("Esvaziar carrinho") keeps the row: the same id
|
|
7
|
+
* comes back holding nothing, then holding something else entirely, and a
|
|
8
|
+
* comparison on the id says "same basket" for a shopper who has thrown the old
|
|
9
|
+
* one away and started again.
|
|
10
|
+
*
|
|
11
|
+
* So the identity is the LINES: which ones, and how many of each. Two baskets
|
|
12
|
+
* with the same lines in a different order are the same basket; one extra unit
|
|
13
|
+
* of one line is not.
|
|
14
|
+
*
|
|
15
|
+
* The HOST computes it, because the host owns the cart — this module only says
|
|
16
|
+
* what shape the answer takes and how to build one from lines, so that every
|
|
17
|
+
* adopter's signature is built the same way and a package-side comparison can
|
|
18
|
+
* mean something.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** One line of the host's basket, reduced to what identity depends on. */
|
|
22
|
+
export interface CheckoutBasketLine {
|
|
23
|
+
/** The line's own stable handle — a cart-line id, never a product id alone. */
|
|
24
|
+
id: string;
|
|
25
|
+
quantity: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The lines' signature, or `null` for an empty basket.
|
|
30
|
+
*
|
|
31
|
+
* `null` rather than `""` because an EMPTY basket is a meaningful state on this
|
|
32
|
+
* path rather than a missing answer: the server closes a cart when its order is
|
|
33
|
+
* paid, so an empty basket is exactly what a buyer who paid comes back to.
|
|
34
|
+
*
|
|
35
|
+
* Sorted before joining, so the answer does not depend on the order the host
|
|
36
|
+
* happens to hold its lines in — a re-fetch that returns them differently
|
|
37
|
+
* sorted must not read as a different basket.
|
|
38
|
+
*
|
|
39
|
+
* The id is ENCODED before it is joined. `id + "x" + quantity` on a `|` join is
|
|
40
|
+
* ambiguous the moment an id can contain either character: `[a×1, b×2]` and the
|
|
41
|
+
* single line `["ax1|b" × 2]` produce the same string, and two different baskets
|
|
42
|
+
* that compare equal is a resume over the wrong one. Unreachable with the cuid
|
|
43
|
+
* and uuid ids every adopter has today — and this is exported for hosts whose
|
|
44
|
+
* ids nobody here has seen, so it is encoded rather than argued about.
|
|
45
|
+
*/
|
|
46
|
+
export function basketSignature(lines: readonly CheckoutBasketLine[]): string | null {
|
|
47
|
+
if (lines.length === 0) return null;
|
|
48
|
+
return lines
|
|
49
|
+
.map((line) => `${encodeURIComponent(line.id)}x${line.quantity}`)
|
|
50
|
+
.sort()
|
|
51
|
+
.join("|");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The basket in front of the checkout right now, as the resume rule reads it.
|
|
56
|
+
*
|
|
57
|
+
* `ready` is not a nicety. The host's cart is fetched, so the first render of a
|
|
58
|
+
* checkout has an EMPTY cart that is merely unloaded — and "empty" is the one
|
|
59
|
+
* state the rule resumes on unconditionally (it is the paid buyer's normal
|
|
60
|
+
* state). Deciding against an unseeded cart would resume every abandoned
|
|
61
|
+
* hand-off on every mount, which is the bug this whole rule exists to remove.
|
|
62
|
+
* So the decision waits.
|
|
63
|
+
*/
|
|
64
|
+
export interface CheckoutBasketIdentity {
|
|
65
|
+
/** The lines' signature; `null` ⇒ the basket is empty. */
|
|
66
|
+
signature: string | null;
|
|
67
|
+
/** False while the host's cart is still loading — decide nothing yet. */
|
|
68
|
+
ready: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The signature to PARK with an order, or nothing.
|
|
73
|
+
*
|
|
74
|
+
* `undefined` — meaning "no basket was recorded" — for a host that names none
|
|
75
|
+
* AND for a cart that has not answered yet. The second is the one worth
|
|
76
|
+
* stating: a loading cart reports `signature: null`, which is the value that
|
|
77
|
+
* means EMPTY, and an order parked as "raised from an empty basket" would later
|
|
78
|
+
* be compared against the real one and read as a different basket. Recording
|
|
79
|
+
* nothing is honest and degrades to the pre-1213 resume; recording `null` would
|
|
80
|
+
* be a fact we do not have.
|
|
81
|
+
*/
|
|
82
|
+
export function parkedBasket(basket: CheckoutBasketIdentity | undefined): string | null | undefined {
|
|
83
|
+
if (!basket || !basket.ready) return undefined;
|
|
84
|
+
return basket.signature;
|
|
85
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { parkedBasket, type CheckoutBasketIdentity } from "./basket";
|
|
2
|
+
import type { CheckoutDecline } from "./decline";
|
|
3
|
+
import { rememberHostedOrder } from "./hosted-return";
|
|
4
|
+
import type { CheckoutNavigate } from "./navigate-context";
|
|
5
|
+
import type { ChargeOutcome, CheckoutOrder, OnCheckoutResolved } from "./types";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* WHAT HAPPENS TO A CHARGE'S ANSWER — the two endings a card submit can have
|
|
9
|
+
* that are not "keep polling".
|
|
10
|
+
*
|
|
11
|
+
* Split out of `./use-card-checkout.ts`, which is at its size ceiling. Both
|
|
12
|
+
* halves are about the same moment: the provider has answered, and the buyer
|
|
13
|
+
* is either being sent somewhere or being told something.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Whose store and which basket a challenge's parked order belongs to. */
|
|
17
|
+
export interface ChallengeScope {
|
|
18
|
+
tenantSlug?: string;
|
|
19
|
+
basket?: CheckoutBasketIdentity;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Hand the buyer to the provider's authentication page (FUT-698) — Stripe's
|
|
24
|
+
* redirect-based 3-D Secure. Park the order and navigate, the same trip a
|
|
25
|
+
* redirect provider's link takes (FUT-556): the return lands back on this
|
|
26
|
+
* checkout route, where the hosted-resume machinery polls the parked order.
|
|
27
|
+
*
|
|
28
|
+
* IT PARKS WITH THE SAME FACTS the other hand-off does, and that is a fix
|
|
29
|
+
* rather than a tidy-up: this call site named neither the store nor the basket,
|
|
30
|
+
* and both absences are read as "no opinion" by the resume — `belongsHere`
|
|
31
|
+
* passes an entry with no slug at ANY store, and the basket rule passes an
|
|
32
|
+
* entry with no basket against ANY basket. So a 3-D Secure challenge the buyer
|
|
33
|
+
* abandoned was exempt from both the multi-tenant scoping (FUT-556) and the
|
|
34
|
+
* basket binding (FUT-1213): it resumed over whatever checkout mounted next.
|
|
35
|
+
*/
|
|
36
|
+
export function handOverToChallenge(
|
|
37
|
+
order: CheckoutOrder,
|
|
38
|
+
url: string,
|
|
39
|
+
navigate: CheckoutNavigate,
|
|
40
|
+
scope: ChallengeScope = {},
|
|
41
|
+
): void {
|
|
42
|
+
// PARK FIRST. The navigation may not come back to a live SPA at all, and a
|
|
43
|
+
// return trip that finds nothing parked lands the buyer on a blank
|
|
44
|
+
// confirmation after they have paid.
|
|
45
|
+
const basket = parkedBasket(scope.basket);
|
|
46
|
+
rememberHostedOrder(order, {
|
|
47
|
+
...(scope.tenantSlug ? { tenantSlug: scope.tenantSlug } : {}),
|
|
48
|
+
...(basket === undefined ? {} : { basket }),
|
|
49
|
+
handoff: true,
|
|
50
|
+
});
|
|
51
|
+
navigate(url);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The refusal a charge answer carries, or nothing (FUT-1145).
|
|
56
|
+
*
|
|
57
|
+
* `undefined` for an outcome with neither field — an older server, a provider
|
|
58
|
+
* whose adapter classifies nothing — so the caller makes the same one-argument
|
|
59
|
+
* call it always did and every screen below behaves exactly as before.
|
|
60
|
+
*/
|
|
61
|
+
function declineOf(outcome: ChargeOutcome): CheckoutDecline | undefined {
|
|
62
|
+
const reason = outcome.declineReason;
|
|
63
|
+
const retriable = outcome.retriable;
|
|
64
|
+
if (reason === undefined && retriable === undefined) return undefined;
|
|
65
|
+
return {
|
|
66
|
+
...(reason === undefined ? {} : { reason }),
|
|
67
|
+
...(retriable === undefined ? {} : { retriable }),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Bubble a terminal status up, carrying the refusal when there is one.
|
|
73
|
+
*
|
|
74
|
+
* Called with ONE argument when the server said nothing new, so a host on an
|
|
75
|
+
* older mount produces exactly the call it always did.
|
|
76
|
+
*/
|
|
77
|
+
export function reportResolved(outcome: ChargeOutcome, onResolved: OnCheckoutResolved): void {
|
|
78
|
+
const refusal = declineOf(outcome);
|
|
79
|
+
if (refusal) onResolved(outcome.status, refusal);
|
|
80
|
+
else onResolved(outcome.status);
|
|
81
|
+
}
|
|
@@ -11,8 +11,9 @@ import {
|
|
|
11
11
|
import { useCheckoutCopy } from "./copy-context";
|
|
12
12
|
import { UNRESOLVED_CODE } from "./failure-codes";
|
|
13
13
|
import { StalledWait } from "./stalled-wait";
|
|
14
|
+
import type { CheckoutBasketIdentity } from "./basket";
|
|
14
15
|
import type { CardChainLink } from "./method-capability";
|
|
15
|
-
import type { BuyerInfo, CheckoutOrder,
|
|
16
|
+
import type { BuyerInfo, CheckoutOrder, OnCheckoutResolved } from "./types";
|
|
16
17
|
import { useCheckoutComponents } from "./ui";
|
|
17
18
|
import { useCardCheckout, type CardCheckout } from "./use-card-checkout";
|
|
18
19
|
|
|
@@ -97,6 +98,40 @@ function ChargeFailure({
|
|
|
97
98
|
);
|
|
98
99
|
}
|
|
99
100
|
|
|
101
|
+
/**
|
|
102
|
+
* What the buyer pays WITH: the saved cards they may reuse, and the form for a
|
|
103
|
+
* new one.
|
|
104
|
+
*
|
|
105
|
+
* The picker is absent when there is nothing saved, and the form is absent
|
|
106
|
+
* while a saved card is selected — so a buyer retrying a refused card (FUT-1145)
|
|
107
|
+
* lands on the form, because nothing is preselected for them.
|
|
108
|
+
*/
|
|
109
|
+
function CardInstrumentFields({ card }: { card: CardCheckout }): JSX.Element {
|
|
110
|
+
return (
|
|
111
|
+
<>
|
|
112
|
+
{card.savedCards.length > 0 ? (
|
|
113
|
+
<SavedCardsPicker
|
|
114
|
+
savedCards={card.savedCards}
|
|
115
|
+
selection={card.selection}
|
|
116
|
+
onSelect={card.setSelection}
|
|
117
|
+
/>
|
|
118
|
+
) : null}
|
|
119
|
+
|
|
120
|
+
{card.usingNewCard ? (
|
|
121
|
+
<NewCardForm
|
|
122
|
+
card={card.card}
|
|
123
|
+
fieldErrors={card.fieldErrors}
|
|
124
|
+
brand={card.brand}
|
|
125
|
+
saveCard={card.saveCard}
|
|
126
|
+
setCard={card.setCard}
|
|
127
|
+
setFieldErrors={card.setFieldErrors}
|
|
128
|
+
onSaveCardChange={card.setSaveCard}
|
|
129
|
+
/>
|
|
130
|
+
) : null}
|
|
131
|
+
</>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
100
135
|
/**
|
|
101
136
|
* Card payment view (FUT-58). Card data is validated + formatted client-side, then
|
|
102
137
|
* tokenized (mock PagBank JS SDK) so the PAN never reaches our server; only the
|
|
@@ -115,6 +150,8 @@ export function CardView({
|
|
|
115
150
|
tenantSlug,
|
|
116
151
|
onResolved,
|
|
117
152
|
pollIntervalMs = 2500,
|
|
153
|
+
freshInstrument = false,
|
|
154
|
+
basket,
|
|
118
155
|
}: {
|
|
119
156
|
order: CheckoutOrder;
|
|
120
157
|
buyer?: BuyerInfo;
|
|
@@ -128,12 +165,33 @@ export function CardView({
|
|
|
128
165
|
providerChain?: CardChainLink[];
|
|
129
166
|
/** Scopes the saved-card list to cards the store's provider can charge. */
|
|
130
167
|
tenantSlug?: string;
|
|
131
|
-
onResolved:
|
|
168
|
+
onResolved: OnCheckoutResolved;
|
|
132
169
|
pollIntervalMs?: number;
|
|
170
|
+
/**
|
|
171
|
+
* Preselect NOTHING from the saved list (FUT-1145): the buyer is back here
|
|
172
|
+
* because a card was refused, and the card this would otherwise choose for
|
|
173
|
+
* them is that one.
|
|
174
|
+
*/
|
|
175
|
+
freshInstrument?: boolean;
|
|
176
|
+
/**
|
|
177
|
+
* WHICH basket this checkout is for (FUT-1213) — parked with the order when
|
|
178
|
+
* a 3-D Secure challenge sends the buyer to the provider's page.
|
|
179
|
+
*/
|
|
180
|
+
basket?: CheckoutBasketIdentity;
|
|
133
181
|
}): JSX.Element {
|
|
134
182
|
const { Text } = useCheckoutComponents();
|
|
135
183
|
const copy = useCheckoutCopy().screens.card;
|
|
136
|
-
const cc = useCardCheckout(
|
|
184
|
+
const cc = useCardCheckout(
|
|
185
|
+
order,
|
|
186
|
+
buyer,
|
|
187
|
+
providerConfig,
|
|
188
|
+
onResolved,
|
|
189
|
+
pollIntervalMs,
|
|
190
|
+
tenantSlug,
|
|
191
|
+
providerChain,
|
|
192
|
+
freshInstrument,
|
|
193
|
+
{ tenantSlug, basket },
|
|
194
|
+
);
|
|
137
195
|
// A charge NOBODY can confirm yet is not a decline (FUT-563). Some provider
|
|
138
196
|
// may be holding the buyer's money, so it gets its own presentation: the
|
|
139
197
|
// danger heading "Não foi possível pagar" contradicts the body's "não pague
|
|
@@ -150,25 +208,7 @@ export function CardView({
|
|
|
150
208
|
{copy.heading}
|
|
151
209
|
</Text>
|
|
152
210
|
|
|
153
|
-
{cc
|
|
154
|
-
<SavedCardsPicker
|
|
155
|
-
savedCards={cc.savedCards}
|
|
156
|
-
selection={cc.selection}
|
|
157
|
-
onSelect={cc.setSelection}
|
|
158
|
-
/>
|
|
159
|
-
) : null}
|
|
160
|
-
|
|
161
|
-
{cc.usingNewCard ? (
|
|
162
|
-
<NewCardForm
|
|
163
|
-
card={cc.card}
|
|
164
|
-
fieldErrors={cc.fieldErrors}
|
|
165
|
-
brand={cc.brand}
|
|
166
|
-
saveCard={cc.saveCard}
|
|
167
|
-
setCard={cc.setCard}
|
|
168
|
-
setFieldErrors={cc.setFieldErrors}
|
|
169
|
-
onSaveCardChange={cc.setSaveCard}
|
|
170
|
-
/>
|
|
171
|
-
) : null}
|
|
211
|
+
<CardInstrumentFields card={cc} />
|
|
172
212
|
|
|
173
213
|
{cc.error ? <ChargeFailure message={cc.error} unresolved={unresolved} /> : null}
|
|
174
214
|
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from "react";
|
|
2
|
+
|
|
3
|
+
import { buyerGateError } from "./buyer-gate";
|
|
4
|
+
import type { CheckoutDecline } from "./decline";
|
|
5
|
+
import { forgetHostedOrder, rememberHostedOrder } from "./hosted-return";
|
|
6
|
+
import { parkedBasket, type CheckoutBasketIdentity } from "./basket";
|
|
7
|
+
import type { CheckoutNavigate } from "./navigate-context";
|
|
8
|
+
import type { CheckoutScreensCopy } from "./screens-copy";
|
|
9
|
+
import type {
|
|
10
|
+
BuyerContact,
|
|
11
|
+
BuyerField,
|
|
12
|
+
BuyerInfo,
|
|
13
|
+
CheckoutCustomerField,
|
|
14
|
+
CheckoutOrder,
|
|
15
|
+
CreateOrderRequest,
|
|
16
|
+
CreateOrderResult,
|
|
17
|
+
OrderStatus,
|
|
18
|
+
PaymentMethod,
|
|
19
|
+
} from "./types";
|
|
20
|
+
import { useResumeEffect, type HostedResume } from "./use-hosted-resume";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The controller's own moving parts, one concern per hook.
|
|
24
|
+
*
|
|
25
|
+
* Split out of `./use-checkout-controller.ts` when the resumed leg grew a
|
|
26
|
+
* decision (FUT-1213), a general parked entry (FUT-1140) and a way out
|
|
27
|
+
* (FUT-1146) and that file's one exported function reached its size gate. The
|
|
28
|
+
* seam is the obvious one: everything here is state a step reads, and nothing
|
|
29
|
+
* here knows the ORDER the steps come in.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** Where the flow can be — the step ids, which are the flow's own contract. */
|
|
33
|
+
export type Step = "dados" | "payment" | "status";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The flow's navigation actions, split out of {@link useCheckoutController} for
|
|
37
|
+
* the 80-line per-function gate.
|
|
38
|
+
*
|
|
39
|
+
* `back` is where "the Dados step was skipped" has to be honoured: going back
|
|
40
|
+
* off Pagamento normally lands on Dados, but for a buyer with a CPF on file
|
|
41
|
+
* that step is not part of their flow, so the menu is the only honest
|
|
42
|
+
* destination — until they open it themselves via `editBuyer` ("alterar" on the
|
|
43
|
+
* payer block), after which it IS part of their flow and back returns to it.
|
|
44
|
+
*/
|
|
45
|
+
export function useCheckoutNav(
|
|
46
|
+
taxIdOnFile: boolean,
|
|
47
|
+
goToMenu: () => void,
|
|
48
|
+
setStep: Dispatch<SetStateAction<Step>>,
|
|
49
|
+
): { back: () => void; editBuyer: (() => void) | undefined } {
|
|
50
|
+
const [dadosOpened, setDadosOpened] = useState(false);
|
|
51
|
+
const openDados = useCallback(() => {
|
|
52
|
+
setDadosOpened(true);
|
|
53
|
+
setStep("dados");
|
|
54
|
+
}, [setStep]);
|
|
55
|
+
const back = useCallback(() => {
|
|
56
|
+
setStep((current) => {
|
|
57
|
+
if (current === "payment" && (!taxIdOnFile || dadosOpened)) return "dados";
|
|
58
|
+
goToMenu();
|
|
59
|
+
return current;
|
|
60
|
+
});
|
|
61
|
+
}, [dadosOpened, goToMenu, setStep, taxIdOnFile]);
|
|
62
|
+
// Undefined unless Dados was skipped — the payer block keys off its presence,
|
|
63
|
+
// so the decision lives here rather than being re-derived by every caller.
|
|
64
|
+
return { back, editBuyer: taxIdOnFile ? openDados : undefined };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* What the resumed leg contributes to the controller's surface.
|
|
69
|
+
*
|
|
70
|
+
* All of it is inert for a checkout that never left this tab and never raised
|
|
71
|
+
* anything: with nothing parked the poll is disabled, so the error stays null,
|
|
72
|
+
* the bound never elapses, and neither action has a wait to act on.
|
|
73
|
+
*/
|
|
74
|
+
export function resumeSurface(resume: HostedResume): {
|
|
75
|
+
resumeTimedOut: boolean;
|
|
76
|
+
resumeError: string | null;
|
|
77
|
+
resumeCheckAgain: () => void;
|
|
78
|
+
resumeRelease: (() => void) | undefined;
|
|
79
|
+
resumeReleasing: boolean;
|
|
80
|
+
} {
|
|
81
|
+
return {
|
|
82
|
+
resumeTimedOut: resume.timedOut,
|
|
83
|
+
resumeError: resume.error,
|
|
84
|
+
resumeCheckAgain: resume.checkAgain,
|
|
85
|
+
resumeRelease: resume.release,
|
|
86
|
+
resumeReleasing: resume.releasing,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Open the flow on whatever was resumed, and close it again if the buyer
|
|
92
|
+
* releases the order (FUT-1140/FUT-1146).
|
|
93
|
+
*
|
|
94
|
+
* A LAYOUT effect, through `useResumeEffect`: the decision cannot be made
|
|
95
|
+
* during render (it waits for the host's cart), and an ordinary effect runs
|
|
96
|
+
* after paint — so a buyer coming back from a payment would see one frame of
|
|
97
|
+
* the Dados or Pagamento step before their confirmation replaced it.
|
|
98
|
+
*/
|
|
99
|
+
export function useResumedCheckout(
|
|
100
|
+
resume: HostedResume,
|
|
101
|
+
setOrder: Dispatch<SetStateAction<CheckoutOrder | null>>,
|
|
102
|
+
setStep: Dispatch<SetStateAction<Step>>,
|
|
103
|
+
setFinalStatus: Dispatch<SetStateAction<OrderStatus | null>>,
|
|
104
|
+
setMethod: Dispatch<SetStateAction<PaymentMethod | null>>,
|
|
105
|
+
): void {
|
|
106
|
+
const { order, step, released } = resume;
|
|
107
|
+
useResumeEffect(() => {
|
|
108
|
+
if (!order || !step) return;
|
|
109
|
+
setOrder(order);
|
|
110
|
+
setStep(step);
|
|
111
|
+
// The METHOD comes back with it, which matters for a resume that lands on
|
|
112
|
+
// the payment step: the picker keys off it, and a shopper looking at the
|
|
113
|
+
// PIX code they were already paying must not also be asked to choose how
|
|
114
|
+
// to pay. Set through the raw setter on purpose — the public `setMethod`
|
|
115
|
+
// drops the order on a change, which is the opposite of resuming one.
|
|
116
|
+
setMethod(order.method);
|
|
117
|
+
}, [order, step, setOrder, setStep, setMethod]);
|
|
118
|
+
useResumeEffect(() => {
|
|
119
|
+
if (!released) return;
|
|
120
|
+
// Back to a checkout they can actually use, with the basket they are
|
|
121
|
+
// holding. The order they released is gone from this screen; whether it is
|
|
122
|
+
// gone at the provider is the server's answer, not ours.
|
|
123
|
+
setOrder(null);
|
|
124
|
+
setFinalStatus(null);
|
|
125
|
+
setStep("payment");
|
|
126
|
+
}, [released, setOrder, setStep, setFinalStatus]);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The end of a checkout: tell the host, and let the parked entry go.
|
|
131
|
+
*
|
|
132
|
+
* PAID fires the host's `onPaid` port. FUT-601 made the SERVER empty the cart
|
|
133
|
+
* inside the confirmation transaction — but nothing told the SPA, whose cart
|
|
134
|
+
* provider survives every checkout route change and kept counting the items the
|
|
135
|
+
* buyer had just bought. A FAILED or EXPIRED order fires nothing: that shopper
|
|
136
|
+
* still has a basket to retry with, and the host must not be told otherwise.
|
|
137
|
+
*
|
|
138
|
+
* The parked entry is dropped on ANY terminal status (FUT-1140). It exists to
|
|
139
|
+
* carry one payment across a torn-down SPA; once that payment has an answer,
|
|
140
|
+
* resuming it could only re-show an outcome the buyer has already been given.
|
|
141
|
+
*/
|
|
142
|
+
export function useSettledPort(settled: OrderStatus | null, onPaid: (() => void) | undefined): void {
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
if (!settled || settled === "AWAITING_PAYMENT") return;
|
|
145
|
+
forgetHostedOrder();
|
|
146
|
+
if (settled === "PAID") onPaid?.();
|
|
147
|
+
}, [settled, onPaid]);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The create-order refusal the steps render: what to say, which field to
|
|
152
|
+
* highlight, and the machine CODE that decides how it is presented — an
|
|
153
|
+
* unresolved charge is not a failed one, and the Pagamento step must not offer
|
|
154
|
+
* it a "Tentar novamente" (FUT-563). One hook so the three always move
|
|
155
|
+
* together; they were three `useState`s that could be cleared apart.
|
|
156
|
+
*/
|
|
157
|
+
export function useCreateFailure() {
|
|
158
|
+
const [message, setMessage] = useState<string | null>(null);
|
|
159
|
+
const [field, setField] = useState<BuyerField | null>(null);
|
|
160
|
+
const [code, setCode] = useState<string | null>(null);
|
|
161
|
+
const clear = useCallback(() => {
|
|
162
|
+
setMessage(null);
|
|
163
|
+
setField(null);
|
|
164
|
+
setCode(null);
|
|
165
|
+
}, []);
|
|
166
|
+
const fail = useCallback(
|
|
167
|
+
(next: { message: string; field?: BuyerField | null; code?: string }) => {
|
|
168
|
+
setMessage(next.message);
|
|
169
|
+
setField(next.field ?? null);
|
|
170
|
+
setCode(next.code ?? null);
|
|
171
|
+
},
|
|
172
|
+
[],
|
|
173
|
+
);
|
|
174
|
+
return { message, field, code, clear, fail };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* "Continuar" on the Dados step: gate on what the store's chain demands, then
|
|
180
|
+
* PERSIST the buyer's details before advancing.
|
|
181
|
+
*
|
|
182
|
+
* The write happens HERE and not when a payment is raised, because everything
|
|
183
|
+
* after this step can fail — no provider configured, a declined card, an
|
|
184
|
+
* abandoned PIX, a closed tab — and the details must survive all of it.
|
|
185
|
+
* Fire-and-forget on purpose: making the buyer wait on the write, or blocking
|
|
186
|
+
* them when it fails, would trade the bug for a worse one. Gated on the
|
|
187
|
+
* "salvar meus dados" consent (LGPD), which is what the checkbox means.
|
|
188
|
+
*/
|
|
189
|
+
export function useGoToPayment(input: {
|
|
190
|
+
buyer: BuyerInfo;
|
|
191
|
+
buyerFields: readonly CheckoutCustomerField[];
|
|
192
|
+
taxIdOnFile: boolean;
|
|
193
|
+
saveProfile: boolean;
|
|
194
|
+
saveBuyerContact: ((contact: BuyerContact) => void) | undefined;
|
|
195
|
+
validation: CheckoutScreensCopy["validation"];
|
|
196
|
+
failure: { clear: () => void; fail: (next: { message: string; field?: BuyerField | null }) => void };
|
|
197
|
+
setStep: Dispatch<SetStateAction<Step>>;
|
|
198
|
+
}): () => void {
|
|
199
|
+
const { buyer, buyerFields, taxIdOnFile, saveProfile, saveBuyerContact } = input;
|
|
200
|
+
const { validation, failure, setStep } = input;
|
|
201
|
+
return useCallback(() => {
|
|
202
|
+
failure.clear();
|
|
203
|
+
const complaint = buyerGateError(validation, buyer, buyerFields, taxIdOnFile);
|
|
204
|
+
if (complaint) {
|
|
205
|
+
failure.fail(complaint);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (saveProfile) {
|
|
209
|
+
saveBuyerContact?.({ name: buyer.name, phone: buyer.phone, taxId: buyer.taxId });
|
|
210
|
+
}
|
|
211
|
+
setStep("payment");
|
|
212
|
+
}, [buyer, buyerFields, saveProfile, taxIdOnFile, failure, saveBuyerContact, validation, setStep]);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* "Tentar novamente" after a refusal — and WHICH order it goes against.
|
|
217
|
+
*
|
|
218
|
+
* A RETRIABLE decline keeps the order (FUT-1145). Minting a new one for every
|
|
219
|
+
* refused card leaves a trail of failed orders in the buyer's own history for
|
|
220
|
+
* one purchase they are still trying to make, and the money rule is unchanged
|
|
221
|
+
* either way: the server refuses a second charge on a payable it has already
|
|
222
|
+
* settled. `freshInstrument` is the other half — the saved card that just
|
|
223
|
+
* failed is not chosen for them again.
|
|
224
|
+
*/
|
|
225
|
+
export function useRetryAction(input: {
|
|
226
|
+
decline: CheckoutDecline | null;
|
|
227
|
+
order: CheckoutOrder | null;
|
|
228
|
+
clearError: () => void;
|
|
229
|
+
setOrder: Dispatch<SetStateAction<CheckoutOrder | null>>;
|
|
230
|
+
setDecline: Dispatch<SetStateAction<CheckoutDecline | null>>;
|
|
231
|
+
setFinalStatus: Dispatch<SetStateAction<OrderStatus | null>>;
|
|
232
|
+
setStep: Dispatch<SetStateAction<Step>>;
|
|
233
|
+
setFreshInstrument: Dispatch<SetStateAction<boolean>>;
|
|
234
|
+
}): () => void {
|
|
235
|
+
const { decline, order, clearError, setOrder, setDecline } = input;
|
|
236
|
+
const { setFinalStatus, setStep, setFreshInstrument } = input;
|
|
237
|
+
return useCallback(() => {
|
|
238
|
+
setFinalStatus(null);
|
|
239
|
+
clearError();
|
|
240
|
+
setStep("payment");
|
|
241
|
+
if (decline?.retriable && order) {
|
|
242
|
+
setFreshInstrument(true);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
setOrder(null);
|
|
246
|
+
setDecline(null);
|
|
247
|
+
forgetHostedOrder();
|
|
248
|
+
}, [
|
|
249
|
+
clearError, decline, order, setDecline, setFinalStatus, setFreshInstrument, setOrder, setStep,
|
|
250
|
+
]);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Hand the buyer to a redirect provider's own page, if that is where this
|
|
255
|
+
* charge settles (FUT-556).
|
|
256
|
+
*
|
|
257
|
+
* Called BEFORE the order is stored: storing it first would render the PIX or
|
|
258
|
+
* card step for a provider that returned neither, which is the dead end this
|
|
259
|
+
* fixes.
|
|
260
|
+
*
|
|
261
|
+
* A full navigation rather than the host's router, because the destination is
|
|
262
|
+
* another origin. The return trip comes back to this same checkout route
|
|
263
|
+
* carrying `transaction_nsu` + `slug`, which the status poll already reads.
|
|
264
|
+
*
|
|
265
|
+
* @returns true when the buyer is on their way and the caller must stop.
|
|
266
|
+
*/
|
|
267
|
+
function handOverToProvider(
|
|
268
|
+
order: CheckoutOrder,
|
|
269
|
+
navigate: CheckoutNavigate,
|
|
270
|
+
tenantSlug?: string,
|
|
271
|
+
basket?: CheckoutBasketIdentity,
|
|
272
|
+
): boolean {
|
|
273
|
+
if (!order.hostedCheckoutUrl) return false;
|
|
274
|
+
// PARK FIRST, navigate second. The order is the only thing the return trip
|
|
275
|
+
// has to rehydrate from, and the navigation may tear this SPA down before
|
|
276
|
+
// any later write lands.
|
|
277
|
+
//
|
|
278
|
+
// The STORE goes with it: one tab holds one slot, and on a multi-tenant
|
|
279
|
+
// storefront every store shares an origin. Without the slug, abandoning this
|
|
280
|
+
// hand-off and opening another store's checkout resumed THIS order there.
|
|
281
|
+
//
|
|
282
|
+
// So does the BASKET (FUT-1213): a hand-off nobody completed must not resume
|
|
283
|
+
// itself over the shopper's next basket, and the only way to tell the two
|
|
284
|
+
// apart later is to record which basket this one was raised from.
|
|
285
|
+
rememberHostedOrder(order, {
|
|
286
|
+
tenantSlug,
|
|
287
|
+
basket: parkedBasket(basket),
|
|
288
|
+
handoff: true,
|
|
289
|
+
});
|
|
290
|
+
navigate(order.hostedCheckoutUrl);
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Raise the order for a chosen method, and decide what happens to it.
|
|
296
|
+
*
|
|
297
|
+
* Three outcomes, in order: a refusal the step renders, a HAND-OFF that leaves
|
|
298
|
+
* this page for the provider's own, and an order raised here.
|
|
299
|
+
*/
|
|
300
|
+
export function useStartPayment(input: {
|
|
301
|
+
buyer: BuyerInfo;
|
|
302
|
+
saveProfile: boolean;
|
|
303
|
+
createOrder: (request: CreateOrderRequest) => Promise<CreateOrderResult>;
|
|
304
|
+
navigate: CheckoutNavigate;
|
|
305
|
+
tenantSlug: string | undefined;
|
|
306
|
+
basket: CheckoutBasketIdentity | undefined;
|
|
307
|
+
failure: { clear: () => void; fail: (next: { message: string; field?: BuyerField | null; code?: string }) => void };
|
|
308
|
+
setCreating: Dispatch<SetStateAction<boolean>>;
|
|
309
|
+
setDecline: Dispatch<SetStateAction<CheckoutDecline | null>>;
|
|
310
|
+
setOrder: Dispatch<SetStateAction<CheckoutOrder | null>>;
|
|
311
|
+
setFinalStatus: Dispatch<SetStateAction<OrderStatus | null>>;
|
|
312
|
+
}): (chosen: PaymentMethod, override?: BuyerInfo) => Promise<void> {
|
|
313
|
+
const { buyer, saveProfile, createOrder, navigate, tenantSlug, basket, failure } = input;
|
|
314
|
+
const { setCreating, setDecline, setOrder, setFinalStatus } = input;
|
|
315
|
+
return useCallback(
|
|
316
|
+
async (chosen: PaymentMethod, override?: BuyerInfo) => {
|
|
317
|
+
failure.clear();
|
|
318
|
+
setDecline(null);
|
|
319
|
+
setCreating(true);
|
|
320
|
+
const result = await createOrder({ method: chosen, buyer: override ?? buyer, saveProfile });
|
|
321
|
+
setCreating(false);
|
|
322
|
+
if (!result.ok) {
|
|
323
|
+
failure.fail(result.error);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (handOverToProvider(result.data, navigate, tenantSlug, basket)) return;
|
|
327
|
+
// PARKED EVEN THOUGH NOBODY IS LEAVING (FUT-1140). A low-memory phone
|
|
328
|
+
// discards this tab while the shopper is in their bank app, and the SPA
|
|
329
|
+
// that comes back has never heard of the order it raised — so the buyer
|
|
330
|
+
// meets an empty cart and a retry button instead of the confirmation for
|
|
331
|
+
// the payment they just made.
|
|
332
|
+
rememberHostedOrder(result.data, { tenantSlug, basket: parkedBasket(basket) });
|
|
333
|
+
setOrder(result.data);
|
|
334
|
+
setFinalStatus(null);
|
|
335
|
+
},
|
|
336
|
+
[
|
|
337
|
+
buyer, saveProfile, createOrder, failure, navigate, tenantSlug, basket,
|
|
338
|
+
setCreating, setDecline, setOrder, setFinalStatus,
|
|
339
|
+
],
|
|
340
|
+
);
|
|
341
|
+
}
|