@12-apps/payments-frontend 3.22.0 → 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/checkout-actions.ts +55 -9
- package/src/components/checkout/checkout-flow.tsx +15 -7
- package/src/components/checkout/confirmation-wait.ts +97 -0
- package/src/components/checkout/en-US.ts +8 -0
- package/src/components/checkout/failure-codes.ts +82 -0
- package/src/components/checkout/hosted-store.ts +23 -1
- package/src/components/checkout/payment-error-panel.tsx +9 -3
- package/src/components/checkout/pix-view.tsx +97 -8
- package/src/components/checkout/poll-loop.ts +5 -3
- package/src/components/checkout/pt-BR.ts +9 -0
- package/src/components/checkout/types.ts +11 -0
- package/src/components/checkout/use-card-checkout.ts +3 -2
- package/src/components/checkout/use-checkout-controller.ts +19 -5
- 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 +38 -0
- package/src/components/checkout/wallet-pane.tsx +6 -1
- package/src/flows/catalog-exit.ts +33 -0
- package/src/flows/create-payment-flows.tsx +12 -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 +37 -4
- package/src/flows/screens-pay.tsx +6 -1
- package/src/flows/types.ts +25 -2
- package/src/index.ts +7 -19
|
@@ -169,8 +169,9 @@ type CardSubmit = Pick<
|
|
|
169
169
|
* It was 36 healthy polls, which is the same 90 s at the default 2500 ms
|
|
170
170
|
* interval and an unbounded wait at any other — including the one that
|
|
171
171
|
* mattered, where every poll is FAILING and the healthy count never moves.
|
|
172
|
-
* PIX
|
|
173
|
-
*
|
|
172
|
+
* PIX is bounded too since FUT-1170, but by the CHARGE's own expiry rather
|
|
173
|
+
* than by a constant: a code the buyer can still scan is still worth watching,
|
|
174
|
+
* and one that has died is not.
|
|
174
175
|
*/
|
|
175
176
|
const CARD_AWAITING_WAIT_MS = 90_000;
|
|
176
177
|
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
useStartPayment,
|
|
13
13
|
type Step,
|
|
14
14
|
} from "./checkout-actions";
|
|
15
|
+
import { useConfirmationWait } from "./confirmation-wait";
|
|
15
16
|
import { useCheckoutCopy } from "./copy-context";
|
|
16
17
|
|
|
17
18
|
import { forgetHostedOrder } from "./hosted-return";
|
|
@@ -124,10 +125,14 @@ export function useCheckoutController(
|
|
|
124
125
|
const { back, editBuyer } = useCheckoutNav(taxIdOnFile, onExitToMenu, setStep);
|
|
125
126
|
const setMethod = useCallback((next: PaymentMethod) => {
|
|
126
127
|
setMethodState((prev) => {
|
|
127
|
-
|
|
128
|
+
// SCOPED (FUT-1240): the order dropped here is THIS store's, and so is
|
|
129
|
+
// the parked entry that goes with it. Unscoped, changing method at store
|
|
130
|
+
// B threw away store A's parked hand-off — the same cross-store slot the
|
|
131
|
+
// slug closed on the read side, still open on the write side.
|
|
132
|
+
if (prev !== next) { setOrder(null); forgetHostedOrder(tenantSlug); clearError(); }
|
|
128
133
|
return next;
|
|
129
134
|
});
|
|
130
|
-
}, [clearError]);
|
|
135
|
+
}, [clearError, tenantSlug]);
|
|
131
136
|
const goToPayment = useGoToPayment({
|
|
132
137
|
buyer, buyerFields, taxIdOnFile, saveProfile, saveBuyerContact, validation, failure, setStep,
|
|
133
138
|
});
|
|
@@ -150,13 +155,22 @@ export function useCheckoutController(
|
|
|
150
155
|
decline, order, clearError, setOrder, setDecline, setFinalStatus, setStep, setFreshInstrument,
|
|
151
156
|
});
|
|
152
157
|
const completed = useMemo(() => new Set(STEP_ORDER.slice(0, STEP_ORDER.indexOf(step))), [step]);
|
|
153
|
-
|
|
158
|
+
const settled = finalStatus ?? resume.status;
|
|
159
|
+
useSettledPort(settled, onPaid);
|
|
160
|
+
// The confirmation screen's own wait (FUT-1170). Live only where nothing else
|
|
161
|
+
// is polling and nothing has answered: a resumed leg brings its own wait, and
|
|
162
|
+
// every method's screen polls for itself on the payment step.
|
|
163
|
+
const confirming = useConfirmationWait({
|
|
164
|
+
orderId: order?.orderId ?? null,
|
|
165
|
+
active: step === "status" && settled === null && resume.order === null,
|
|
166
|
+
onSettled: setFinalStatus,
|
|
167
|
+
});
|
|
154
168
|
|
|
155
169
|
return {
|
|
156
170
|
step, setStep, method, setMethod, buyer, setBuyer, saveProfile, setSaveProfile,
|
|
157
|
-
order, finalStatus:
|
|
171
|
+
order, finalStatus: settled, creating,
|
|
158
172
|
decline, freshInstrument,
|
|
159
|
-
...resumeSurface(resume),
|
|
173
|
+
...resumeSurface(resume, confirming),
|
|
160
174
|
createError: failure.message, errorField: failure.field, errorCode: failure.code,
|
|
161
175
|
goToMenu: onExitToMenu, back, editBuyer,
|
|
162
176
|
goToPayment, startPayment, payWithEmail, handleResolved, retry, completed,
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { useCallback, useEffect, useRef, useState } from "react";
|
|
1
|
+
import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from "react";
|
|
2
2
|
|
|
3
3
|
import { useCheckoutClientApi } from "./client-context";
|
|
4
4
|
import { useCheckoutCopy } from "./copy-context";
|
|
5
|
-
import { createPollLoop, type PollLoop, type PollingOptions } from "./poll-loop";
|
|
5
|
+
import { createPollLoop, type PollLoop, type PollingOptions, type PollSink } from "./poll-loop";
|
|
6
6
|
import type { OrderStatus } from "./types";
|
|
7
7
|
|
|
8
8
|
/** What a consumer reads off the wait, and the one action it can take. */
|
|
@@ -24,6 +24,50 @@ interface PaymentPollingState {
|
|
|
24
24
|
checkAgain: () => void;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* What one wait has learned, and WHICH order it learned it about (FUT-1170).
|
|
29
|
+
*
|
|
30
|
+
* The three facts used to be three `useState`s with no order id attached, so a
|
|
31
|
+
* consumer re-pointed at a new charge read the PREVIOUS charge's answer until
|
|
32
|
+
* the new loop's first poll returned. That is not a cosmetic flash: a terminal
|
|
33
|
+
* status is acted on, and `PixView` acts on it by handing the flow to the
|
|
34
|
+
* confirmation screen — so a fresh, unpaid PIX code was reported settled by the
|
|
35
|
+
* code it had just replaced. Regenerating an expired charge is the path that
|
|
36
|
+
* does exactly this, and it is the one FUT-1170 was reported from.
|
|
37
|
+
*
|
|
38
|
+
* Stamping the answer and comparing at READ time (rather than clearing the
|
|
39
|
+
* state in an effect) is what makes the guard hold in the render that changes
|
|
40
|
+
* the id, with no intermediate frame in which the stale answer is still live.
|
|
41
|
+
*/
|
|
42
|
+
interface PollAnswer {
|
|
43
|
+
orderId: string | null;
|
|
44
|
+
status: OrderStatus | null;
|
|
45
|
+
error: string | null;
|
|
46
|
+
timedOut: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const NO_ANSWER: PollAnswer = { orderId: null, status: null, error: null, timedOut: false };
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Where a wait for ONE order writes what it learns.
|
|
53
|
+
*
|
|
54
|
+
* Every write is stamped with the order the loop was started for, and a write
|
|
55
|
+
* arriving against a DIFFERENT stamp starts from `NO_ANSWER` rather than
|
|
56
|
+
* merging into the previous order's facts.
|
|
57
|
+
*/
|
|
58
|
+
function sinkFor(
|
|
59
|
+
orderId: string,
|
|
60
|
+
setAnswer: Dispatch<SetStateAction<PollAnswer>>,
|
|
61
|
+
): PollSink {
|
|
62
|
+
const write = (patch: Partial<PollAnswer>): void =>
|
|
63
|
+
setAnswer((prev) => ({ ...(prev.orderId === orderId ? prev : NO_ANSWER), ...patch, orderId }));
|
|
64
|
+
return {
|
|
65
|
+
setStatus: (status) => write({ status }),
|
|
66
|
+
setError: (error) => write({ error }),
|
|
67
|
+
setTimedOut: (timedOut) => write({ timedOut }),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
27
71
|
|
|
28
72
|
/**
|
|
29
73
|
* Ask again the moment the shopper could plausibly have an answer for us.
|
|
@@ -77,9 +121,7 @@ export function usePaymentPolling(
|
|
|
77
121
|
askTimeoutMs,
|
|
78
122
|
}: PollingOptions = {},
|
|
79
123
|
): PaymentPollingState {
|
|
80
|
-
const [
|
|
81
|
-
const [error, setError] = useState<string | null>(null);
|
|
82
|
-
const [timedOut, setTimedOut] = useState(false);
|
|
124
|
+
const [answer, setAnswer] = useState<PollAnswer>(NO_ANSWER);
|
|
83
125
|
// Whichever mount this tree is bound to (FUT-741). Stable per provider, so it
|
|
84
126
|
// belongs in the deps below rather than being read out of a ref: a checkout
|
|
85
127
|
// re-pointed at another mount must re-poll against THAT one.
|
|
@@ -106,7 +148,7 @@ export function usePaymentPolling(
|
|
|
106
148
|
askTimeoutMs,
|
|
107
149
|
askTimeoutError: transportCopy.offline,
|
|
108
150
|
},
|
|
109
|
-
|
|
151
|
+
sinkFor(orderId, setAnswer),
|
|
110
152
|
);
|
|
111
153
|
loop.current = running;
|
|
112
154
|
running.restart();
|
|
@@ -133,6 +175,15 @@ export function usePaymentPolling(
|
|
|
133
175
|
loop.current?.restart();
|
|
134
176
|
}, []);
|
|
135
177
|
|
|
136
|
-
|
|
178
|
+
// The answer is this order's, or there is no answer yet. Read here rather
|
|
179
|
+
// than reset in an effect: an effect runs after paint, so the render that
|
|
180
|
+
// re-points the hook would still hand a consumer the old charge's status.
|
|
181
|
+
const current = answer.orderId === orderId ? answer : NO_ANSWER;
|
|
182
|
+
return {
|
|
183
|
+
status: current.status,
|
|
184
|
+
error: current.error,
|
|
185
|
+
timedOut: current.timedOut,
|
|
186
|
+
checkAgain,
|
|
187
|
+
};
|
|
137
188
|
}
|
|
138
189
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { useEffect, useState } from "react";
|
|
2
2
|
|
|
3
|
+
import { parkedBasket } from "./basket";
|
|
4
|
+
import type { ChallengeScope } from "./card-outcome";
|
|
3
5
|
import { useCheckoutClientApi } from "./client-context";
|
|
4
6
|
import { UNRESOLVED_CODE } from "./failure-codes";
|
|
5
7
|
import { rememberHostedOrder } from "./hosted-return";
|
|
@@ -62,12 +64,33 @@ export interface WalletCharge {
|
|
|
62
64
|
*/
|
|
63
65
|
const WALLET_AWAITING_WAIT_MS = 90_000;
|
|
64
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Park the order before the tab leaves, WITH the store and the basket
|
|
69
|
+
* (FUT-1240).
|
|
70
|
+
*
|
|
71
|
+
* Both absences are read as "no opinion" by the resume — `belongsHere` passes
|
|
72
|
+
* a slug-less entry at any store and the basket rule passes a basket-less one
|
|
73
|
+
* against any basket — so a wallet's 3-D Secure hand-off that named neither
|
|
74
|
+
* resumed over whatever checkout mounted next. The card path has carried these
|
|
75
|
+
* since FUT-1213; this call site is the one that did not.
|
|
76
|
+
*/
|
|
77
|
+
function parkForHandover(order: CheckoutOrder, scope: ChallengeScope): void {
|
|
78
|
+
const basket = parkedBasket(scope.basket);
|
|
79
|
+
rememberHostedOrder(order, {
|
|
80
|
+
...(scope.tenantSlug === undefined ? {} : { tenantSlug: scope.tenantSlug }),
|
|
81
|
+
...(basket === undefined ? {} : { basket }),
|
|
82
|
+
handoff: true,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
65
86
|
/** The wallet charge state machine. See the module comment. */
|
|
66
87
|
export function useWalletCharge(
|
|
67
88
|
order: CheckoutOrder,
|
|
68
89
|
buyer: BuyerInfo,
|
|
69
90
|
onResolved: (status: OrderStatus) => void,
|
|
70
91
|
pollIntervalMs = 2500,
|
|
92
|
+
/** WHOSE store and WHICH basket — see {@link parkForHandover} (FUT-1240). */
|
|
93
|
+
scope: ChallengeScope = {},
|
|
71
94
|
): WalletCharge {
|
|
72
95
|
const [phase, setPhase] = useState<WalletPhase>("idle");
|
|
73
96
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -116,7 +139,7 @@ export function useWalletCharge(
|
|
|
116
139
|
// FUT-698): park the order and hand the buyer over, exactly as the card
|
|
117
140
|
// path does. `phase` stays as-is — the tab is navigating away.
|
|
118
141
|
if (charged.data.hostedCheckoutUrl) {
|
|
119
|
-
|
|
142
|
+
parkForHandover(order, scope);
|
|
120
143
|
navigate(charged.data.hostedCheckoutUrl);
|
|
121
144
|
return true;
|
|
122
145
|
}
|
|
@@ -130,12 +130,50 @@ export interface PaymentStatusCopy {
|
|
|
130
130
|
receiptEmailLabel: string;
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
/**
|
|
134
|
+
* The pipeline engine's own two sentences (FUT-1240).
|
|
135
|
+
*
|
|
136
|
+
* REQUIRED, both of them, and stated here rather than defaulted for the reason
|
|
137
|
+
* every other string in this file is: a default in the origin host's language
|
|
138
|
+
* reads as finished right up until a shopper sees it. These two are the whole
|
|
139
|
+
* of what the ENGINE renders on its own account — everything else on screen
|
|
140
|
+
* belongs to a step, a gate or a settlement method.
|
|
141
|
+
*/
|
|
142
|
+
export interface CheckoutPipelineCopy {
|
|
143
|
+
/**
|
|
144
|
+
* What is on screen while the checkout has nothing to show yet: a gate still
|
|
145
|
+
* deciding, the store's protocol still in flight, or no step applying.
|
|
146
|
+
*
|
|
147
|
+
* It replaces a blank frame. A shopper who taps "pagar" and gets an empty
|
|
148
|
+
* page taps again.
|
|
149
|
+
*/
|
|
150
|
+
loading: string;
|
|
151
|
+
/**
|
|
152
|
+
* PER SETTLEMENT METHOD, what the shopper reads between choosing it and the
|
|
153
|
+
* surface that takes the money arriving — the hand-off interstitial's own
|
|
154
|
+
* line. Keyed by `SettlementMethodDescriptor.id`.
|
|
155
|
+
*
|
|
156
|
+
* Per method because the sentences are not interchangeable: a Pix hand-off
|
|
157
|
+
* and a card challenge send the shopper to different places for different
|
|
158
|
+
* reasons, and one shared "aguarde" describes neither. A method with no
|
|
159
|
+
* entry falls back to {@link CheckoutPipelineCopy.loading}, which is what a
|
|
160
|
+
* host registering a new charged method gets until it writes the sentence.
|
|
161
|
+
*/
|
|
162
|
+
awaitingHandover: Readonly<Record<string, string>>;
|
|
163
|
+
}
|
|
164
|
+
|
|
133
165
|
/** What the legacy `CheckoutFlow` itself renders and must be handed. */
|
|
134
166
|
export interface CheckoutViewCopy {
|
|
135
167
|
steps: CheckoutStepperCopy;
|
|
136
168
|
dados: DadosStepCopy;
|
|
137
169
|
emptyCart: EmptyCartCopy;
|
|
138
170
|
status: PaymentStatusCopy;
|
|
171
|
+
/**
|
|
172
|
+
* The engine's own two sentences (FUT-1240). Carried here, beside the
|
|
173
|
+
* stepper labels, so a host still answers copy exactly once — the pipeline
|
|
174
|
+
* is another way of rendering this same checkout, not a second surface.
|
|
175
|
+
*/
|
|
176
|
+
pipeline: CheckoutPipelineCopy;
|
|
139
177
|
/**
|
|
140
178
|
* The words the screens BELOW these read — the card fields, the wallet
|
|
141
179
|
* panes, the buyer-details inputs (FUT-760).
|
|
@@ -150,7 +150,12 @@ export function WalletCardPane(props: WalletPaneProps): JSX.Element {
|
|
|
150
150
|
const copy = useCheckoutCopy().screens.settling;
|
|
151
151
|
const { order, buyer, config, tenantSlug, onResolved, pollIntervalMs } = props;
|
|
152
152
|
const { freshInstrument, basket } = props;
|
|
153
|
-
|
|
153
|
+
// The same store and basket the card path already parks with (FUT-1213,
|
|
154
|
+
// FUT-1240): a wallet's 3-D Secure hand-off is a hand-off like any other.
|
|
155
|
+
const wallet = useWalletCharge(order, buyer, onResolved, pollIntervalMs, {
|
|
156
|
+
...(tenantSlug === undefined ? {} : { tenantSlug }),
|
|
157
|
+
...(basket === undefined ? {} : { basket }),
|
|
158
|
+
});
|
|
154
159
|
// A sheet failure the wallet reported before any charge existed (pay.js
|
|
155
160
|
// refused, merchant validation unavailable, the sheet errored) — shown
|
|
156
161
|
// beside the form, which stays usable.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ONE WAY OUT OF A CHECKOUT (FUT-1240).
|
|
3
|
+
*
|
|
4
|
+
* A checkout has two controls that leave it — the chrome's back link on the
|
|
5
|
+
* first step, and "voltar ao cardápio" on the confirmation — and until this
|
|
6
|
+
* existed they answered differently: back honoured a registered
|
|
7
|
+
* {@link CheckoutExit} while the confirmation went straight to
|
|
8
|
+
* `ports.exitToCatalog`. So a host that registered its router's own catalog
|
|
9
|
+
* route got it from one control and a full page navigation from the other,
|
|
10
|
+
* with the difference visible only to the shopper who pressed the second one.
|
|
11
|
+
*
|
|
12
|
+
* `exit` is a FACTORY-scope constant, so calling its `useCatalog()` here is a
|
|
13
|
+
* hook call whose presence never changes between renders.
|
|
14
|
+
*/
|
|
15
|
+
import { useCallback } from "react";
|
|
16
|
+
|
|
17
|
+
import type { FlowsRuntime } from "./runtime";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Leave for the host's catalog, through whichever door it registered.
|
|
21
|
+
*
|
|
22
|
+
* `exit` wins when there is one — it is the host's own router, and a router
|
|
23
|
+
* navigation keeps the SPA alive. `ports.exitToCatalog` is the fallback every
|
|
24
|
+
* host already wires, and stays the answer for a host that registered nothing.
|
|
25
|
+
*/
|
|
26
|
+
export function useCatalogExit(runtime: FlowsRuntime): () => void {
|
|
27
|
+
const exit = runtime.config.exit;
|
|
28
|
+
const catalog = exit?.useCatalog();
|
|
29
|
+
return useCallback(() => {
|
|
30
|
+
if (exit && catalog) exit.navigate(catalog.to);
|
|
31
|
+
else runtime.config.ports.exitToCatalog();
|
|
32
|
+
}, [exit, catalog, runtime]);
|
|
33
|
+
}
|
|
@@ -22,6 +22,8 @@ import { createCheckoutClient } from "../components/checkout/transport";
|
|
|
22
22
|
import type { CheckoutProviderConfig, SettlementCheckout } from "../components/checkout/types";
|
|
23
23
|
import { useCheckoutController } from "../components/checkout/use-checkout-controller";
|
|
24
24
|
|
|
25
|
+
import { buildPipeline } from "./pipeline/engine";
|
|
26
|
+
import { pipelineRequested } from "./pipeline/types";
|
|
25
27
|
import { FlowsProvider, useResolvedConfig, type FlowsRuntime } from "./runtime";
|
|
26
28
|
import { buyerScreens } from "./screens-buyer";
|
|
27
29
|
import { hostedScreens } from "./screens-hosted";
|
|
@@ -203,12 +205,21 @@ export function createPaymentFlows(config: PaymentFlowsConfig): PaymentFlows {
|
|
|
203
205
|
);
|
|
204
206
|
}
|
|
205
207
|
|
|
208
|
+
// THE ADDITIVE SWITCH (FUT-1240). A host that registered a step, a gate, a
|
|
209
|
+
// settlement method, an intent, an open payable, an exit or a settle
|
|
210
|
+
// callback gets the pipeline; a host that registered none gets the flat
|
|
211
|
+
// three-step flow, unchanged, down to its test ids. `useAdmission` is built
|
|
212
|
+
// either way — with no gates registered it passes, which is the honest
|
|
213
|
+
// answer for a host that has declared no admission rules.
|
|
214
|
+
const pipeline = buildPipeline(runtime, screens);
|
|
215
|
+
|
|
206
216
|
return {
|
|
207
|
-
Checkout: buildCheckout(runtime, screens),
|
|
217
|
+
Checkout: pipelineRequested(config) ? pipeline.Checkout : buildCheckout(runtime, screens),
|
|
208
218
|
Provider,
|
|
209
219
|
screens,
|
|
210
220
|
useCheckout: buildUseCheckout(runtime),
|
|
211
221
|
useCheckoutConfig: () => useResolvedConfig(runtime),
|
|
222
|
+
useAdmission: pipeline.useAdmission,
|
|
212
223
|
client: runtime.client,
|
|
213
224
|
};
|
|
214
225
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHAT A STEP CAN DO, as a context (FUT-1240).
|
|
3
|
+
*
|
|
4
|
+
* A step's `render` is handed only what §4.2 declares — its context, its
|
|
5
|
+
* facts, its slice, and the two navigations. Everything a step needs to CHANGE
|
|
6
|
+
* (choose a method, place the order, report a terminal status, edit the buyer)
|
|
7
|
+
* belongs to the engine, and the engine is the only writer of each.
|
|
8
|
+
*
|
|
9
|
+
* It arrives as a React context rather than as a closure, for one mechanical
|
|
10
|
+
* reason: the registered arrays must be identity-stable across renders (see
|
|
11
|
+
* `stable-plugins.ts`), so a step object cannot be rebuilt each render to
|
|
12
|
+
* capture this render's callbacks. A context is read where it is used, in a
|
|
13
|
+
* component, and leaves the step objects frozen.
|
|
14
|
+
*/
|
|
15
|
+
import { createContext, useContext, type JSX, type ReactNode } from "react";
|
|
16
|
+
|
|
17
|
+
import type { CheckoutDecline } from "../../components/checkout/decline";
|
|
18
|
+
import type {
|
|
19
|
+
BuyerInfo,
|
|
20
|
+
CheckoutError,
|
|
21
|
+
CheckoutOrder,
|
|
22
|
+
OrderStatus,
|
|
23
|
+
} from "../../components/checkout/types";
|
|
24
|
+
import type { CheckoutViewCopy } from "../../components/checkout/view-copy";
|
|
25
|
+
import type { CheckoutScreens } from "../types";
|
|
26
|
+
|
|
27
|
+
import type { AnySettlementMethod, CheckoutContext } from "./types";
|
|
28
|
+
|
|
29
|
+
/** The engine's writers, plus the two tables a step renders from. */
|
|
30
|
+
export interface PipelineActions {
|
|
31
|
+
/** Every screen the factory built, already bound to transport and slots. */
|
|
32
|
+
screens: CheckoutScreens;
|
|
33
|
+
/** The words. The engine's own two live under `copy.pipeline`. */
|
|
34
|
+
copy: CheckoutViewCopy;
|
|
35
|
+
/** Every registered settlement method, in picker order. */
|
|
36
|
+
methods: readonly AnySettlementMethod[];
|
|
37
|
+
/** The subset this shopper is actually offered, in the same order. */
|
|
38
|
+
offered: readonly AnySettlementMethod[];
|
|
39
|
+
/** The shopper picked a way to settle. Raises the payable unless a Review owns that. */
|
|
40
|
+
choose(methodId: string): void;
|
|
41
|
+
/** Raise the payable for the chosen method — a `Review`'s own action. */
|
|
42
|
+
place(): void;
|
|
43
|
+
/** A payable is being raised right now. */
|
|
44
|
+
placing: boolean;
|
|
45
|
+
/** "Continuar" on the buyer-details step: gate, persist, advance. */
|
|
46
|
+
continueFromDados(): void;
|
|
47
|
+
setBuyer(buyer: BuyerInfo): void;
|
|
48
|
+
saveProfile: boolean;
|
|
49
|
+
setSaveProfile(save: boolean): void;
|
|
50
|
+
/** A terminal status, carrying the refusal when the charge produced one. */
|
|
51
|
+
resolve(status: OrderStatus, decline?: CheckoutDecline | null): void;
|
|
52
|
+
/**
|
|
53
|
+
* Take up an order this visit did not raise — a resumed on-page charge.
|
|
54
|
+
* Separate from {@link PipelineActions.place} because nothing is being
|
|
55
|
+
* created: the charge exists, and what changes is only which order the walk
|
|
56
|
+
* is about.
|
|
57
|
+
*/
|
|
58
|
+
adoptOrder(order: CheckoutOrder): void;
|
|
59
|
+
/** Leave for the host's catalog. */
|
|
60
|
+
exitToCatalog(): void;
|
|
61
|
+
/** The refusal the engine currently holds, whoever claimed it. */
|
|
62
|
+
error: CheckoutError | null;
|
|
63
|
+
/** Reopen the buyer-details step — the payer block's "alterar". */
|
|
64
|
+
editBuyer: (() => void) | undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const PipelineActionsContext = createContext<PipelineActions | null>(null);
|
|
68
|
+
|
|
69
|
+
/** The engine's actions. Throws outside the engine, on purpose. */
|
|
70
|
+
export function usePipelineActions(): PipelineActions {
|
|
71
|
+
const actions = useContext(PipelineActionsContext);
|
|
72
|
+
if (!actions) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
"usePipelineActions() was called outside the checkout pipeline. A step's " +
|
|
75
|
+
"render only runs inside <Checkout />; mounting one on its own is what " +
|
|
76
|
+
"`flows.screens.*` is for.",
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
return actions;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Supplied once by the engine, above every step. */
|
|
83
|
+
export function PipelineActionsProvider({
|
|
84
|
+
actions,
|
|
85
|
+
children,
|
|
86
|
+
}: {
|
|
87
|
+
actions: PipelineActions;
|
|
88
|
+
children: ReactNode;
|
|
89
|
+
}): JSX.Element {
|
|
90
|
+
return (
|
|
91
|
+
<PipelineActionsContext.Provider value={actions}>
|
|
92
|
+
{children}
|
|
93
|
+
</PipelineActionsContext.Provider>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The descriptor for a chosen method, or `undefined` when nobody registered it. */
|
|
98
|
+
export function descriptorFor(
|
|
99
|
+
methods: readonly AnySettlementMethod[],
|
|
100
|
+
ctx: CheckoutContext,
|
|
101
|
+
): AnySettlementMethod | undefined {
|
|
102
|
+
if (ctx.method === null) return undefined;
|
|
103
|
+
return methods.find((entry) => entry.id === ctx.method);
|
|
104
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MAY THIS SHOPPER CHECK OUT — one answer, offered headless (FUT-1240).
|
|
3
|
+
*
|
|
4
|
+
* The gates run in ARRAY ORDER and the first non-`pass` verdict wins. Order
|
|
5
|
+
* matters and is the host's to choose: a gate that would curtain the screen
|
|
6
|
+
* must not speak before the gate that is still waiting for the cart, or a
|
|
7
|
+
* shopper meets "loja fechada" because a fact had not arrived yet.
|
|
8
|
+
*
|
|
9
|
+
* Exported as `flows.useAdmission()` so the cart drawer's CTA and a
|
|
10
|
+
* buy-now button consume the SAME list the checkout does. Two surfaces
|
|
11
|
+
* deciding this separately is how a storefront ends up with a drawer that
|
|
12
|
+
* offers a checkout the checkout itself refuses.
|
|
13
|
+
*/
|
|
14
|
+
import { hostedCheckoutReturnPending } from "../../components/checkout/hosted-return";
|
|
15
|
+
|
|
16
|
+
import type { AnyCheckoutGate, CheckoutContext, GateVerdict } from "./types";
|
|
17
|
+
|
|
18
|
+
/** Nothing said otherwise. */
|
|
19
|
+
const PASS: GateVerdict = { kind: "pass" };
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The gates' verdict, given the facts each of them returned.
|
|
23
|
+
*
|
|
24
|
+
* Pure: the hooks run in the caller's body, in array order, and their answers
|
|
25
|
+
* arrive here as a list. That is what makes this testable without a renderer
|
|
26
|
+
* and what keeps hook order a property of the ARRAY rather than of the
|
|
27
|
+
* verdicts.
|
|
28
|
+
*/
|
|
29
|
+
export function decideAdmission(input: {
|
|
30
|
+
gates: readonly AnyCheckoutGate[];
|
|
31
|
+
facts: readonly unknown[];
|
|
32
|
+
ctx: CheckoutContext;
|
|
33
|
+
/** A hand-off from this tab is still waiting — see `standsAsideForResume`. */
|
|
34
|
+
resuming: boolean;
|
|
35
|
+
}): GateVerdict {
|
|
36
|
+
for (const [at, gate] of input.gates.entries()) {
|
|
37
|
+
// A gate that stands aside for a resume is standing aside from the one
|
|
38
|
+
// route where money gets confirmed. Skipped for that visit only.
|
|
39
|
+
if (input.resuming && gate.standsAsideForResume) continue;
|
|
40
|
+
const verdict = gate.decide(input.ctx, input.facts[at]);
|
|
41
|
+
if (verdict.kind !== "pass") return verdict;
|
|
42
|
+
}
|
|
43
|
+
return PASS;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Whether a hand-off from this tab is still waiting to be resolved.
|
|
48
|
+
*
|
|
49
|
+
* Asked with the SAME slug and basket the resume asks with, so a gate and the
|
|
50
|
+
* flow behind it cannot disagree about whose return this is — the property
|
|
51
|
+
* `hostedCheckoutReturnPending`'s own doc argues for at length.
|
|
52
|
+
*/
|
|
53
|
+
export function resumePending(ctx: CheckoutContext): boolean {
|
|
54
|
+
return hostedCheckoutReturnPending(ctx.tenantSlug, ctx.cart.identity);
|
|
55
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE CONTEXT EVERY PLUGIN READS, built once from the host's own hooks
|
|
3
|
+
* (FUT-1240).
|
|
4
|
+
*
|
|
5
|
+
* One builder, used by BOTH the engine and `flows.useAdmission()`, so the
|
|
6
|
+
* headless admission the cart drawer asks and the checkout the shopper reaches
|
|
7
|
+
* cannot disagree about the same shopper. That was the whole harm behind
|
|
8
|
+
* "three exemption sets": every surface answered "may this store take money"
|
|
9
|
+
* from its own reading of its own facts.
|
|
10
|
+
*
|
|
11
|
+
* Every field is server-owned or parked. Nothing here is React state the
|
|
12
|
+
* engine happens to hold — the engine layers its own on top with
|
|
13
|
+
* {@link withCheckoutState}, and only there.
|
|
14
|
+
*/
|
|
15
|
+
import type { CheckoutOrder } from "../../components/checkout/types";
|
|
16
|
+
|
|
17
|
+
import { useResolvedConfig, type FlowsRuntime } from "../runtime";
|
|
18
|
+
|
|
19
|
+
import type { CheckoutContext, CheckoutPipelineConfig } from "./types";
|
|
20
|
+
|
|
21
|
+
/** No host `useIntent` ⇒ no one-click, no resume request, no preset method. */
|
|
22
|
+
const NO_INTENT: CheckoutContext["intent"] = Object.freeze({
|
|
23
|
+
oneClick: false,
|
|
24
|
+
resuming: false,
|
|
25
|
+
presetMethod: null,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/** No host `useOpenPayable` ⇒ the server is never asked; the park still answers. */
|
|
29
|
+
const NO_OPEN_PAYABLE: { order: CheckoutOrder | null; pending: boolean } = Object.freeze({
|
|
30
|
+
order: null,
|
|
31
|
+
pending: false,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The two reads only a HOST can answer: what the address bar asked for, and
|
|
36
|
+
* what the server says is already in flight.
|
|
37
|
+
*
|
|
38
|
+
* Its own function so the defaulting stays in one place — and so the base
|
|
39
|
+
* builder below reads as a list of facts rather than as a chain of `??`.
|
|
40
|
+
*/
|
|
41
|
+
function usePipelineReads(pipeline: CheckoutPipelineConfig): {
|
|
42
|
+
intent: CheckoutContext["intent"];
|
|
43
|
+
openPayable: { order: CheckoutOrder | null; pending: boolean };
|
|
44
|
+
} {
|
|
45
|
+
const intent = pipeline.useIntent?.() ?? NO_INTENT;
|
|
46
|
+
const openPayable = pipeline.useOpenPayable?.() ?? NO_OPEN_PAYABLE;
|
|
47
|
+
return { intent, openPayable };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The base context plus the two reads a caller may need on their own. */
|
|
51
|
+
interface CheckoutBase {
|
|
52
|
+
ctx: CheckoutContext;
|
|
53
|
+
openPayable: { order: CheckoutOrder | null; pending: boolean };
|
|
54
|
+
/** The buyer's saved details are still being fetched. */
|
|
55
|
+
buyerPending: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The host's facts, as a context.
|
|
60
|
+
*
|
|
61
|
+
* `method`, `order`, `outcome` and `slices` are at their resting values here:
|
|
62
|
+
* an admission decision is about the SHOPPER and the STORE, never about how
|
|
63
|
+
* far into a payment somebody is. The engine supplies the rest.
|
|
64
|
+
*/
|
|
65
|
+
export function useCheckoutBase(
|
|
66
|
+
runtime: FlowsRuntime,
|
|
67
|
+
pipeline: CheckoutPipelineConfig,
|
|
68
|
+
): CheckoutBase {
|
|
69
|
+
const cart = runtime.config.useCart();
|
|
70
|
+
const defaults = runtime.config.useBuyerDefaults?.() ?? {};
|
|
71
|
+
const settlement = runtime.config.useSettlement?.() ?? null;
|
|
72
|
+
const { config, pending } = useResolvedConfig(runtime);
|
|
73
|
+
const tenantSlug = runtime.useTenantSlug();
|
|
74
|
+
const { intent, openPayable } = usePipelineReads(pipeline);
|
|
75
|
+
return {
|
|
76
|
+
ctx: {
|
|
77
|
+
...(tenantSlug === undefined ? {} : { tenantSlug }),
|
|
78
|
+
config,
|
|
79
|
+
configPending: pending,
|
|
80
|
+
cart,
|
|
81
|
+
settlement,
|
|
82
|
+
...buyerFacts(defaults),
|
|
83
|
+
...AT_REST,
|
|
84
|
+
order: openPayable.order,
|
|
85
|
+
intent,
|
|
86
|
+
},
|
|
87
|
+
openPayable,
|
|
88
|
+
buyerPending: defaults.pending ?? false,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Nothing has been chosen, raised or answered yet. */
|
|
93
|
+
const AT_REST = Object.freeze({
|
|
94
|
+
method: null,
|
|
95
|
+
outcome: null,
|
|
96
|
+
slices: Object.freeze({}),
|
|
97
|
+
} as const);
|
|
98
|
+
|
|
99
|
+
/** The buyer half, with the two absences that mean "the host wired none". */
|
|
100
|
+
function buyerFacts(defaults: {
|
|
101
|
+
buyer?: CheckoutContext["buyer"];
|
|
102
|
+
taxIdOnFile?: boolean;
|
|
103
|
+
}): Pick<CheckoutContext, "buyer" | "taxIdOnFile"> {
|
|
104
|
+
return {
|
|
105
|
+
buyer: defaults.buyer ?? {},
|
|
106
|
+
taxIdOnFile: defaults.taxIdOnFile ?? false,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** What the ENGINE knows and the base does not. */
|
|
111
|
+
interface CheckoutEngineState {
|
|
112
|
+
buyer: CheckoutContext["buyer"];
|
|
113
|
+
method: string | null;
|
|
114
|
+
order: CheckoutOrder | null;
|
|
115
|
+
outcome: CheckoutContext["outcome"];
|
|
116
|
+
slices: Readonly<Record<string, unknown>>;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The base context with the engine's own state laid over it.
|
|
121
|
+
*
|
|
122
|
+
* The BUYER is overlaid rather than merged: the shopper may have typed a CPF
|
|
123
|
+
* for this purchase over the one on file, and the whole of `checkout-skip-dados`
|
|
124
|
+
* turns on that replacement being visible to every later step.
|
|
125
|
+
*/
|
|
126
|
+
export function withCheckoutState(
|
|
127
|
+
base: CheckoutContext,
|
|
128
|
+
state: CheckoutEngineState,
|
|
129
|
+
): CheckoutContext {
|
|
130
|
+
return {
|
|
131
|
+
...base,
|
|
132
|
+
buyer: state.buyer,
|
|
133
|
+
method: state.method,
|
|
134
|
+
// The just-raised order wins over whatever the server or the park offered:
|
|
135
|
+
// it is the one this visit is actually paying.
|
|
136
|
+
order: state.order ?? base.order,
|
|
137
|
+
outcome: state.outcome,
|
|
138
|
+
slices: state.slices,
|
|
139
|
+
};
|
|
140
|
+
}
|