@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
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.27.
|
|
25
|
+
"@12-apps/payments-backend": "^4.27.1",
|
|
26
26
|
"react-qr-code": "^2.2.0"
|
|
27
27
|
},
|
|
28
28
|
"peerDependencies": {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from "react";
|
|
2
2
|
|
|
3
3
|
import { buyerGateError } from "./buyer-gate";
|
|
4
|
+
import type { ConfirmationWait } from "./confirmation-wait";
|
|
4
5
|
import type { CheckoutDecline } from "./decline";
|
|
5
6
|
import { forgetHostedOrder, rememberHostedOrder } from "./hosted-return";
|
|
6
7
|
import { parkedBasket, type CheckoutBasketIdentity } from "./basket";
|
|
@@ -65,23 +66,55 @@ export function useCheckoutNav(
|
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
/**
|
|
68
|
-
*
|
|
69
|
+
* WHAT THE CONFIRMATION SCREEN KNOWS ABOUT ITS WAIT, from whichever wait is
|
|
70
|
+
* live.
|
|
69
71
|
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
72
|
+
* Two can reach that screen and they are mutually exclusive by construction: a
|
|
73
|
+
* checkout RESUMED from a parked entry polls through `useHostedResume`, and one
|
|
74
|
+
* that never left this tab polls through the confirmation wait (FUT-1170) — and
|
|
75
|
+
* that one stands down whenever a resumed order is what put the flow here. So
|
|
76
|
+
* the merge below is a choice between one live wait and one inert one, never a
|
|
77
|
+
* blend of two opinions.
|
|
78
|
+
*
|
|
79
|
+
* All of it is inert for a checkout with an answer already: with nothing to
|
|
80
|
+
* wait on both polls are disabled, so the error stays null, no bound elapses,
|
|
81
|
+
* and none of the actions has a wait to act on.
|
|
82
|
+
*
|
|
83
|
+
* `release` stays the resumed leg's alone. "Não consegui pagar" exists because
|
|
84
|
+
* a provider's own page produces no signal when a buyer abandons it (FUT-1146);
|
|
85
|
+
* a charge raised and still held on THIS page has no such gap to close.
|
|
73
86
|
*/
|
|
74
|
-
export function resumeSurface(
|
|
87
|
+
export function resumeSurface(
|
|
88
|
+
resume: HostedResume,
|
|
89
|
+
confirming: ConfirmationWait,
|
|
90
|
+
): {
|
|
91
|
+
awaitingTimedOut: boolean;
|
|
92
|
+
awaitingError: string | null;
|
|
93
|
+
awaitingCheckAgain: () => void;
|
|
94
|
+
/** @deprecated Renamed to `awaitingTimedOut`; kept so the rename ships additively. */
|
|
75
95
|
resumeTimedOut: boolean;
|
|
96
|
+
/** @deprecated Renamed to `awaitingError`; kept so the rename ships additively. */
|
|
76
97
|
resumeError: string | null;
|
|
98
|
+
/** @deprecated Renamed to `awaitingCheckAgain`; kept so the rename ships additively. */
|
|
77
99
|
resumeCheckAgain: () => void;
|
|
78
100
|
resumeRelease: (() => void) | undefined;
|
|
79
101
|
resumeReleasing: boolean;
|
|
80
102
|
} {
|
|
103
|
+
const live = resume.order === null ? confirming : resume;
|
|
81
104
|
return {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
105
|
+
awaitingTimedOut: live.timedOut,
|
|
106
|
+
awaitingError: live.error,
|
|
107
|
+
awaitingCheckAgain: live.checkAgain,
|
|
108
|
+
// The three old spellings, beside the new ones rather than replaced by them.
|
|
109
|
+
// This shape is PUBLIC — `CheckoutController` is `ReturnType<typeof
|
|
110
|
+
// useCheckoutController>` (flows/types.ts:148), re-exported from index.ts,
|
|
111
|
+
// and `PaymentFlows.useCheckout()` returns it. Dropping three members of it
|
|
112
|
+
// is a source break for any adopter that hand-composes the flow, and this
|
|
113
|
+
// release is a patch. The rename was cosmetic; breaking a published type for
|
|
114
|
+
// it is not a trade worth making, so the old names stay until a major.
|
|
115
|
+
resumeTimedOut: live.timedOut,
|
|
116
|
+
resumeError: live.error,
|
|
117
|
+
resumeCheckAgain: live.checkAgain,
|
|
85
118
|
resumeRelease: resume.release,
|
|
86
119
|
resumeReleasing: resume.releasing,
|
|
87
120
|
};
|
|
@@ -316,6 +349,20 @@ export function useStartPayment(input: {
|
|
|
316
349
|
async (chosen: PaymentMethod, override?: BuyerInfo) => {
|
|
317
350
|
failure.clear();
|
|
318
351
|
setDecline(null);
|
|
352
|
+
// THE CHARGE BEING REPLACED IS DROPPED FIRST (FUT-1170), before the raise
|
|
353
|
+
// rather than after it. Raising a payment means whatever was on screen is
|
|
354
|
+
// no longer the one being paid, and a provider round trip is long enough
|
|
355
|
+
// for the difference to matter: "Gerar novo código" left the expired
|
|
356
|
+
// charge mounted, so its own view polled it, got the terminal EXPIRED it
|
|
357
|
+
// was always going to get, and bounced the flow to the confirmation
|
|
358
|
+
// screen — where the new charge then landed with nothing polling it.
|
|
359
|
+
//
|
|
360
|
+
// A no-op on every other caller (the auto-raise, the alternate e-mail and
|
|
361
|
+
// the retry all run with no order held), which is the point: the clear
|
|
362
|
+
// belongs to what raising a payment MEANS, not to the one path that
|
|
363
|
+
// noticed.
|
|
364
|
+
setOrder(null);
|
|
365
|
+
setFinalStatus(null);
|
|
319
366
|
setCreating(true);
|
|
320
367
|
const result = await createOrder({ method: chosen, buyer: override ?? buyer, saveProfile });
|
|
321
368
|
setCreating(false);
|
|
@@ -331,7 +378,6 @@ export function useStartPayment(input: {
|
|
|
331
378
|
// the payment they just made.
|
|
332
379
|
rememberHostedOrder(result.data, { tenantSlug, basket: parkedBasket(basket) });
|
|
333
380
|
setOrder(result.data);
|
|
334
|
-
setFinalStatus(null);
|
|
335
381
|
},
|
|
336
382
|
[
|
|
337
383
|
buyer, saveProfile, createOrder, failure, navigate, tenantSlug, basket,
|
|
@@ -197,12 +197,14 @@ function StatusStep({
|
|
|
197
197
|
onRegenerate={() => { c.setStep("payment"); void c.startPayment("PIX"); }}
|
|
198
198
|
onBackToMenu={c.goToMenu}
|
|
199
199
|
paidExtra={confirmationExtra}
|
|
200
|
-
awaitingTimedOut={c.
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
|
|
205
|
-
|
|
200
|
+
awaitingTimedOut={c.awaitingTimedOut}
|
|
201
|
+
// How the wait behind this screen is going, and the way out of it
|
|
202
|
+
// (FUT-1144). It is the resumed leg's wait for a checkout that came back
|
|
203
|
+
// from a provider's page, and this step's own (FUT-1170) for one that
|
|
204
|
+
// never left the tab — which used to have no wait at all, and so a
|
|
205
|
+
// spinner that stood for nothing.
|
|
206
|
+
awaitingError={c.awaitingError}
|
|
207
|
+
onCheckAgain={c.awaitingCheckAgain}
|
|
206
208
|
// The buyer's own way out of a hosted wait with no terminal state
|
|
207
209
|
// (FUT-1146). Absent — and so unrendered — until the resumed leg has one
|
|
208
210
|
// to offer, which is every checkout that never left this tab.
|
|
@@ -229,6 +231,12 @@ function StatusStep({
|
|
|
229
231
|
* A cart still being FETCHED is empty in exactly the way a real one is not, so
|
|
230
232
|
* a host that wires `identity` gets this screen when its cart is empty rather
|
|
231
233
|
* than when it is late — which is also the moment the resume decision waits for.
|
|
234
|
+
*
|
|
235
|
+
* A RAISE IN FLIGHT is not "nothing to pay for" either (FUT-1170). Raising a
|
|
236
|
+
* charge now drops the one it replaces before it asks for the new one, so there
|
|
237
|
+
* is a window with no order held — and a checkout whose cart the host had
|
|
238
|
+
* already emptied would otherwise swap the buyer onto the empty-cart screen
|
|
239
|
+
* mid-regenerate, discarding the charge as it arrived.
|
|
232
240
|
*/
|
|
233
241
|
function nothingToPayFor(
|
|
234
242
|
settlement: SettlementCheckout | null | undefined,
|
|
@@ -236,7 +244,7 @@ function nothingToPayFor(
|
|
|
236
244
|
c: ReturnType<typeof useCheckoutController>,
|
|
237
245
|
): boolean {
|
|
238
246
|
if (settlement || !cart.empty || !cartLoaded(cart)) return false;
|
|
239
|
-
return !c.order && c.step !== "status";
|
|
247
|
+
return !c.order && !c.creating && c.step !== "status";
|
|
240
248
|
}
|
|
241
249
|
|
|
242
250
|
/** Step 2, with the controller's facts and the host's money mapped onto it. */
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
|
|
3
|
+
import type { OrderStatus } from "./types";
|
|
4
|
+
import { usePaymentPolling } from "./use-payment-polling";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* THE CONFIRMATION STEP'S OWN WAIT (FUT-1170).
|
|
8
|
+
*
|
|
9
|
+
* Polling used to live entirely in the method's screen — the PIX code's footer,
|
|
10
|
+
* the card pane's post-submit state — which is right for the payment step and
|
|
11
|
+
* leaves the LAST step with none. A flow parked on Confirmação holding an
|
|
12
|
+
* unsettled order therefore rendered "Confirmando seu pagamento" over a spinner
|
|
13
|
+
* that stood for nothing: no poll was scheduled, no error could appear, no
|
|
14
|
+
* clock could elapse, and the only control on screen was "Voltar ao cardápio".
|
|
15
|
+
*
|
|
16
|
+
* FUT-1170 reaches that state through the regenerate path, and dropping the
|
|
17
|
+
* replaced charge closes that particular door. This is the other half, and it
|
|
18
|
+
* is the half that holds for doors nobody has opened yet: any caller that lands
|
|
19
|
+
* on the confirmation screen with a live order now has something asking about
|
|
20
|
+
* it, and — when the clock runs out — something to press.
|
|
21
|
+
*
|
|
22
|
+
* ## Why here rather than hoisting the screens' polls into the flow
|
|
23
|
+
*
|
|
24
|
+
* The three waits are genuinely different. PIX is bounded by the CODE's own
|
|
25
|
+
* expiry and decays over minutes; the card wait is 90 s from the submit; the
|
|
26
|
+
* resumed hosted leg has its own release action attached to it. One hoisted
|
|
27
|
+
* poll would have to serve all three at one cadence and one bound, and the
|
|
28
|
+
* cadence is the thing each of them tunes. So the payment step keeps the wait
|
|
29
|
+
* that belongs to the pane the buyer is looking at, and this covers the step
|
|
30
|
+
* that has no pane of its own.
|
|
31
|
+
*
|
|
32
|
+
* The two can never run together: this one is gated on the confirmation step,
|
|
33
|
+
* and every screen that polls renders on the payment step.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/** How often the confirmation screen asks. The same cadence the panes open at. */
|
|
37
|
+
const CONFIRMATION_INTERVAL_MS = 2_500;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* How long it asks for, in WALL TIME — the card pane's bound, for the same
|
|
41
|
+
* reason it has one: past this point the spinner is no longer a description of
|
|
42
|
+
* anything, and the honest screen says so and offers the buyer the ask.
|
|
43
|
+
*
|
|
44
|
+
* Nothing is lost when it elapses. The order stays AWAITING server-side and is
|
|
45
|
+
* still recoverable by webhook, reconciliation or backfill; what changes is
|
|
46
|
+
* only that the screen stops pretending to watch.
|
|
47
|
+
*/
|
|
48
|
+
const CONFIRMATION_WAIT_MS = 90_000;
|
|
49
|
+
|
|
50
|
+
/** What the confirmation screen reads off a wait it owns. */
|
|
51
|
+
export interface ConfirmationWait {
|
|
52
|
+
/** The bound elapsed — nothing further is scheduled. */
|
|
53
|
+
timedOut: boolean;
|
|
54
|
+
/** The last ask failed, and the wait is still running (FUT-1144). */
|
|
55
|
+
error: string | null;
|
|
56
|
+
/** Ask now, and start the clock over. */
|
|
57
|
+
checkAgain: () => void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Watch the order the flow is holding, while the confirmation screen is the one
|
|
62
|
+
* on display and nothing has settled it yet.
|
|
63
|
+
*
|
|
64
|
+
* @param active Whether this wait is the live one. FALSE for every checkout
|
|
65
|
+
* that has an answer already, and for every step that polls for itself — the
|
|
66
|
+
* caller decides, because only it knows which screen is up.
|
|
67
|
+
* @param onSettled A terminal status arrived. Called once per status, with the
|
|
68
|
+
* status only: whether it carries a refusal is the charge path's knowledge and
|
|
69
|
+
* a poll has none of it.
|
|
70
|
+
*/
|
|
71
|
+
export function useConfirmationWait(input: {
|
|
72
|
+
orderId: string | null;
|
|
73
|
+
active: boolean;
|
|
74
|
+
onSettled: (status: OrderStatus) => void;
|
|
75
|
+
}): ConfirmationWait {
|
|
76
|
+
const { orderId, active, onSettled } = input;
|
|
77
|
+
const { status, error, timedOut, checkAgain } = usePaymentPolling(orderId, {
|
|
78
|
+
enabled: active,
|
|
79
|
+
intervalMs: CONFIRMATION_INTERVAL_MS,
|
|
80
|
+
maxWaitMs: CONFIRMATION_WAIT_MS,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (!active || !status || status === "AWAITING_PAYMENT") return;
|
|
85
|
+
onSettled(status);
|
|
86
|
+
}, [active, status, onSettled]);
|
|
87
|
+
|
|
88
|
+
// Reported only while this wait is the live one. A bound that elapsed for an
|
|
89
|
+
// order the flow has since settled must not turn a paid confirmation into a
|
|
90
|
+
// warning, and `usePaymentPolling` keeps its last answer after `enabled` goes
|
|
91
|
+
// false — deliberately, so a settled wait can still be read.
|
|
92
|
+
return {
|
|
93
|
+
timedOut: active && timedOut,
|
|
94
|
+
error: active ? error : null,
|
|
95
|
+
checkAgain,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -134,4 +134,12 @@ export const EN_US_CHECKOUT_VIEW_COPY: CheckoutViewCopy = {
|
|
|
134
134
|
action: "See the menu",
|
|
135
135
|
},
|
|
136
136
|
status: EN_US_PAYMENT_STATUS_COPY,
|
|
137
|
+
pipeline: {
|
|
138
|
+
loading: "Loading…",
|
|
139
|
+
// The KEYS are the package's own settlement-method ids, never words.
|
|
140
|
+
awaitingHandover: {
|
|
141
|
+
PIX: "Opening Pix…",
|
|
142
|
+
CARD: "Opening the card payment…",
|
|
143
|
+
},
|
|
144
|
+
},
|
|
137
145
|
};
|
|
@@ -15,3 +15,85 @@
|
|
|
15
15
|
* one and must not word it as a failure.
|
|
16
16
|
*/
|
|
17
17
|
export const UNRESOLVED_CODE = "PAYMENT_UNRESOLVED";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* REFUSALS RE-SENDING THE SAME REQUEST CANNOT CLEAR (FUT-1182).
|
|
21
|
+
*
|
|
22
|
+
* A different exception from {@link UNRESOLVED_CODE}, and the difference is
|
|
23
|
+
* worth keeping: an unresolved charge withholds the retry because pressing it
|
|
24
|
+
* could take the buyer's money twice. These withhold it because pressing it
|
|
25
|
+
* cannot do anything at all. The order was refused for a fact about the world —
|
|
26
|
+
* the shop is shut, the mode is off, the booked slot is gone, the basket has
|
|
27
|
+
* already been paid for, the merchant has connected no provider — and the
|
|
28
|
+
* identical POST meets the identical fact. So the button was the screen's most
|
|
29
|
+
* prominent control and it was guaranteed to fail, phrased as though the buyer
|
|
30
|
+
* had got something wrong.
|
|
31
|
+
*
|
|
32
|
+
* ## Why a package names a host's vocabulary here
|
|
33
|
+
*
|
|
34
|
+
* Because the alternative is worse in both directions. A prop the host fills
|
|
35
|
+
* would leave every host that has not filled it with the defect this exists to
|
|
36
|
+
* remove; and a rule inferred from the STATUS code cannot separate these from
|
|
37
|
+
* the 400s and 409s that a retry genuinely clears (`CHARGE_MISMATCH` is a 409
|
|
38
|
+
* and re-raising is exactly what fixes it).
|
|
39
|
+
*
|
|
40
|
+
* What makes it safe is that the list is CLOSED and its members are wire
|
|
41
|
+
* constants: this file already names `PAYMENT_UNRESOLVED` for the same reason,
|
|
42
|
+
* and the transport beside it already speaks the routes those codes arrive on.
|
|
43
|
+
* A host that answers none of these loses nothing — an unknown code keeps the
|
|
44
|
+
* retry, which is the pre-1182 behaviour for every code.
|
|
45
|
+
*
|
|
46
|
+
* ## It never SUPPRESSES the message, only the button
|
|
47
|
+
*
|
|
48
|
+
* The host half of this ticket (FUT-1166) acts on the same codes: it refetches
|
|
49
|
+
* whatever was stale, so the screen behind the refusal corrects itself into the
|
|
50
|
+
* gate that says what the buyer can actually do. The refusal's own sentence has
|
|
51
|
+
* to stay on screen while that happens, or a shopper watches a checkout
|
|
52
|
+
* rearrange itself for no stated reason.
|
|
53
|
+
*
|
|
54
|
+
* ## What is deliberately NOT here
|
|
55
|
+
*
|
|
56
|
+
* `BASKET_NOT_FOUND` and `MODE_CATALOG_SCOPE`, because this list is the
|
|
57
|
+
* GUARANTEED half. Both name states a fresh read can legitimately answer
|
|
58
|
+
* differently, and withholding the retry on a maybe is how a buyer with a
|
|
59
|
+
* recoverable problem ends up with no control at all — the same asymmetry
|
|
60
|
+
* `CheckoutDecline.retriable` settles by treating silence as yes.
|
|
61
|
+
*/
|
|
62
|
+
const NO_RETRY_CODES: readonly string[] = [
|
|
63
|
+
// The shop is shut, or shut by the time the slot came round.
|
|
64
|
+
"STORE_CLOSED",
|
|
65
|
+
"SCHEDULE_UNAVAILABLE",
|
|
66
|
+
// The store does not sell this way — a bookmarked URL, or a mode cookie that
|
|
67
|
+
// outlived the config allowing it.
|
|
68
|
+
"MODE_UNAVAILABLE",
|
|
69
|
+
// No provider connected. TWO spellings on purpose: the package's own routes
|
|
70
|
+
// answer `PAYMENT_NOT_CONFIGURED`, and a host that guards its order route
|
|
71
|
+
// before delegating answers its own. A client meets both.
|
|
72
|
+
"PAYMENT_NOT_CONFIGURED",
|
|
73
|
+
"PAYMENTS_NOT_CONFIGURED",
|
|
74
|
+
// Already bought — in another tab, or by whoever was settling alongside them.
|
|
75
|
+
// Retrying cannot un-pay it; the buyer wants their purchases, not this step.
|
|
76
|
+
"CART_ALREADY_PAID",
|
|
77
|
+
"BASKET_ALREADY_BOUGHT",
|
|
78
|
+
// Nothing left to charge for, and this step cannot put anything back.
|
|
79
|
+
"EMPTY_CART",
|
|
80
|
+
"COMANDA_CLOSED",
|
|
81
|
+
// The order cannot be delivered as asked. Fixed where the address is, which
|
|
82
|
+
// is not here.
|
|
83
|
+
"DELIVERY_UNAVAILABLE",
|
|
84
|
+
"DELIVERY_ADDRESS_REQUIRED",
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Whether the surface may offer to send this refused order again.
|
|
89
|
+
*
|
|
90
|
+
* SILENCE MEANS YES, and so does anything unrecognised: a refusal with no code,
|
|
91
|
+
* or one from a server this bundle is a release behind, keeps the retry. The
|
|
92
|
+
* two packages version independently and a host may answer a vocabulary of its
|
|
93
|
+
* own, so "never heard of it" has to degrade to the old behaviour rather than
|
|
94
|
+
* to a screen with nothing on it.
|
|
95
|
+
*/
|
|
96
|
+
export function retryMayHelp(code: string | null | undefined): boolean {
|
|
97
|
+
if (code === undefined || code === null) return true;
|
|
98
|
+
return !NO_RETRY_CODES.includes(code);
|
|
99
|
+
}
|
|
@@ -259,7 +259,18 @@ export function isStale(parked: ParkedHostedOrder): boolean {
|
|
|
259
259
|
* ask, FUT-1146's release, and the settle that ends a checkout normally), so
|
|
260
260
|
* callers other than the read need a way to say "this one is finished".
|
|
261
261
|
*/
|
|
262
|
-
export function forgetHostedOrder(): void {
|
|
262
|
+
export function forgetHostedOrder(tenantSlug?: string): void {
|
|
263
|
+
// SCOPED WHEN THE CALLER KNOWS WHOSE CHECKOUT IT IS (FUT-1240). One tab
|
|
264
|
+
// holds one slot for every store on a multi-tenant storefront, so an
|
|
265
|
+
// unscoped clear is a clear of whichever store happens to be parked — the
|
|
266
|
+
// mirror image of the unscoped RESUME the slug closed, and just as quiet: a
|
|
267
|
+
// shopper who switched stores mid-hand-off loses the confirmation for a
|
|
268
|
+
// payment they made, with nothing on screen to say why.
|
|
269
|
+
//
|
|
270
|
+
// The no-argument call is unchanged and still clears whatever is there. It
|
|
271
|
+
// is what a caller with no slug in hand means, and it is the read side's
|
|
272
|
+
// own rule: an entry nobody can attribute belongs to everybody.
|
|
273
|
+
if (tenantSlug !== undefined && !parkedBelongsTo(tenantSlug)) return;
|
|
263
274
|
try {
|
|
264
275
|
window.sessionStorage?.removeItem(HOSTED_ORDER_STORAGE_KEY);
|
|
265
276
|
window.sessionStorage?.removeItem(LEGACY_KEY);
|
|
@@ -267,3 +278,14 @@ export function forgetHostedOrder(): void {
|
|
|
267
278
|
// Storage disabled — there was nothing to clear.
|
|
268
279
|
}
|
|
269
280
|
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Whether what is parked (if anything) is this store's.
|
|
284
|
+
*
|
|
285
|
+
* `true` with nothing parked, deliberately: there is no other store's entry to
|
|
286
|
+
* protect, and answering `false` would leave a stale key nobody may delete.
|
|
287
|
+
*/
|
|
288
|
+
function parkedBelongsTo(tenantSlug: string): boolean {
|
|
289
|
+
const parked = readParked();
|
|
290
|
+
return parked === null || belongsHere(parked, tenantSlug);
|
|
291
|
+
}
|
|
@@ -2,14 +2,14 @@ import { Box } from "@mui/material";
|
|
|
2
2
|
import { useState, type JSX } from "react";
|
|
3
3
|
|
|
4
4
|
import { useCheckoutCopy } from "./copy-context";
|
|
5
|
-
import { UNRESOLVED_CODE } from "./failure-codes";
|
|
5
|
+
import { retryMayHelp, UNRESOLVED_CODE } from "./failure-codes";
|
|
6
6
|
import { useCheckoutComponents } from "./ui";
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Order-creation failure shown inline on Pagamento — the buyer never leaves the
|
|
10
10
|
* step. Split out of `checkout-steps.tsx` when it grew a second presentation.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
12
|
+
* Four shapes, and which one renders is decided by the failure's CODE, never
|
|
13
13
|
* by its prose:
|
|
14
14
|
*
|
|
15
15
|
* - the buyer e-mail was rejected (the owner testing with the store's own
|
|
@@ -19,6 +19,10 @@ import { useCheckoutComponents } from "./ui";
|
|
|
19
19
|
* a new reference, outside the walk's re-probe of the old one: precisely
|
|
20
20
|
* the double payment the message forbids, offered as the panel's most
|
|
21
21
|
* prominent affordance;
|
|
22
|
+
* - the refusal is about the WORLD rather than the payment (FUT-1182) — the
|
|
23
|
+
* ordinary danger Alert, and no retry either. Re-sending the identical
|
|
24
|
+
* request meets the identical shut shop; see `retryMayHelp` for the list and
|
|
25
|
+
* for why the sentence stays while the button goes;
|
|
22
26
|
* - anything else — the ordinary danger Alert with a retry.
|
|
23
27
|
*/
|
|
24
28
|
export function PaymentErrorPanel({
|
|
@@ -48,7 +52,9 @@ export function PaymentErrorPanel({
|
|
|
48
52
|
showIcon
|
|
49
53
|
data-testid={unresolved ? "checkout-unresolved" : "checkout-error"}
|
|
50
54
|
/>
|
|
51
|
-
{unresolved
|
|
55
|
+
{!unresolved && retryMayHelp(code) ? (
|
|
56
|
+
<RetryAffordance {...{ emailFlagged, onUseEmail, onRetry }} />
|
|
57
|
+
) : null}
|
|
52
58
|
</Box>
|
|
53
59
|
);
|
|
54
60
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { Box } from "@mui/material";
|
|
2
|
-
import { useEffect, useState, type JSX } from "react";
|
|
2
|
+
import { useEffect, useMemo, useState, type JSX } from "react";
|
|
3
3
|
import QRCode from "react-qr-code";
|
|
4
4
|
|
|
5
5
|
import { useCheckoutCopy } from "./copy-context";
|
|
6
6
|
import { ContentCopyIcon } from "./icons";
|
|
7
|
+
import type { SettlingCopy } from "./screens-copy";
|
|
7
8
|
import { StalledWait } from "./stalled-wait";
|
|
8
9
|
import type { CheckoutOrder, OrderStatus, PixCharge } from "./types";
|
|
9
10
|
import { useCheckoutComponents } from "./ui";
|
|
@@ -16,6 +17,54 @@ import { usePaymentPolling } from "./use-payment-polling";
|
|
|
16
17
|
* the payment-status screen.
|
|
17
18
|
*/
|
|
18
19
|
|
|
20
|
+
/**
|
|
21
|
+
* How many polls at the opening cadence before the wait decays (FUT-1170).
|
|
22
|
+
*
|
|
23
|
+
* Twelve is thirty seconds at the default 2.5 s, which is the window the common
|
|
24
|
+
* case lives in: a buyer on this screen has the QR in front of them and pays
|
|
25
|
+
* within a few taps, and the answer lands within seconds of the webhook.
|
|
26
|
+
* Everything after that window is an abandoned tab, or a shopper who has gone
|
|
27
|
+
* to fetch a different phone — and paying full cadence for it taxes the case
|
|
28
|
+
* that matters to subsidise the one that does not.
|
|
29
|
+
*/
|
|
30
|
+
const PIX_FAST_POLLS = 12;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The decayed cadence. Six asks a minute rather than twenty-four, and the two
|
|
34
|
+
* re-arm events (`visibilitychange`, `online`) still poll IMMEDIATELY when the
|
|
35
|
+
* buyer comes back from their bank app — which is the moment they are waiting
|
|
36
|
+
* on, so the slow phase costs them nothing there.
|
|
37
|
+
*/
|
|
38
|
+
const PIX_SLOW_INTERVAL_MS = 15_000;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* How long the wait outlives the code itself.
|
|
42
|
+
*
|
|
43
|
+
* The bound is the CHARGE's expiry, because that is the fact that ends this
|
|
44
|
+
* screen: after it, the server flips the order and answers a terminal EXPIRED.
|
|
45
|
+
* The grace is room for that flip to happen and be observed — stopping exactly
|
|
46
|
+
* at the expiry would end the wait one poll before the answer it exists for.
|
|
47
|
+
*/
|
|
48
|
+
const PIX_EXPIRY_GRACE_MS = 60_000;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The bound for a charge whose expiry cannot be read — a malformed instant, or
|
|
52
|
+
* an order that arrived without one.
|
|
53
|
+
*
|
|
54
|
+
* Unbounded is not the safe reading. It was the shipped one, and it is what let
|
|
55
|
+
* a forgotten tab ask a provider every 2.5 s for as long as it stayed open. A
|
|
56
|
+
* quarter of an hour is longer than any PIX code this checkout raises, and the
|
|
57
|
+
* buyer gets the ask back with one press.
|
|
58
|
+
*/
|
|
59
|
+
const PIX_FALLBACK_WAIT_MS = 15 * 60_000;
|
|
60
|
+
|
|
61
|
+
/** How long to keep asking about this charge — see the constants above. */
|
|
62
|
+
function pixWaitMs(expiresAt: string | undefined): number {
|
|
63
|
+
const deadline = expiresAt === undefined ? Number.NaN : Date.parse(expiresAt);
|
|
64
|
+
if (Number.isNaN(deadline)) return PIX_FALLBACK_WAIT_MS;
|
|
65
|
+
return Math.max(0, deadline - Date.now()) + PIX_EXPIRY_GRACE_MS;
|
|
66
|
+
}
|
|
67
|
+
|
|
19
68
|
/** The copyable "copia e cola" strip with its copy button. */
|
|
20
69
|
function PixCodeBox({ pix }: { pix: PixCharge }): JSX.Element {
|
|
21
70
|
const { Button, Text } = useCheckoutComponents();
|
|
@@ -72,35 +121,66 @@ function PixCodeBox({ pix }: { pix: PixCharge }): JSX.Element {
|
|
|
72
121
|
);
|
|
73
122
|
}
|
|
74
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Which of the wait's three faces the footer is showing — STOPPED, then STILL
|
|
126
|
+
* TRYING, then working.
|
|
127
|
+
*
|
|
128
|
+
* The card pane's order, and for its reason (`SubmittedState`): a wait that
|
|
129
|
+
* failed its way to its own clock carries both flags, and "we keep trying" over
|
|
130
|
+
* a wait nothing is scheduled for is the lie. The sentences are the shared
|
|
131
|
+
* {@link SettlingCopy} ones, so the two panes say the same thing about the same
|
|
132
|
+
* situation rather than drifting into two accounts of it.
|
|
133
|
+
*/
|
|
134
|
+
function pixWaitPanel(
|
|
135
|
+
copy: SettlingCopy,
|
|
136
|
+
error: string | null,
|
|
137
|
+
timedOut: boolean,
|
|
138
|
+
): { title: string; description: string; testId: string } | null {
|
|
139
|
+
if (timedOut) {
|
|
140
|
+
return { title: copy.takingLonger, description: copy.takingLongerHelp, testId: "pix-poll-timeout" };
|
|
141
|
+
}
|
|
142
|
+
if (error) return { title: copy.connectionLost, description: error, testId: "pix-poll-error" };
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
75
146
|
/**
|
|
76
147
|
* The live footer: the pulsing "awaiting payment" indicator, or — while the
|
|
77
|
-
* poll cannot reach us — the same wait said out loud,
|
|
148
|
+
* poll cannot reach us, or once it has stopped — the same wait said out loud,
|
|
149
|
+
* with a way to restart it.
|
|
78
150
|
*
|
|
79
151
|
* A WARNING rather than a danger (FUT-1144). The old red panel said "não foi
|
|
80
152
|
* possível confirmar o pagamento" and meant it: four consecutive failures ended
|
|
81
153
|
* the wait, so a shopper who paid during a ten-second blip watched a QR under a
|
|
82
154
|
* final-sounding refusal that would never update. The QR is still good, the
|
|
83
155
|
* wait is still running, and the sentence now says both.
|
|
156
|
+
*
|
|
157
|
+
* The elapsed face is FUT-1170's half. The PIX wait is bounded now, and a bound
|
|
158
|
+
* that stops the polling without changing what is on screen is the same silent
|
|
159
|
+
* failure one screen later: a pulsing dot beside "Aguardando pagamento…" for a
|
|
160
|
+
* wait that is no longer asking anything.
|
|
84
161
|
*/
|
|
85
162
|
function PixPollFooter({
|
|
86
163
|
error,
|
|
164
|
+
timedOut,
|
|
87
165
|
onCheckAgain,
|
|
88
166
|
}: {
|
|
89
167
|
error: string | null;
|
|
168
|
+
timedOut: boolean;
|
|
90
169
|
onCheckAgain: () => void;
|
|
91
170
|
}): JSX.Element {
|
|
92
171
|
const { Text } = useCheckoutComponents();
|
|
93
172
|
const { pix, settling } = useCheckoutCopy().screens;
|
|
94
|
-
|
|
173
|
+
const panel = pixWaitPanel(settling, error, timedOut);
|
|
174
|
+
if (panel) {
|
|
95
175
|
// The same panel the card and wallet panes show, held to the width of the
|
|
96
176
|
// copy-and-paste strip above it so the centred PIX column stays a column.
|
|
97
177
|
return (
|
|
98
178
|
<Box sx={{ width: "100%", maxWidth: 420 }}>
|
|
99
179
|
<StalledWait
|
|
100
|
-
title={
|
|
101
|
-
description={
|
|
180
|
+
title={panel.title}
|
|
181
|
+
description={panel.description}
|
|
102
182
|
onCheckAgain={onCheckAgain}
|
|
103
|
-
testId=
|
|
183
|
+
testId={panel.testId}
|
|
104
184
|
actionTestId="pix-check-again"
|
|
105
185
|
/>
|
|
106
186
|
</Box>
|
|
@@ -127,8 +207,17 @@ export function PixView({
|
|
|
127
207
|
}): JSX.Element {
|
|
128
208
|
const { Text } = useCheckoutComponents();
|
|
129
209
|
const copy = useCheckoutCopy().screens.pix;
|
|
130
|
-
|
|
210
|
+
// Fixed once per charge (FUT-1170): the bound is a span, so recomputing it
|
|
211
|
+
// every render would keep pushing the deadline out — and it is an effect
|
|
212
|
+
// dependency, so it would also restart the wait on every render.
|
|
213
|
+
const expiresAt = order.pix?.expiresAt;
|
|
214
|
+
const maxWaitMs = useMemo(() => pixWaitMs(expiresAt), [expiresAt]);
|
|
215
|
+
const { status, error, timedOut, checkAgain } = usePaymentPolling(order.orderId, {
|
|
131
216
|
intervalMs: pollIntervalMs,
|
|
217
|
+
slowAfterPolls: PIX_FAST_POLLS,
|
|
218
|
+
// Never FASTER than the opening cadence: a host that opens slowly means it.
|
|
219
|
+
slowIntervalMs: Math.max(pollIntervalMs, PIX_SLOW_INTERVAL_MS),
|
|
220
|
+
maxWaitMs,
|
|
132
221
|
});
|
|
133
222
|
|
|
134
223
|
// Bubble a terminal status up once, so the parent can advance to the status step.
|
|
@@ -182,7 +271,7 @@ export function PixView({
|
|
|
182
271
|
{copy.validUntil(validUntil)}
|
|
183
272
|
</Text>
|
|
184
273
|
|
|
185
|
-
<PixPollFooter error={error} onCheckAgain={checkAgain} />
|
|
274
|
+
<PixPollFooter error={error} timedOut={timedOut} onCheckAgain={checkAgain} />
|
|
186
275
|
</Box>
|
|
187
276
|
);
|
|
188
277
|
}
|
|
@@ -32,8 +32,10 @@ export interface PollingOptions {
|
|
|
32
32
|
/**
|
|
33
33
|
* WALL-CLOCK bound on the whole wait (FUT-1144): stop scheduling and report
|
|
34
34
|
* `timedOut` once this many milliseconds have passed since the wait began.
|
|
35
|
-
* Undefined ⇒ unbounded
|
|
36
|
-
* server-side and comes back
|
|
35
|
+
* Undefined ⇒ unbounded, which no consumer here is any more. PIX passed none
|
|
36
|
+
* — its charge expires server-side and comes back terminal, true of a tab
|
|
37
|
+
* somebody is watching and not of one left open in a pocket, which asked a
|
|
38
|
+
* provider every 2.5 s for as long as it lived (FUT-1170).
|
|
37
39
|
*
|
|
38
40
|
* It used to be a count of HEALTHY polls, which measured the wrong thing in
|
|
39
41
|
* the only case that matters. A wait that is failing makes no healthy polls,
|
|
@@ -126,7 +128,7 @@ function pollDelay(healthy: number, errors: number, options: PollingOptions): nu
|
|
|
126
128
|
}
|
|
127
129
|
|
|
128
130
|
/** Where a running wait writes what it has learned. */
|
|
129
|
-
interface PollSink {
|
|
131
|
+
export interface PollSink {
|
|
130
132
|
setStatus: (status: OrderStatus) => void;
|
|
131
133
|
setError: (error: string | null) => void;
|
|
132
134
|
setTimedOut: (timedOut: boolean) => void;
|
|
@@ -125,4 +125,13 @@ export const PT_BR_CHECKOUT_VIEW_COPY: CheckoutViewCopy = {
|
|
|
125
125
|
action: "Ver cardápio",
|
|
126
126
|
},
|
|
127
127
|
status: PT_BR_PAYMENT_STATUS_COPY,
|
|
128
|
+
pipeline: {
|
|
129
|
+
loading: "Carregando…",
|
|
130
|
+
// Keyed by the settlement method's id. The package registers PIX and CARD;
|
|
131
|
+
// a host that registers another charged method adds its own line here.
|
|
132
|
+
awaitingHandover: {
|
|
133
|
+
PIX: "Abrindo o Pix…",
|
|
134
|
+
CARD: "Abrindo o pagamento com cartão…",
|
|
135
|
+
},
|
|
136
|
+
},
|
|
128
137
|
};
|
|
@@ -185,6 +185,17 @@ export interface CreateOrderRequest {
|
|
|
185
185
|
buyer: BuyerInfo;
|
|
186
186
|
/** Opt-in: save buyer name/phone (not CPF — never persisted here) for next-checkout pre-fill. */
|
|
187
187
|
saveProfile: boolean;
|
|
188
|
+
/**
|
|
189
|
+
* WHICH settlement the buyer chose, when it is not one of the two above
|
|
190
|
+
* (FUT-1240).
|
|
191
|
+
*
|
|
192
|
+
* `method` is what the CHAIN can be asked to charge, so a pipeline host's own
|
|
193
|
+
* registered settlement — "pay the courier", "pay the waiter" — cannot be
|
|
194
|
+
* said there, and without this every one of them arrives as the same request.
|
|
195
|
+
* ABSENT for `PIX` and `CARD`, which is what keeps it additive; otherwise the
|
|
196
|
+
* registered `SettlementMethodDescriptor.id` verbatim (`flows/pipeline`).
|
|
197
|
+
*/
|
|
198
|
+
settlementMethod?: string;
|
|
188
199
|
}
|
|
189
200
|
|
|
190
201
|
/** What the flow hands the host's `saveBuyerContact` port on "Continuar". */
|