@12-apps/payments-frontend 3.34.4 → 3.35.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 +1 -1
- package/src/components/checkout/card-view.tsx +5 -4
- package/src/components/checkout/cart-changed.tsx +273 -0
- package/src/components/checkout/checkout-flow.tsx +2 -2
- package/src/components/checkout/checkout-steps.tsx +25 -19
- package/src/components/checkout/screens-copy.ts +16 -0
- package/src/components/checkout/screens-en-US.ts +6 -0
- package/src/components/checkout/screens-pt-BR.ts +6 -0
- package/src/components/checkout/start-payment.ts +48 -19
- package/src/components/checkout/use-checkout-controller.ts +6 -2
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/payments-frontend",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.35.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.",
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type CardTokenizationConfig,
|
|
9
9
|
} from "../../card";
|
|
10
10
|
|
|
11
|
+
import { useRefreshOnCartChanged } from "./cart-changed";
|
|
11
12
|
import { useCheckoutCopy } from "./copy-context";
|
|
12
13
|
import { UNRESOLVED_CODE } from "./failure-codes";
|
|
13
14
|
import { StalledWait } from "./stalled-wait";
|
|
@@ -192,11 +193,11 @@ export function CardView({
|
|
|
192
193
|
freshInstrument,
|
|
193
194
|
{ tenantSlug, basket },
|
|
194
195
|
);
|
|
195
|
-
// A charge NOBODY can confirm yet is not a decline (FUT-563)
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
// de novo" and pushes the buyer toward exactly the retry it forbids.
|
|
196
|
+
// A charge NOBODY can confirm yet is not a decline (FUT-563): a provider may
|
|
197
|
+
// hold the money, and "Não foi possível pagar" over "não pague de novo"
|
|
198
|
+
// pushes the buyer toward exactly the retry it forbids.
|
|
199
199
|
const unresolved = cc.errorCode === UNRESOLVED_CODE;
|
|
200
|
+
useRefreshOnCartChanged(cc.errorCode); // re-priced by the host (FUT-1139)
|
|
200
201
|
|
|
201
202
|
if (cc.submitted) {
|
|
202
203
|
return <SubmittedState card={cc} />;
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { Box } from "@mui/material";
|
|
2
|
+
import {
|
|
3
|
+
createContext,
|
|
4
|
+
useCallback,
|
|
5
|
+
useContext,
|
|
6
|
+
useEffect,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
type JSX,
|
|
10
|
+
type ReactNode,
|
|
11
|
+
} from "react";
|
|
12
|
+
|
|
13
|
+
import type { CheckoutBasketIdentity } from "./basket";
|
|
14
|
+
import { useCheckoutCopy } from "./copy-context";
|
|
15
|
+
import type {
|
|
16
|
+
BuyerInfo,
|
|
17
|
+
CheckoutOrder,
|
|
18
|
+
CreateOrderRequest,
|
|
19
|
+
CreateOrderResult,
|
|
20
|
+
PaymentMethod,
|
|
21
|
+
} from "./types";
|
|
22
|
+
import { useCheckoutComponents } from "./ui";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* THE CART MOVED UNDER A PREPARED PAYMENT (FUT-1139).
|
|
26
|
+
*
|
|
27
|
+
* An order is raised the moment the buyer picks a method, and it prices the
|
|
28
|
+
* cart as it stood THEN. Another tab can add a line, a store can move a price,
|
|
29
|
+
* a product can stop selling — and the card charge or the PIX code on screen
|
|
30
|
+
* still asks for the old amount. Paying it settles the old order and the
|
|
31
|
+
* server closes the cart, so whatever changed was neither charged nor kept.
|
|
32
|
+
*
|
|
33
|
+
* Two halves, both here:
|
|
34
|
+
*
|
|
35
|
+
* - the SERVER refuses a card charge whose order no longer matches the cart,
|
|
36
|
+
* with {@link CART_CHANGED_CODE}, after re-pricing the order to the cart as it
|
|
37
|
+
* stands. The card form then re-reads that order, so the pay bar shows the
|
|
38
|
+
* amount that will actually be charged before the buyer presses it again;
|
|
39
|
+
* - the SCREEN notices on its own that the basket it was raised from is not the
|
|
40
|
+
* basket in front of it (the host's signature, `./basket.ts`), and says so
|
|
41
|
+
* with one control that brings the payment up to date. That is the only
|
|
42
|
+
* warning a PIX buyer can get: a QR code is paid outside the app.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/** The refusal a host answers when the order no longer prices the cart. */
|
|
46
|
+
export const CART_CHANGED_CODE = "CART_CHANGED";
|
|
47
|
+
|
|
48
|
+
interface RaisedFrom {
|
|
49
|
+
order: CheckoutOrder;
|
|
50
|
+
signature: string | null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Whether the basket in front of the checkout is no longer the one `order` was
|
|
55
|
+
* raised from.
|
|
56
|
+
*
|
|
57
|
+
* The basket is recorded when the ORDER changes — a re-raise or a refresh
|
|
58
|
+
* hands back a new object, so it is re-recorded and the warning clears — and
|
|
59
|
+
* only once the host's cart has answered (`ready`): a loading cart reports the
|
|
60
|
+
* EMPTY signature, and recording that would read every real cart as changed.
|
|
61
|
+
* A host that passes no basket never sees the warning, which is the behaviour
|
|
62
|
+
* before this existed.
|
|
63
|
+
*/
|
|
64
|
+
export function useBasketChanged(
|
|
65
|
+
order: CheckoutOrder | null,
|
|
66
|
+
basket: CheckoutBasketIdentity | undefined,
|
|
67
|
+
): boolean {
|
|
68
|
+
const [raised, setRaised] = useState<RaisedFrom | null>(null);
|
|
69
|
+
const ready = basket?.ready === true;
|
|
70
|
+
const signature = basket?.signature ?? null;
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
setRaised((prev) => {
|
|
73
|
+
if (order === null) return null;
|
|
74
|
+
if (prev?.order === order || !ready) return prev;
|
|
75
|
+
return { order, signature };
|
|
76
|
+
});
|
|
77
|
+
// A cart change re-runs this and keeps `prev`: only a NEW order records, so
|
|
78
|
+
// the raised basket never follows the cart it is compared with.
|
|
79
|
+
}, [order, ready, signature]);
|
|
80
|
+
if (order === null || raised === null || raised.order !== order || !ready) return false;
|
|
81
|
+
return raised.signature !== signature;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The step's "bring this payment up to date", with what the buyer must see of it. */
|
|
85
|
+
export interface OrderRefresh {
|
|
86
|
+
run: () => void;
|
|
87
|
+
/** A refresh is on its way — the control waits rather than firing twice. */
|
|
88
|
+
pending: boolean;
|
|
89
|
+
/** The last refresh failed, in the host's words; null otherwise. */
|
|
90
|
+
error: string | null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Re-price the order IN PLACE: ask the host for the order again and swap it in
|
|
95
|
+
* without dropping the one on screen first.
|
|
96
|
+
*
|
|
97
|
+
* Unlike raising a payment (`./start-payment.ts`), nothing is cleared before
|
|
98
|
+
* the answer: the card form keeps what the buyer typed, and the refusal that
|
|
99
|
+
* sent us here stays on screen while the amount under it corrects itself. The
|
|
100
|
+
* answer is routed exactly as a raise is (`route`, which is
|
|
101
|
+
* `routeRaisedOrder`), so a re-priced order that is already PAID or must finish
|
|
102
|
+
* on the provider's page goes where a raised one would.
|
|
103
|
+
*
|
|
104
|
+
* Three guards, each against something a review found:
|
|
105
|
+
*
|
|
106
|
+
* - one refresh at a time — a second tap while one is out does nothing;
|
|
107
|
+
* - an answer for an order the screen has moved on from (the buyer switched
|
|
108
|
+
* method, or raised the payment again) is DROPPED rather than resurrected;
|
|
109
|
+
* - a failure is KEPT here and shown by {@link CartChangedBoundary}, because the
|
|
110
|
+
* step's own create-error slot is hidden whenever an order is on screen.
|
|
111
|
+
*/
|
|
112
|
+
export function useOrderRefresh(input: {
|
|
113
|
+
order: CheckoutOrder | null;
|
|
114
|
+
buyer: BuyerInfo;
|
|
115
|
+
saveProfile: boolean;
|
|
116
|
+
createOrder: (request: CreateOrderRequest) => Promise<CreateOrderResult>;
|
|
117
|
+
route: (order: CheckoutOrder) => void;
|
|
118
|
+
}): OrderRefresh {
|
|
119
|
+
const { order, buyer, saveProfile, createOrder, route } = input;
|
|
120
|
+
const offline = useCheckoutCopy().screens.transport.offline;
|
|
121
|
+
const [pending, setPending] = useState(false);
|
|
122
|
+
const [error, setError] = useState<string | null>(null);
|
|
123
|
+
const current = useRef(order);
|
|
124
|
+
const inFlight = useRef(false);
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
current.current = order;
|
|
127
|
+
}, [order]);
|
|
128
|
+
// A failure is about the order it was raised for: once another is on screen
|
|
129
|
+
// (a method switch, a fresh raise) it is no longer true, and must not stay up.
|
|
130
|
+
const orderId = order?.orderId;
|
|
131
|
+
const method = order?.method;
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
setError(null);
|
|
134
|
+
}, [orderId, method]);
|
|
135
|
+
const run = useCallback(() => {
|
|
136
|
+
if (!order || inFlight.current) return;
|
|
137
|
+
inFlight.current = true;
|
|
138
|
+
setPending(true);
|
|
139
|
+
setError(null);
|
|
140
|
+
void createOrder({ method: order.method, buyer, saveProfile })
|
|
141
|
+
.catch((): CreateOrderResult => ({ ok: false, error: { code: "NETWORK", message: offline, field: null } }))
|
|
142
|
+
.then((result) => {
|
|
143
|
+
inFlight.current = false;
|
|
144
|
+
setPending(false);
|
|
145
|
+
// The same order still on screen — by id and method, since a re-priced
|
|
146
|
+
// order comes back as a new object for the same id.
|
|
147
|
+
const now = current.current;
|
|
148
|
+
if (!now || now.orderId !== order.orderId || now.method !== order.method) return;
|
|
149
|
+
if (!result.ok) setError(result.error.message);
|
|
150
|
+
else route(result.data);
|
|
151
|
+
});
|
|
152
|
+
}, [order, buyer, saveProfile, createOrder, route, offline]);
|
|
153
|
+
return { run, pending, error };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* How this step brings a payment up to date with the cart (FUT-1139): a PIX
|
|
158
|
+
* code is minted again — the amount is IN the code — and a card order is
|
|
159
|
+
* re-priced in place so the form keeps what was typed.
|
|
160
|
+
*/
|
|
161
|
+
export function paymentRefresh(
|
|
162
|
+
order: CheckoutOrder | null,
|
|
163
|
+
onGenerate: (method: PaymentMethod) => void,
|
|
164
|
+
onRefreshOrder: (() => void) | undefined,
|
|
165
|
+
): (() => void) | null {
|
|
166
|
+
if (!order) return null;
|
|
167
|
+
if (order.method === "CARD" && onRefreshOrder) return onRefreshOrder;
|
|
168
|
+
return () => onGenerate(order.method);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const PaymentRefreshContext = createContext<(() => void) | null>(null);
|
|
172
|
+
|
|
173
|
+
/** Hands the payment panes the step's "bring this payment up to date". */
|
|
174
|
+
export function PaymentRefreshProvider({
|
|
175
|
+
refresh,
|
|
176
|
+
children,
|
|
177
|
+
}: {
|
|
178
|
+
refresh: (() => void) | null;
|
|
179
|
+
children: ReactNode;
|
|
180
|
+
}): JSX.Element {
|
|
181
|
+
return <PaymentRefreshContext.Provider value={refresh}>{children}</PaymentRefreshContext.Provider>;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The card pane's half: a charge the host refused with
|
|
186
|
+
* {@link CART_CHANGED_CODE} re-reads the order, once per refusal.
|
|
187
|
+
*/
|
|
188
|
+
export function useRefreshOnCartChanged(errorCode: string | null): void {
|
|
189
|
+
const refresh = useContext(PaymentRefreshContext);
|
|
190
|
+
// Held in a ref: the step hands down a fresh closure on every render, and
|
|
191
|
+
// keying the effect on it would re-fire the refresh after each re-price for
|
|
192
|
+
// as long as the refusal stays on screen.
|
|
193
|
+
const latest = useRef(refresh);
|
|
194
|
+
useEffect(() => {
|
|
195
|
+
latest.current = refresh;
|
|
196
|
+
}, [refresh]);
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
if (errorCode === CART_CHANGED_CODE) latest.current?.();
|
|
199
|
+
}, [errorCode]);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The payment pane, with the step's warning above it when the cart moved, and
|
|
204
|
+
* the refresh handed down to the card form. Nothing changes on screen while the
|
|
205
|
+
* basket is the one the order was raised from.
|
|
206
|
+
*/
|
|
207
|
+
export function CartChangedBoundary({
|
|
208
|
+
order,
|
|
209
|
+
basket,
|
|
210
|
+
creating,
|
|
211
|
+
onGenerate,
|
|
212
|
+
refresh,
|
|
213
|
+
children,
|
|
214
|
+
}: {
|
|
215
|
+
order: CheckoutOrder | null;
|
|
216
|
+
/** Absent for a settlement: it pays a balance, not the cart, so its basket is no signal. */
|
|
217
|
+
basket: CheckoutBasketIdentity | undefined;
|
|
218
|
+
creating: boolean;
|
|
219
|
+
onGenerate: (method: PaymentMethod) => void;
|
|
220
|
+
refresh: OrderRefresh | undefined;
|
|
221
|
+
children: ReactNode;
|
|
222
|
+
}): JSX.Element {
|
|
223
|
+
const changed = useBasketChanged(order, basket) && !creating;
|
|
224
|
+
const run = paymentRefresh(order, onGenerate, refresh?.run);
|
|
225
|
+
const pending = refresh?.pending ?? false;
|
|
226
|
+
const error = refresh?.error ?? null;
|
|
227
|
+
// ONE tree shape whether or not the warning shows: wrapping the pane only
|
|
228
|
+
// while it does would remount the card form the moment the refresh lands,
|
|
229
|
+
// dropping what the buyer typed and the refusal that explains the new total.
|
|
230
|
+
return (
|
|
231
|
+
<Box sx={{ display: "flex", flexDirection: "column", gap: 3 }}>
|
|
232
|
+
{(changed || error) && run ? <CartChangedNotice onRefresh={run} pending={pending} error={error} /> : null}
|
|
233
|
+
<PaymentRefreshProvider refresh={run}>{children}</PaymentRefreshProvider>
|
|
234
|
+
</Box>
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** "Seu carrinho mudou" — what the step says, and the one thing to do about it. */
|
|
239
|
+
function CartChangedNotice({
|
|
240
|
+
onRefresh,
|
|
241
|
+
pending = false,
|
|
242
|
+
error = null,
|
|
243
|
+
}: {
|
|
244
|
+
onRefresh: () => void;
|
|
245
|
+
pending?: boolean;
|
|
246
|
+
/** A refresh that failed, in the host's words: said, with the same control to try again. */
|
|
247
|
+
error?: string | null;
|
|
248
|
+
}): JSX.Element {
|
|
249
|
+
const { Alert, Button } = useCheckoutComponents();
|
|
250
|
+
const copy = useCheckoutCopy().screens.cartChanged;
|
|
251
|
+
return (
|
|
252
|
+
<Box data-testid="checkout-cart-changed" sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
|
253
|
+
{error ? (
|
|
254
|
+
<Alert variant="danger" title={copy.refreshFailed} description={error} showIcon data-testid="checkout-cart-changed-error" />
|
|
255
|
+
) : (
|
|
256
|
+
<Alert variant="warning" title={copy.heading} description={copy.support} showIcon />
|
|
257
|
+
)}
|
|
258
|
+
<Box>
|
|
259
|
+
<Button
|
|
260
|
+
variant="solid"
|
|
261
|
+
color="primary"
|
|
262
|
+
size="md"
|
|
263
|
+
onClick={onRefresh}
|
|
264
|
+
loading={pending}
|
|
265
|
+
disabled={pending}
|
|
266
|
+
dataTestId="checkout-cart-changed-refresh"
|
|
267
|
+
>
|
|
268
|
+
{copy.refreshAction}
|
|
269
|
+
</Button>
|
|
270
|
+
</Box>
|
|
271
|
+
</Box>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
@@ -297,6 +297,7 @@ function PagamentoStep({
|
|
|
297
297
|
errorField={c.errorField}
|
|
298
298
|
errorCode={c.errorCode}
|
|
299
299
|
onGenerate={(chosen) => void c.startPayment(chosen)}
|
|
300
|
+
refresh={c.refresh}
|
|
300
301
|
onUseEmail={c.payWithEmail}
|
|
301
302
|
// Set only for a skipped-Dados flow (the controller decides); the payer
|
|
302
303
|
// block hides itself when it is absent.
|
|
@@ -307,8 +308,7 @@ function PagamentoStep({
|
|
|
307
308
|
cartTotals={cart}
|
|
308
309
|
totalOverride={settlementTotalOverride(settlement)}
|
|
309
310
|
discountLines={cart.discountLines}
|
|
310
|
-
// Retrying a refused card: the saved card
|
|
311
|
-
// them again (FUT-1145).
|
|
311
|
+
// Retrying a refused card: the failed saved card is not chosen again (FUT-1145).
|
|
312
312
|
freshInstrument={c.freshInstrument}
|
|
313
313
|
// The card path parks an order of its own for a 3-D Secure challenge, so
|
|
314
314
|
// it needs the same basket the flow was mounted for (FUT-1213).
|
|
@@ -3,6 +3,7 @@ import { useEffect, useRef, type JSX, type ReactNode } from "react";
|
|
|
3
3
|
|
|
4
4
|
import type { CheckoutBasketIdentity } from "./basket";
|
|
5
5
|
import { displayTotals, PayBarTotal } from "./checkout-totals";
|
|
6
|
+
import { CartChangedBoundary, type OrderRefresh } from "./cart-changed";
|
|
6
7
|
import { useCheckoutCopy } from "./copy-context";
|
|
7
8
|
import { useMethodChoice } from "./method-choice";
|
|
8
9
|
import { MethodPicker } from "./method-picker";
|
|
@@ -180,6 +181,11 @@ interface PaymentStepProps {
|
|
|
180
181
|
*/
|
|
181
182
|
errorCode?: string | null;
|
|
182
183
|
onGenerate: (method: PaymentMethod) => void;
|
|
184
|
+
/**
|
|
185
|
+
* Re-price the raised order IN PLACE, keeping the card form (FUT-1139). Absent,
|
|
186
|
+
* the step falls back to raising the payment again.
|
|
187
|
+
*/
|
|
188
|
+
refresh?: OrderRefresh;
|
|
183
189
|
onUseEmail: (email: string) => void;
|
|
184
190
|
/**
|
|
185
191
|
* Present ⇒ the buyer reached this step without a Dados step (FUT-465), so
|
|
@@ -240,6 +246,7 @@ export function PaymentStep({
|
|
|
240
246
|
errorField,
|
|
241
247
|
errorCode,
|
|
242
248
|
onGenerate,
|
|
249
|
+
refresh,
|
|
243
250
|
onUseEmail,
|
|
244
251
|
onEditBuyer,
|
|
245
252
|
providerConfig,
|
|
@@ -261,11 +268,8 @@ export function PaymentStep({
|
|
|
261
268
|
return (
|
|
262
269
|
<PixStageProvider stage={pixStage}>
|
|
263
270
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 3 }}>
|
|
264
|
-
{/* THE AMOUNT, before anything asks for it (FUT-1179)
|
|
265
|
-
|
|
266
|
-
pane below it — the PIX code, the card form's own pay bar, the
|
|
267
|
-
hand-off button — and a second bar would put two "pay" controls on
|
|
268
|
-
one screen. */}
|
|
271
|
+
{/* THE AMOUNT, before anything asks for it (FUT-1179) — at the top, not in a
|
|
272
|
+
bar of its own: a second bar would put two "pay" controls on one screen. */}
|
|
269
273
|
<PaymentStepTotal money={{ order, cartTotals, totalOverride, discountLines }} />
|
|
270
274
|
|
|
271
275
|
{/* Self-hiding: renders only for a flow whose Dados step was skipped. */}
|
|
@@ -278,20 +282,22 @@ export function PaymentStep({
|
|
|
278
282
|
)}
|
|
279
283
|
|
|
280
284
|
<StepColumn full={order?.method === "PIX"}>
|
|
281
|
-
<
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
285
|
+
<CartChangedBoundary {...{ order, creating, onGenerate, refresh }} basket={totalOverride ? undefined : basket}>
|
|
286
|
+
<PaymentBody
|
|
287
|
+
order={order}
|
|
288
|
+
buyer={buyer}
|
|
289
|
+
providerConfig={config}
|
|
290
|
+
method={method}
|
|
291
|
+
tenantSlug={tenantSlug}
|
|
292
|
+
onResolved={onResolved}
|
|
293
|
+
onStart={choice.onStart}
|
|
294
|
+
creating={creating}
|
|
295
|
+
pollIntervalMs={pollIntervalMs}
|
|
296
|
+
freshInstrument={freshInstrument}
|
|
297
|
+
basket={basket}
|
|
298
|
+
validateApplePayMerchant={validateApplePayMerchant}
|
|
299
|
+
/>
|
|
300
|
+
</CartChangedBoundary>
|
|
295
301
|
</StepColumn>
|
|
296
302
|
|
|
297
303
|
<StepColumn>
|
|
@@ -266,6 +266,21 @@ export interface HostedHandoverCopy {
|
|
|
266
266
|
preparing: string;
|
|
267
267
|
}
|
|
268
268
|
|
|
269
|
+
/**
|
|
270
|
+
* The cart changed after this payment was prepared (FUT-1139): another tab, a
|
|
271
|
+
* price the store moved, a line it stopped selling. The charge prepared for the
|
|
272
|
+
* OLD basket must not be the one paid, so the step says so and offers the one
|
|
273
|
+
* control that brings the payment up to date.
|
|
274
|
+
*/
|
|
275
|
+
export interface CartChangedCopy {
|
|
276
|
+
heading: string;
|
|
277
|
+
support: string;
|
|
278
|
+
/** Re-prepares the payment for the cart as it stands now. */
|
|
279
|
+
refreshAction: string;
|
|
280
|
+
/** The heading over a refresh that failed; the host's own sentence is the body. */
|
|
281
|
+
refreshFailed: string;
|
|
282
|
+
}
|
|
283
|
+
|
|
269
284
|
/** What the browser says when the wire itself failed. */
|
|
270
285
|
export interface CheckoutTransportCopy {
|
|
271
286
|
/** A refusal whose envelope carried no sentence of its own. */
|
|
@@ -298,6 +313,7 @@ export interface CheckoutScreensCopy {
|
|
|
298
313
|
hosted: HostedHandoverCopy;
|
|
299
314
|
transport: CheckoutTransportCopy;
|
|
300
315
|
validation: CheckoutValidationCopy;
|
|
316
|
+
cartChanged: CartChangedCopy;
|
|
301
317
|
/** The step wrapper's own wait, while the charge is being raised. */
|
|
302
318
|
generatingPayment: string;
|
|
303
319
|
/**
|
|
@@ -143,6 +143,12 @@ export const EN_US_CHECKOUT_SCREENS_COPY: CheckoutScreensCopy = {
|
|
|
143
143
|
invalidResponse: 'Invalid response from the server.',
|
|
144
144
|
offline: 'Could not connect. Check your connection and try again.',
|
|
145
145
|
},
|
|
146
|
+
cartChanged: {
|
|
147
|
+
heading: 'Your cart changed',
|
|
148
|
+
support: 'This payment was prepared for your previous cart. Update it to pay the right amount.',
|
|
149
|
+
refreshAction: 'Update payment',
|
|
150
|
+
refreshFailed: 'Could not update the payment',
|
|
151
|
+
},
|
|
146
152
|
validation: {
|
|
147
153
|
taxIdInvalid: 'That CPF is not valid.',
|
|
148
154
|
nameRequired: 'Enter your name.',
|
|
@@ -129,6 +129,12 @@ export const PT_BR_CHECKOUT_SCREENS_COPY: CheckoutScreensCopy = {
|
|
|
129
129
|
invalidResponse: 'Resposta inválida do servidor.',
|
|
130
130
|
offline: 'Não foi possível conectar. Verifique sua conexão e tente novamente.',
|
|
131
131
|
},
|
|
132
|
+
cartChanged: {
|
|
133
|
+
heading: 'Seu carrinho mudou',
|
|
134
|
+
support: 'O pagamento foi preparado para o carrinho anterior. Atualize para pagar o valor certo.',
|
|
135
|
+
refreshAction: 'Atualizar pagamento',
|
|
136
|
+
refreshFailed: 'Não foi possível atualizar o pagamento',
|
|
137
|
+
},
|
|
132
138
|
validation: {
|
|
133
139
|
taxIdInvalid: 'CPF inválido.',
|
|
134
140
|
nameRequired: 'Informe seu nome.',
|
|
@@ -124,28 +124,57 @@ export function useStartPayment(input: {
|
|
|
124
124
|
failure.fail(result.error);
|
|
125
125
|
return;
|
|
126
126
|
}
|
|
127
|
-
|
|
128
|
-
// reason every other raised order is: the confirmation this is about to
|
|
129
|
-
// show has to survive a tab the phone discards on the way to it.
|
|
130
|
-
if (result.data.status !== "AWAITING_PAYMENT") {
|
|
131
|
-
rememberHostedOrder(result.data, { tenantSlug, basket: parkedBasket(basket) });
|
|
132
|
-
setOrder(result.data);
|
|
133
|
-
setFinalStatus(result.data.status);
|
|
134
|
-
setStep("status");
|
|
135
|
-
return;
|
|
136
|
-
}
|
|
137
|
-
if (handOverToProvider(result.data, navigate, tenantSlug, basket)) return;
|
|
138
|
-
// PARKED EVEN THOUGH NOBODY IS LEAVING (FUT-1140). A low-memory phone
|
|
139
|
-
// discards this tab while the shopper is in their bank app, and the SPA
|
|
140
|
-
// that comes back has never heard of the order it raised — so the buyer
|
|
141
|
-
// meets an empty cart and a retry button instead of the confirmation for
|
|
142
|
-
// the payment they just made.
|
|
143
|
-
rememberHostedOrder(result.data, { tenantSlug, basket: parkedBasket(basket) });
|
|
144
|
-
setOrder(result.data);
|
|
127
|
+
routeRaisedOrder(result.data, { navigate, tenantSlug, basket, setOrder, setFinalStatus, setStep });
|
|
145
128
|
},
|
|
146
129
|
[
|
|
147
130
|
buyer, saveProfile, createOrder, failure, navigate, tenantSlug, basket,
|
|
148
131
|
setCreating, setDecline, setOrder, setFinalStatus, setStep,
|
|
149
132
|
],
|
|
150
133
|
);
|
|
151
|
-
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* What becomes of an order the host just raised — or re-priced (FUT-1139): the
|
|
138
|
+
* three outcomes of {@link useStartPayment} after its refusal branch, shared so
|
|
139
|
+
* a refresh routes a PAID or handed-off answer exactly as a raise does.
|
|
140
|
+
*/
|
|
141
|
+
export function routeRaisedOrder(
|
|
142
|
+
order: CheckoutOrder,
|
|
143
|
+
ctx: {
|
|
144
|
+
navigate: CheckoutNavigate;
|
|
145
|
+
tenantSlug: string | undefined;
|
|
146
|
+
basket: CheckoutBasketIdentity | undefined;
|
|
147
|
+
setOrder: Dispatch<SetStateAction<CheckoutOrder | null>>;
|
|
148
|
+
setFinalStatus: Dispatch<SetStateAction<OrderStatus | null>>;
|
|
149
|
+
setStep: Dispatch<SetStateAction<Step>>;
|
|
150
|
+
},
|
|
151
|
+
): void {
|
|
152
|
+
const { navigate, tenantSlug, basket, setOrder, setFinalStatus, setStep } = ctx;
|
|
153
|
+
// NOTHING LEFT TO PAY — see the docblock. Parked first for the same
|
|
154
|
+
// reason every other raised order is: the confirmation this is about to
|
|
155
|
+
// show has to survive a tab the phone discards on the way to it.
|
|
156
|
+
if (order.status !== "AWAITING_PAYMENT") {
|
|
157
|
+
rememberHostedOrder(order, { tenantSlug, basket: parkedBasket(basket) });
|
|
158
|
+
setOrder(order);
|
|
159
|
+
setFinalStatus(order.status);
|
|
160
|
+
setStep("status");
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (handOverToProvider(order, navigate, tenantSlug, basket)) return;
|
|
164
|
+
// PARKED EVEN THOUGH NOBODY IS LEAVING (FUT-1140). A low-memory phone
|
|
165
|
+
// discards this tab while the shopper is in their bank app, and the SPA
|
|
166
|
+
// that comes back has never heard of the order it raised — so the buyer
|
|
167
|
+
// meets an empty cart and a retry button instead of the confirmation for
|
|
168
|
+
// the payment they just made.
|
|
169
|
+
rememberHostedOrder(order, { tenantSlug, basket: parkedBasket(basket) });
|
|
170
|
+
setOrder(order);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** {@link routeRaisedOrder}, bound to one checkout and stable while its inputs are. */
|
|
174
|
+
export function useRaisedOrderRoute(ctx: Parameters<typeof routeRaisedOrder>[1]): (order: CheckoutOrder) => void {
|
|
175
|
+
const { navigate, tenantSlug, basket, setOrder, setFinalStatus, setStep } = ctx;
|
|
176
|
+
return useCallback(
|
|
177
|
+
(order: CheckoutOrder) => routeRaisedOrder(order, { navigate, tenantSlug, basket, setOrder, setFinalStatus, setStep }),
|
|
178
|
+
[navigate, tenantSlug, basket, setOrder, setFinalStatus, setStep],
|
|
179
|
+
);
|
|
180
|
+
}
|
|
@@ -11,7 +11,8 @@ import {
|
|
|
11
11
|
useSettledPort,
|
|
12
12
|
type Step,
|
|
13
13
|
} from "./checkout-actions";
|
|
14
|
-
import {
|
|
14
|
+
import { useOrderRefresh } from "./cart-changed";
|
|
15
|
+
import { useRaisedOrderRoute, useStartPayment } from "./start-payment";
|
|
15
16
|
import { useConfirmationWait } from "./confirmation-wait";
|
|
16
17
|
import { useCheckoutCopy } from "./copy-context";
|
|
17
18
|
|
|
@@ -144,6 +145,9 @@ export function useCheckoutController(
|
|
|
144
145
|
const { payWithEmail, handleResolved } = useResolutionActions({
|
|
145
146
|
buyer, method, startPayment, setBuyerState, setDecline, setFinalStatus, setStep,
|
|
146
147
|
});
|
|
148
|
+
// Re-price the order in place when the cart moved under it (FUT-1139), routed as a raise is.
|
|
149
|
+
const route = useRaisedOrderRoute({ navigate, tenantSlug, basket, setOrder, setFinalStatus, setStep });
|
|
150
|
+
const refresh = useOrderRefresh({ order, buyer, saveProfile, createOrder, route });
|
|
147
151
|
const retry = useRetryAction({
|
|
148
152
|
decline, order, clearError, setOrder, setDecline, setFinalStatus, setStep, setFreshInstrument,
|
|
149
153
|
});
|
|
@@ -166,6 +170,6 @@ export function useCheckoutController(
|
|
|
166
170
|
...resumeSurface(resume, confirming),
|
|
167
171
|
createError: failure.message, errorField: failure.field, errorCode: failure.code,
|
|
168
172
|
goToMenu: onExitToMenu, back, editBuyer,
|
|
169
|
-
goToPayment, startPayment, payWithEmail, handleResolved, retry, completed,
|
|
173
|
+
goToPayment, startPayment, refresh, payWithEmail, handleResolved, retry, completed,
|
|
170
174
|
};
|
|
171
175
|
}
|