@12-apps/payments-frontend 3.21.4 → 3.23.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 +387 -0
- package/src/components/checkout/checkout-flow.tsx +126 -24
- 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/confirmation-wait.ts +97 -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 +41 -0
- package/src/components/checkout/failure-codes.ts +82 -0
- package/src/components/checkout/hosted-return.ts +190 -206
- package/src/components/checkout/hosted-store.ts +291 -0
- package/src/components/checkout/payment-error-panel.tsx +9 -3
- package/src/components/checkout/payment-status-parts.tsx +311 -0
- package/src/components/checkout/payment-status.tsx +69 -264
- package/src/components/checkout/pix-view.tsx +97 -8
- package/src/components/checkout/poll-loop.ts +5 -3
- package/src/components/checkout/providers/types.ts +20 -3
- package/src/components/checkout/pt-BR.ts +42 -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 +35 -0
- package/src/components/checkout/use-card-checkout.ts +34 -33
- package/src/components/checkout/use-checkout-controller.ts +68 -274
- package/src/components/checkout/use-hosted-resume.ts +326 -0
- package/src/components/checkout/use-payment-polling.ts +58 -7
- package/src/components/checkout/use-wallet-charge.ts +24 -1
- package/src/components/checkout/view-copy.ts +70 -0
- package/src/components/checkout/wallet-pane.tsx +9 -1
- package/src/flows/catalog-exit.ts +33 -0
- package/src/flows/create-payment-flows.tsx +19 -1
- package/src/flows/pipeline/actions.tsx +104 -0
- package/src/flows/pipeline/admission.ts +55 -0
- package/src/flows/pipeline/context.ts +140 -0
- package/src/flows/pipeline/derive-step.ts +234 -0
- package/src/flows/pipeline/engine-actions.ts +257 -0
- package/src/flows/pipeline/engine-chrome.tsx +123 -0
- package/src/flows/pipeline/engine-state.ts +107 -0
- package/src/flows/pipeline/engine.tsx +377 -0
- package/src/flows/pipeline/methods.ts +71 -0
- package/src/flows/pipeline/refusal-routing.ts +106 -0
- package/src/flows/pipeline/slices.ts +110 -0
- package/src/flows/pipeline/stable-plugins.ts +72 -0
- package/src/flows/pipeline/steps/buyer-steps.tsx +297 -0
- package/src/flows/pipeline/steps/index.ts +54 -0
- package/src/flows/pipeline/steps/pay-steps.tsx +182 -0
- package/src/flows/pipeline/steps/status-step.tsx +41 -0
- package/src/flows/pipeline/types.ts +232 -0
- package/src/flows/public.ts +78 -0
- package/src/flows/screens-hosted.tsx +55 -5
- package/src/flows/screens-pay.tsx +6 -1
- package/src/flows/types.ts +25 -2
- package/src/index.ts +29 -19
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/payments-frontend",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.23.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.1",
|
|
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
|
|