@12-apps/payments-frontend 3.2.1 → 3.2.3
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/checkout-flow.tsx +2 -1
- package/src/components/checkout/hosted-return.ts +119 -21
- package/src/components/checkout/payment-status.tsx +51 -6
- package/src/components/checkout/use-checkout-controller.ts +57 -9
- package/src/flows/copy.ts +12 -0
- package/src/flows/screens-hosted.tsx +35 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/payments-frontend",
|
|
3
|
-
"version": "3.2.
|
|
3
|
+
"version": "3.2.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"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.",
|
|
6
6
|
"exports": {
|
|
@@ -131,7 +131,7 @@ function CheckoutFlowBody(props: Omit<CheckoutFlowProps, "components">): JSX.Ele
|
|
|
131
131
|
// any chain member may need rather than re-opening after the choice. A chain
|
|
132
132
|
// that declares nothing degrades to CPF-required, never to "ask nothing".
|
|
133
133
|
const buyerFields = useMemo(() => buyerFieldsFor(providerConfig?.chain, null), [providerConfig]);
|
|
134
|
-
const c = useCheckoutController(ports, defaultBuyer, taxIdOnFile, buyerFields);
|
|
134
|
+
const c = useCheckoutController(ports, defaultBuyer, taxIdOnFile, buyerFields, tenantSlug);
|
|
135
135
|
|
|
136
136
|
// A settlement settlement pays already-sent kitchen items — the cart is
|
|
137
137
|
// legitimately empty here, so the empty-cart guard only applies to cart mode.
|
|
@@ -197,6 +197,7 @@ function CheckoutFlowBody(props: Omit<CheckoutFlowProps, "components">): JSX.Ele
|
|
|
197
197
|
onRegenerate={() => { c.setStep("payment"); void c.startPayment("PIX"); }}
|
|
198
198
|
onBackToMenu={c.goToMenu}
|
|
199
199
|
paidExtra={confirmationExtra}
|
|
200
|
+
awaitingTimedOut={c.resumeTimedOut}
|
|
200
201
|
/>
|
|
201
202
|
) : null}
|
|
202
203
|
</Box>
|
|
@@ -69,22 +69,66 @@ function isReturnTrip(): boolean {
|
|
|
69
69
|
* Read WITHOUT consuming. The gate asks on every render; only the flow may
|
|
70
70
|
* take the order.
|
|
71
71
|
*/
|
|
72
|
-
export function hostedCheckoutReturnPending(): boolean {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
72
|
+
export function hostedCheckoutReturnPending(tenantSlug?: string): boolean {
|
|
73
|
+
const parked = readParked();
|
|
74
|
+
// THE PARKED ENTRY DECIDES whenever there is one — the same two questions the
|
|
75
|
+
// resume asks, so a gate and the flow behind it cannot disagree about whose
|
|
76
|
+
// return this is. Another store's hand-off is not this route's business, and
|
|
77
|
+
// a stale one is nobody's.
|
|
78
|
+
//
|
|
79
|
+
// Asking the URL FIRST is what this used to do, and it made the two disagree
|
|
80
|
+
// exactly where it costs something: a provider marker is per-TAB, so store
|
|
81
|
+
// A's abandoned hand-off plus any marked URL had the gate answer "a return is
|
|
82
|
+
// pending here" on store B while `takeHostedOrder` correctly refused to
|
|
83
|
+
// resume it. A gate that stands aside for a return that is not going to
|
|
84
|
+
// happen is a gate that has been talked out of its job.
|
|
85
|
+
if (parked) return belongsHere(parked, tenantSlug) && !isStale(parked);
|
|
86
|
+
// Nothing parked, so the provider's own marker is the only evidence left that
|
|
87
|
+
// a return is in progress. It is kept, and only here, for the case that has
|
|
88
|
+
// no other signal: the flow has already CONSUMED the entry, and a gate
|
|
89
|
+
// re-asking mid-visit must not curtain the confirmation it just let through.
|
|
90
|
+
return isReturnTrip();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* What is actually parked: the order, WHOSE STORE it belongs to, and when.
|
|
95
|
+
*
|
|
96
|
+
* `CheckoutOrder` carries no tenant, and on a multi-tenant storefront every
|
|
97
|
+
* store shares one origin — so one tab holds one slot for all of them. Without
|
|
98
|
+
* the slug, a buyer who abandoned store A's hand-off and opened store B's
|
|
99
|
+
* checkout resumed A's order on B's screen: a confirmation for an unrelated
|
|
100
|
+
* order, and B's own checkout skipped.
|
|
101
|
+
*
|
|
102
|
+
* `parkedAt` bounds the other axis. A hand-off is a round trip of minutes; an
|
|
103
|
+
* entry older than {@link MAX_PARKED_AGE_MS} belongs to a session the buyer has
|
|
104
|
+
* long since abandoned, and resuming it tells them about an order they are no
|
|
105
|
+
* longer trying to place.
|
|
106
|
+
*/
|
|
107
|
+
interface ParkedHostedOrder {
|
|
108
|
+
order: CheckoutOrder;
|
|
109
|
+
/** The store this hand-off belongs to; absent for an unscoped host. */
|
|
110
|
+
tenantSlug?: string;
|
|
111
|
+
parkedAt: number;
|
|
82
112
|
}
|
|
83
113
|
|
|
114
|
+
/**
|
|
115
|
+
* How long a parked hand-off stays resumable.
|
|
116
|
+
*
|
|
117
|
+
* Thirty minutes: a hosted payment takes minutes, and the window has to cover a
|
|
118
|
+
* buyer who fetches their card, not one who comes back tomorrow. Beyond it the
|
|
119
|
+
* entry is dropped on read rather than resumed.
|
|
120
|
+
*/
|
|
121
|
+
const MAX_PARKED_AGE_MS = 30 * 60_000;
|
|
122
|
+
|
|
84
123
|
/** Park the raised order before handing the buyer to the provider's page. */
|
|
85
|
-
export function rememberHostedOrder(order: CheckoutOrder): void {
|
|
124
|
+
export function rememberHostedOrder(order: CheckoutOrder, tenantSlug?: string): void {
|
|
86
125
|
try {
|
|
87
|
-
|
|
126
|
+
const parked: ParkedHostedOrder = {
|
|
127
|
+
order,
|
|
128
|
+
...(tenantSlug ? { tenantSlug } : {}),
|
|
129
|
+
parkedAt: Date.now(),
|
|
130
|
+
};
|
|
131
|
+
window.sessionStorage?.setItem(HOSTED_ORDER_STORAGE_KEY, JSON.stringify(parked));
|
|
88
132
|
} catch {
|
|
89
133
|
// Storage disabled or full. The redirect must still happen: the webhook
|
|
90
134
|
// settles the order either way, and refusing to send the buyer to pay
|
|
@@ -156,15 +200,13 @@ const LEGACY_KEY = atob('ZnV0dXJlcGF5LmNoZWNrb3V0Lmhvc3RlZE9yZGVy');
|
|
|
156
200
|
* legacy entry left behind would let a later return trip resume an order that
|
|
157
201
|
* was already consumed.
|
|
158
202
|
*/
|
|
159
|
-
function
|
|
203
|
+
function peekParkedPayload(): string | null {
|
|
160
204
|
try {
|
|
161
|
-
|
|
205
|
+
return (
|
|
162
206
|
window.sessionStorage?.getItem(HOSTED_ORDER_STORAGE_KEY) ??
|
|
163
207
|
window.sessionStorage?.getItem(LEGACY_KEY) ??
|
|
164
|
-
null
|
|
165
|
-
|
|
166
|
-
window.sessionStorage?.removeItem(LEGACY_KEY);
|
|
167
|
-
return raw;
|
|
208
|
+
null
|
|
209
|
+
);
|
|
168
210
|
} catch {
|
|
169
211
|
// Storage disabled or unavailable — the same "no parked order" as an empty
|
|
170
212
|
// slot, and the webhook still settles the order regardless.
|
|
@@ -172,12 +214,51 @@ function takeParkedPayload(): string | null {
|
|
|
172
214
|
}
|
|
173
215
|
}
|
|
174
216
|
|
|
175
|
-
export function takeHostedOrder(): CheckoutOrder | null {
|
|
176
|
-
const
|
|
217
|
+
export function takeHostedOrder(tenantSlug?: string): CheckoutOrder | null {
|
|
218
|
+
const parked = readParked();
|
|
219
|
+
if (!parked) return null;
|
|
220
|
+
// A hand-off from ANOTHER store is left where it is rather than consumed: it
|
|
221
|
+
// is that store's to resume, and this buyer may well go back to it.
|
|
222
|
+
if (!belongsHere(parked, tenantSlug)) return null;
|
|
223
|
+
clearParked();
|
|
224
|
+
if (isStale(parked)) return null;
|
|
225
|
+
return parked.order;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Whether a parked hand-off is this store's. */
|
|
229
|
+
function belongsHere(parked: ParkedHostedOrder, tenantSlug?: string): boolean {
|
|
230
|
+
// An unscoped entry (a host that passes no slug, or one parked by an older
|
|
231
|
+
// bundle) stays readable by anyone — the single-tenant case, where there is
|
|
232
|
+
// no other store to confuse it with.
|
|
233
|
+
if (!parked.tenantSlug || !tenantSlug) return true;
|
|
234
|
+
return parked.tenantSlug === tenantSlug;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Whether it has been sitting long enough to no longer be this trip's. */
|
|
238
|
+
function isStale(parked: ParkedHostedOrder): boolean {
|
|
239
|
+
if (typeof parked.parkedAt !== "number") return false;
|
|
240
|
+
return Date.now() - parked.parkedAt > MAX_PARKED_AGE_MS;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The parked entry, parsed, or null. Tolerates the PRE-SCOPE shape — a bare
|
|
245
|
+
* `CheckoutOrder` — so a buyer mid-hand-off across the deploy still comes back
|
|
246
|
+
* to their confirmation.
|
|
247
|
+
*/
|
|
248
|
+
function readParked(): ParkedHostedOrder | null {
|
|
249
|
+
const raw = peekParkedPayload();
|
|
177
250
|
if (!raw) return null;
|
|
178
251
|
try {
|
|
179
252
|
const parsed: unknown = JSON.parse(raw);
|
|
180
|
-
|
|
253
|
+
if (isCheckoutOrder(parsed)) return { order: parsed, parkedAt: Date.now() };
|
|
254
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
255
|
+
const candidate = parsed as Partial<ParkedHostedOrder>;
|
|
256
|
+
if (!isCheckoutOrder(candidate.order)) return null;
|
|
257
|
+
return {
|
|
258
|
+
order: candidate.order,
|
|
259
|
+
...(candidate.tenantSlug ? { tenantSlug: candidate.tenantSlug } : {}),
|
|
260
|
+
parkedAt: typeof candidate.parkedAt === "number" ? candidate.parkedAt : Date.now(),
|
|
261
|
+
};
|
|
181
262
|
} catch {
|
|
182
263
|
return null;
|
|
183
264
|
}
|
|
@@ -193,3 +274,20 @@ function isCheckoutOrder(value: unknown): value is CheckoutOrder {
|
|
|
193
274
|
const candidate = value as Partial<CheckoutOrder>;
|
|
194
275
|
return typeof candidate.orderId === "string" && typeof candidate.totalLabel === "string";
|
|
195
276
|
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Drop the parked entry. Split from the read because the READ now has to
|
|
280
|
+
* decide whose it is first — consuming another store's hand-off was the bug
|
|
281
|
+
* this scoping exists to stop.
|
|
282
|
+
*
|
|
283
|
+
* BOTH keys, whichever answered: a legacy entry left behind would let a later
|
|
284
|
+
* return trip resume an order that was already consumed.
|
|
285
|
+
*/
|
|
286
|
+
function clearParked(): void {
|
|
287
|
+
try {
|
|
288
|
+
window.sessionStorage?.removeItem(HOSTED_ORDER_STORAGE_KEY);
|
|
289
|
+
window.sessionStorage?.removeItem(LEGACY_KEY);
|
|
290
|
+
} catch {
|
|
291
|
+
// Storage disabled — there was nothing to clear.
|
|
292
|
+
}
|
|
293
|
+
}
|
|
@@ -66,6 +66,28 @@ const OUTCOME: Record<OrderStatus, Outcome> = {
|
|
|
66
66
|
},
|
|
67
67
|
};
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* What the screen says once it has stopped asking (FUT-556).
|
|
71
|
+
*
|
|
72
|
+
* NOT an `OrderStatus`: the order really is still AWAITING_PAYMENT and may yet
|
|
73
|
+
* settle — the scheduled reconciliation keeps asking the provider long after
|
|
74
|
+
* this tab is gone. What ran out is the WAIT, not the order, and saying
|
|
75
|
+
* otherwise would be the more expensive lie: a buyer told "não concluído" who
|
|
76
|
+
* has in fact paid will either pay twice or call the store.
|
|
77
|
+
*
|
|
78
|
+
* So it leads with what is true and unglamorous — we have not heard yet — and
|
|
79
|
+
* spends its remaining words on the one instruction that matters, which is not
|
|
80
|
+
* to pay again.
|
|
81
|
+
*/
|
|
82
|
+
const AWAITING_TIMED_OUT: Outcome = {
|
|
83
|
+
heading: "Ainda não recebemos a confirmação",
|
|
84
|
+
support:
|
|
85
|
+
"Se você já pagou, o pedido é confirmado assim que a operadora avisar — " +
|
|
86
|
+
"não pague de novo. Você pode fechar esta tela.",
|
|
87
|
+
icon: <ScheduleIcon fontSize="large" />,
|
|
88
|
+
tone: "warning",
|
|
89
|
+
};
|
|
90
|
+
|
|
69
91
|
const TONE_COLOR: Record<Outcome["tone"], string> = {
|
|
70
92
|
success: "success.main",
|
|
71
93
|
danger: "error.main",
|
|
@@ -86,14 +108,29 @@ function orderReference(orderId: string): string {
|
|
|
86
108
|
}
|
|
87
109
|
|
|
88
110
|
/** The headline block: icon, outcome, and one supporting line. */
|
|
89
|
-
function OutcomeHero({
|
|
111
|
+
function OutcomeHero({
|
|
112
|
+
status,
|
|
113
|
+
timedOut = false,
|
|
114
|
+
}: {
|
|
115
|
+
status: OrderStatus;
|
|
116
|
+
timedOut?: boolean;
|
|
117
|
+
}): JSX.Element {
|
|
90
118
|
const { Text } = useCheckoutComponents();
|
|
91
|
-
const outcome = OUTCOME[status];
|
|
119
|
+
const outcome = timedOut && status === "AWAITING_PAYMENT" ? AWAITING_TIMED_OUT : OUTCOME[status];
|
|
92
120
|
return (
|
|
93
121
|
<Box
|
|
94
122
|
// `payment-paid` is load-bearing for the storefront journeys — it is how
|
|
95
|
-
// they assert the buyer actually got there.
|
|
96
|
-
|
|
123
|
+
// they assert the buyer actually got there. The timed-out wait gets its
|
|
124
|
+
// OWN id rather than reusing `payment-awaiting_payment`: a test that
|
|
125
|
+
// cannot tell "still asking" from "stopped asking" is a test that would
|
|
126
|
+
// pass against the unbounded spinner this replaced.
|
|
127
|
+
data-testid={
|
|
128
|
+
timedOut && status === "AWAITING_PAYMENT"
|
|
129
|
+
? "payment-awaiting-timeout"
|
|
130
|
+
: status === "PAID"
|
|
131
|
+
? "payment-paid"
|
|
132
|
+
: `payment-${status.toLowerCase()}`
|
|
133
|
+
}
|
|
97
134
|
sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 1, textAlign: "center" }}
|
|
98
135
|
>
|
|
99
136
|
<Box sx={{ color: TONE_COLOR[outcome.tone], display: "flex" }}>{outcome.icon}</Box>
|
|
@@ -206,6 +243,7 @@ export function PaymentStatus({
|
|
|
206
243
|
onRegenerate,
|
|
207
244
|
onBackToMenu,
|
|
208
245
|
paidExtra,
|
|
246
|
+
awaitingTimedOut = false,
|
|
209
247
|
}: {
|
|
210
248
|
status: OrderStatus | null;
|
|
211
249
|
totalLabel: string;
|
|
@@ -223,6 +261,12 @@ export function PaymentStatus({
|
|
|
223
261
|
* is the difference between an offer and an interruption.
|
|
224
262
|
*/
|
|
225
263
|
paidExtra?: ReactNode;
|
|
264
|
+
/**
|
|
265
|
+
* The wait has been given up on — see {@link AWAITING_TIMED_OUT}. Only
|
|
266
|
+
* meaningful while AWAITING_PAYMENT; every other status has already resolved,
|
|
267
|
+
* so a stale flag cannot change what a settled screen says.
|
|
268
|
+
*/
|
|
269
|
+
awaitingTimedOut?: boolean;
|
|
226
270
|
}): JSX.Element {
|
|
227
271
|
const { LoadingState } = useCheckoutComponents();
|
|
228
272
|
const effective: OrderStatus = status ?? "AWAITING_PAYMENT";
|
|
@@ -231,9 +275,10 @@ export function PaymentStatus({
|
|
|
231
275
|
<Box
|
|
232
276
|
data-testid="payment-status"
|
|
233
277
|
data-status={effective}
|
|
278
|
+
data-timed-out={awaitingTimedOut ? "true" : undefined}
|
|
234
279
|
sx={{ display: "flex", flexDirection: "column", gap: 3, alignItems: "stretch", py: 2 }}
|
|
235
280
|
>
|
|
236
|
-
<OutcomeHero status={effective} />
|
|
281
|
+
<OutcomeHero status={effective} timedOut={awaitingTimedOut} />
|
|
237
282
|
|
|
238
283
|
{effective === "PAID" ? (
|
|
239
284
|
<PaidFacts totalLabel={totalLabel} orderId={orderId} buyerEmail={buyerEmail} />
|
|
@@ -241,7 +286,7 @@ export function PaymentStatus({
|
|
|
241
286
|
|
|
242
287
|
{effective === "PAID" ? paidExtra : null}
|
|
243
288
|
|
|
244
|
-
{effective === "AWAITING_PAYMENT" ? (
|
|
289
|
+
{effective === "AWAITING_PAYMENT" && !awaitingTimedOut ? (
|
|
245
290
|
<LoadingState variant="spinner" size="md" message="" dataTestId="payment-pending" />
|
|
246
291
|
) : null}
|
|
247
292
|
|
|
@@ -156,16 +156,43 @@ function initialStep(resuming: boolean, taxIdOnFile: boolean): Step {
|
|
|
156
156
|
*
|
|
157
157
|
* @returns true when the buyer is on their way and the caller must stop.
|
|
158
158
|
*/
|
|
159
|
-
function handOverToProvider(
|
|
159
|
+
function handOverToProvider(
|
|
160
|
+
order: CheckoutOrder,
|
|
161
|
+
navigate: CheckoutNavigate,
|
|
162
|
+
tenantSlug?: string,
|
|
163
|
+
): boolean {
|
|
160
164
|
if (!order.hostedCheckoutUrl) return false;
|
|
161
165
|
// PARK FIRST, navigate second. The order is the only thing the return trip
|
|
162
166
|
// has to rehydrate from, and the navigation may tear this SPA down before
|
|
163
167
|
// any later write lands.
|
|
164
|
-
|
|
168
|
+
//
|
|
169
|
+
// The STORE goes with it: one tab holds one slot, and on a multi-tenant
|
|
170
|
+
// storefront every store shares an origin. Without the slug, abandoning this
|
|
171
|
+
// hand-off and opening another store's checkout resumed THIS order there.
|
|
172
|
+
rememberHostedOrder(order, tenantSlug);
|
|
165
173
|
navigate(order.hostedCheckoutUrl);
|
|
166
174
|
return true;
|
|
167
175
|
}
|
|
168
176
|
|
|
177
|
+
/**
|
|
178
|
+
* How long the resumed screen keeps asking, and how often.
|
|
179
|
+
*
|
|
180
|
+
* 180 polls at 5 s ≈ 15 minutes. Both halves are chosen against what actually
|
|
181
|
+
* settles a hosted charge, which is the WEBHOOK: it lands seconds after the
|
|
182
|
+
* payment, so a faster interval buys nothing, and by a quarter of an hour a
|
|
183
|
+
* delivery that was ever coming has come. Past that the answer is not going to
|
|
184
|
+
* change while the buyer watches — the scheduled reconciliation is what
|
|
185
|
+
* rescues a genuinely late one, and it does that whether the tab is open or
|
|
186
|
+
* not.
|
|
187
|
+
*
|
|
188
|
+
* The card wait is bounded at 90 s (`CARD_AWAITING_POLL_CAP`) because a card
|
|
189
|
+
* authorises inline and a buyer is holding their phone. This leg is the other
|
|
190
|
+
* shape: the buyer has already been off to another site and back, and may
|
|
191
|
+
* legitimately still be finishing there.
|
|
192
|
+
*/
|
|
193
|
+
const HOSTED_RESUME_POLL_MS = 5_000;
|
|
194
|
+
const HOSTED_RESUME_POLL_CAP = 180;
|
|
195
|
+
|
|
169
196
|
/**
|
|
170
197
|
* The leg of checkout that resumes after a hosted provider sent the buyer back
|
|
171
198
|
* (FUT-556).
|
|
@@ -174,11 +201,27 @@ function handOverToProvider(order: CheckoutOrder, navigate: CheckoutNavigate): b
|
|
|
174
201
|
* parked copy — once, on first render — and polled here rather than in a PIX or
|
|
175
202
|
* card view, because a redirect provider produced neither. The webhook is still
|
|
176
203
|
* what settles the order; this only tells the buyer that it did.
|
|
204
|
+
*
|
|
205
|
+
* And it stops telling them eventually. This poll was the one unbounded wait
|
|
206
|
+
* left in checkout — card and wallet both cap theirs — so a buyer who came back
|
|
207
|
+
* from a payment they never completed got "Confirmando seu pagamento… isso
|
|
208
|
+
* costuma levar alguns segundos" and a spinner, truthfully forever. The screen
|
|
209
|
+
* had no way to reach a terminal state, because the ORDER has none: expiry is
|
|
210
|
+
* PIX-only, and a redirect charge carries no QR window to lapse. `timedOut` is
|
|
211
|
+
* what the screen says instead of spinning.
|
|
177
212
|
*/
|
|
178
|
-
function useHostedResume(): {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
213
|
+
function useHostedResume(tenantSlug?: string): {
|
|
214
|
+
order: CheckoutOrder | null;
|
|
215
|
+
status: OrderStatus | null;
|
|
216
|
+
timedOut: boolean;
|
|
217
|
+
} {
|
|
218
|
+
const [order] = useState(() => takeHostedOrder(tenantSlug));
|
|
219
|
+
const { status, timedOut } = usePaymentPolling(order?.orderId ?? null, {
|
|
220
|
+
enabled: Boolean(order),
|
|
221
|
+
intervalMs: HOSTED_RESUME_POLL_MS,
|
|
222
|
+
maxHealthyPolls: HOSTED_RESUME_POLL_CAP,
|
|
223
|
+
});
|
|
224
|
+
return { order, status, timedOut };
|
|
182
225
|
}
|
|
183
226
|
|
|
184
227
|
/**
|
|
@@ -252,10 +295,11 @@ export function useCheckoutController(
|
|
|
252
295
|
defaultBuyer?: BuyerInfo,
|
|
253
296
|
taxIdOnFile = false,
|
|
254
297
|
buyerFields: readonly CheckoutCustomerField[] = CPF_ONLY,
|
|
298
|
+
tenantSlug?: string,
|
|
255
299
|
) {
|
|
256
300
|
const { createOrder, saveBuyerContact, onExitToMenu, onPaid } = ports;
|
|
257
301
|
const navigate = useCheckoutNavigate();
|
|
258
|
-
const resume = useHostedResume();
|
|
302
|
+
const resume = useHostedResume(tenantSlug);
|
|
259
303
|
const [step, setStep] = useState<Step>(initialStep(Boolean(resume.order), taxIdOnFile));
|
|
260
304
|
// No method pre-selected: the Pagamento step shows just the picker until the
|
|
261
305
|
// buyer chooses PIX or card, then that method's order is raised and its UI
|
|
@@ -296,10 +340,10 @@ export function useCheckoutController(
|
|
|
296
340
|
const result = await createOrder({ method: chosen, buyer: override ?? buyer, saveProfile });
|
|
297
341
|
setCreating(false);
|
|
298
342
|
if (!result.ok) { failure.fail(result.error); return; }
|
|
299
|
-
if (handOverToProvider(result.data, navigate)) return;
|
|
343
|
+
if (handOverToProvider(result.data, navigate, tenantSlug)) return;
|
|
300
344
|
setOrder(result.data);
|
|
301
345
|
setFinalStatus(null);
|
|
302
|
-
}, [buyer, saveProfile, createOrder, clearError, navigate]);
|
|
346
|
+
}, [buyer, saveProfile, createOrder, clearError, navigate, tenantSlug]);
|
|
303
347
|
const payWithEmail = useCallback((email: string) => {
|
|
304
348
|
if (!method) return;
|
|
305
349
|
const next = { ...buyer, email };
|
|
@@ -316,6 +360,10 @@ export function useCheckoutController(
|
|
|
316
360
|
return {
|
|
317
361
|
step, setStep, method, setMethod, buyer, setBuyer, saveProfile, setSaveProfile,
|
|
318
362
|
order, finalStatus: finalStatus ?? resume.status, creating,
|
|
363
|
+
// Only ever true on the resumed leg: `useHostedResume` is the sole caller
|
|
364
|
+
// that caps its polls, and a buyer who never left has a card or PIX view
|
|
365
|
+
// reporting its own wait.
|
|
366
|
+
resumeTimedOut: resume.timedOut,
|
|
319
367
|
createError: failure.message, errorField: failure.field, errorCode: failure.code,
|
|
320
368
|
goToMenu: onExitToMenu, back, editBuyer,
|
|
321
369
|
goToPayment, startPayment, payWithEmail, handleResolved, retry, completed,
|
package/src/flows/copy.ts
CHANGED
|
@@ -35,6 +35,18 @@ export interface CheckoutCopyFE {
|
|
|
35
35
|
returnPending: string;
|
|
36
36
|
/** The return leg when nothing was parked — the buyer came back to nothing. */
|
|
37
37
|
returnUnknown: string;
|
|
38
|
+
/**
|
|
39
|
+
* The return leg once the bounded wait elapsed and the screen stopped asking
|
|
40
|
+
* (FUT-556) — say that nothing has arrived yet and, above all, not to pay
|
|
41
|
+
* again.
|
|
42
|
+
*
|
|
43
|
+
* OPTIONAL, so adopting a version with the bound is not a breaking change for
|
|
44
|
+
* a host that has not written the sentence yet. The BOUND itself applies
|
|
45
|
+
* either way: with no copy the screen still stops spinning and falls back to
|
|
46
|
+
* `returnPending` as a warning, which is the honest half. The words are the
|
|
47
|
+
* part only a host can own.
|
|
48
|
+
*/
|
|
49
|
+
returnTimedOut?: string;
|
|
38
50
|
/** Nothing to check out (cart mode only). */
|
|
39
51
|
emptyCartTitle: string;
|
|
40
52
|
emptyCartAction: string;
|
|
@@ -96,6 +96,20 @@ function buildHostedHandoff(runtime: FlowsRuntime): CheckoutScreens["HostedHando
|
|
|
96
96
|
};
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
/**
|
|
100
|
+
* How long this screen keeps asking, and how often — see the twin constants in
|
|
101
|
+
* `use-checkout-controller.ts`, which bounds the same wait for the components
|
|
102
|
+
* layer. 180 polls at 5 s ≈ 15 minutes.
|
|
103
|
+
*
|
|
104
|
+
* Stated here rather than imported because the two waits are the same DECISION
|
|
105
|
+
* arrived at twice, not one shared implementation: this screen takes its
|
|
106
|
+
* interval from the host's `polling` config when there is one, and a host that
|
|
107
|
+
* tunes that must not have this package's cap silently mean a different
|
|
108
|
+
* wall-clock window than the constant's comment claims.
|
|
109
|
+
*/
|
|
110
|
+
const RETURN_POLL_MS = 5_000;
|
|
111
|
+
const RETURN_POLL_CAP = 180;
|
|
112
|
+
|
|
99
113
|
function buildHostedReturn(runtime: FlowsRuntime): CheckoutScreens["HostedReturn"] {
|
|
100
114
|
function HostedReturnBody({
|
|
101
115
|
onResolved,
|
|
@@ -106,9 +120,13 @@ function buildHostedReturn(runtime: FlowsRuntime): CheckoutScreens["HostedReturn
|
|
|
106
120
|
// Read-and-clear, once, on first render: the resumed view belongs to
|
|
107
121
|
// exactly one return trip.
|
|
108
122
|
const [parked] = useState(takeHostedOrder);
|
|
109
|
-
|
|
123
|
+
// Bounded, for the reason on RETURN_POLL_CAP: nothing here can ever reach a
|
|
124
|
+
// terminal state on its own, so an unbounded poll is a spinner the buyer
|
|
125
|
+
// watches until they close the tab.
|
|
126
|
+
const { status, timedOut } = usePaymentPolling(parked?.orderId ?? null, {
|
|
110
127
|
enabled: Boolean(parked),
|
|
111
|
-
intervalMs: runtime.config.polling?.intervalMs,
|
|
128
|
+
intervalMs: runtime.config.polling?.intervalMs ?? RETURN_POLL_MS,
|
|
129
|
+
maxHealthyPolls: RETURN_POLL_CAP,
|
|
112
130
|
});
|
|
113
131
|
|
|
114
132
|
useEffect(() => {
|
|
@@ -126,6 +144,21 @@ function buildHostedReturn(runtime: FlowsRuntime): CheckoutScreens["HostedReturn
|
|
|
126
144
|
/>
|
|
127
145
|
);
|
|
128
146
|
}
|
|
147
|
+
if (timedOut) {
|
|
148
|
+
return (
|
|
149
|
+
<Box data-testid="checkout-hosted-return" sx={{ py: 4 }}>
|
|
150
|
+
<Alert
|
|
151
|
+
variant="warning"
|
|
152
|
+
title={runtime.copy.returnPending}
|
|
153
|
+
{...(runtime.copy.returnTimedOut === undefined
|
|
154
|
+
? {}
|
|
155
|
+
: { description: runtime.copy.returnTimedOut })}
|
|
156
|
+
showIcon
|
|
157
|
+
data-testid="checkout-hosted-return-timeout"
|
|
158
|
+
/>
|
|
159
|
+
</Box>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
129
162
|
return (
|
|
130
163
|
<Box data-testid="checkout-hosted-return" sx={{ py: 4 }}>
|
|
131
164
|
<LoadingState
|