@tribe-nest/forge 3.67.0 → 3.69.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/data/queries/useCheckoutLink.ts +70 -0
- package/src/i18n/de.json +25 -0
- package/src/i18n/en.json +25 -0
- package/src/index.ts +1 -0
- package/src/runtime/pages.ts +1 -0
- package/src/ui/headless/checkoutLink/useCheckoutLinkPayment.ts +78 -0
- package/src/ui/headless/index.ts +1 -0
- package/src/ui/index.ts +2 -0
- package/src/ui/styled/CheckoutLinkPayment.tsx +303 -0
- package/src/ui/styled/_tests/CheckoutLinkPayment.spec.tsx +231 -0
package/package.json
CHANGED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { useQuery } from "@tanstack/react-query";
|
|
2
|
+
import { useForge } from "../../provider/ForgeProvider";
|
|
3
|
+
|
|
4
|
+
/** What the pay page may show about a checkout link. Nothing about the buyer. */
|
|
5
|
+
export type CheckoutLinkView = {
|
|
6
|
+
status: "open" | "paid" | "expired" | "cancelled";
|
|
7
|
+
/** The buyer paid after the link closed: nothing was confirmed and the money goes back. */
|
|
8
|
+
needsRefund: boolean;
|
|
9
|
+
item: { type: "event_ticket" | "product" | "booking"; title: string } | null;
|
|
10
|
+
/**
|
|
11
|
+
* A booking link only: every session it holds (ISO), the business's own time
|
|
12
|
+
* zone, and the service's booking page for picking another time.
|
|
13
|
+
*/
|
|
14
|
+
booking?: {
|
|
15
|
+
sessions: { startsAt: string; endsAt: string }[];
|
|
16
|
+
timezone: string | null;
|
|
17
|
+
servicePath: string | null;
|
|
18
|
+
};
|
|
19
|
+
quantity: number;
|
|
20
|
+
/** Minor units, in `currency`. */
|
|
21
|
+
unitPriceCents: number | null;
|
|
22
|
+
totalCents: number;
|
|
23
|
+
currency: string;
|
|
24
|
+
/** ISO. When the hold ends if nobody pays. */
|
|
25
|
+
expiresAt: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type CheckoutLinkFinalizeResult = {
|
|
29
|
+
/** `fulfilled`, `paid` (settling), `unpaid`, or `needs_refund`. */
|
|
30
|
+
outcome: string;
|
|
31
|
+
link: CheckoutLinkView;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Open a checkout link by its token (`POST /public/checkout-links/open`).
|
|
36
|
+
*
|
|
37
|
+
* A POST, and not refetched on focus: opening extends the link's hold to the
|
|
38
|
+
* payment window, which is a write the page should make once per visit.
|
|
39
|
+
*/
|
|
40
|
+
export function useCheckoutLink(token?: string | null) {
|
|
41
|
+
const { client, profileId } = useForge();
|
|
42
|
+
return useQuery<CheckoutLinkView>({
|
|
43
|
+
queryKey: ["checkout-link", token, profileId],
|
|
44
|
+
queryFn: async () => {
|
|
45
|
+
const res = await client.post("/public/checkout-links/open", { profileId, token });
|
|
46
|
+
return res.data;
|
|
47
|
+
},
|
|
48
|
+
enabled: !!profileId && !!client && !!token,
|
|
49
|
+
retry: false,
|
|
50
|
+
refetchOnWindowFocus: false,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Settle a checkout link on the return page (`POST /public/checkout-links/finalize`).
|
|
56
|
+
* Idempotent on the server: a reload settles nothing twice.
|
|
57
|
+
*/
|
|
58
|
+
export function useCheckoutLinkFinalize(token?: string | null, enabled = true) {
|
|
59
|
+
const { client, profileId } = useForge();
|
|
60
|
+
return useQuery<CheckoutLinkFinalizeResult>({
|
|
61
|
+
queryKey: ["checkout-link-finalize", token, profileId],
|
|
62
|
+
queryFn: async () => {
|
|
63
|
+
const res = await client.post("/public/checkout-links/finalize", { profileId, token });
|
|
64
|
+
return res.data;
|
|
65
|
+
},
|
|
66
|
+
enabled: enabled && !!profileId && !!client && !!token,
|
|
67
|
+
retry: false,
|
|
68
|
+
refetchOnWindowFocus: false,
|
|
69
|
+
});
|
|
70
|
+
}
|
package/src/i18n/de.json
CHANGED
|
@@ -713,6 +713,31 @@
|
|
|
713
713
|
"forge.checkout_confirmation.total": "Gesamt",
|
|
714
714
|
"forge.checkout_confirmation.total_paid": "Bezahlt gesamt",
|
|
715
715
|
"forge.checkout_confirmation.view_orders": "Bestellungen ansehen",
|
|
716
|
+
"forge.checkout_link_payment.browse_store": "Zum Shop",
|
|
717
|
+
"forge.checkout_link_payment.cancelled_body": "Das Unternehmen hat diesen Link zurückgezogen. Es wurde nichts berechnet.",
|
|
718
|
+
"forge.checkout_link_payment.cancelled_title": "Dieser Link ist nicht mehr verfügbar",
|
|
719
|
+
"forge.checkout_link_payment.each": "je {price}",
|
|
720
|
+
"forge.checkout_link_payment.ended_body": "Wir konnten dies nicht länger für Sie reservieren, deshalb ist es wieder im Verkauf. Es wurde nichts berechnet.",
|
|
721
|
+
"forge.checkout_link_payment.ended_title": "Diese Reservierung ist abgelaufen",
|
|
722
|
+
"forge.checkout_link_payment.held_until": "Für Sie reserviert bis {time}",
|
|
723
|
+
"forge.checkout_link_payment.hold_noun": "Artikel",
|
|
724
|
+
"forge.checkout_link_payment.hold_noun_booking": "diesen Termin",
|
|
725
|
+
"forge.checkout_link_payment.not_found_body": "Dieser Link ist ungültig. Prüfen Sie die Adresse in Ihrer E-Mail.",
|
|
726
|
+
"forge.checkout_link_payment.not_found_title": "Link nicht gefunden",
|
|
727
|
+
"forge.checkout_link_payment.other_times": "Anderen Termin wählen",
|
|
728
|
+
"forge.checkout_link_payment.paid_body": "Vielen Dank. Die Details sind unterwegs an Ihre E-Mail-Adresse.",
|
|
729
|
+
"forge.checkout_link_payment.paid_title": "Zahlung erhalten",
|
|
730
|
+
"forge.checkout_link_payment.pay_amount": "{amount} bezahlen",
|
|
731
|
+
"forge.checkout_link_payment.payment_unavailable": "Die Zahlung ist gerade nicht möglich. Bitte versuchen Sie es gleich noch einmal.",
|
|
732
|
+
"forge.checkout_link_payment.preparing_payment": "Zahlung wird vorbereitet…",
|
|
733
|
+
"forge.checkout_link_payment.quantity": "Anzahl: {quantity}",
|
|
734
|
+
"forge.checkout_link_payment.title": "Für Sie reserviert",
|
|
735
|
+
"forge.checkout_link_payment.too_late_body": "Ihre Zahlung ist nach dem Ende dieser Reservierung eingegangen, deshalb wurde nichts gebucht. Ihre Zahlung wird erstattet.",
|
|
736
|
+
"forge.checkout_link_payment.too_late_title": "Ihre Zahlung wird erstattet",
|
|
737
|
+
"forge.checkout_link_payment.total": "Gesamt",
|
|
738
|
+
"forge.checkout_link_payment.try_again": "Erneut versuchen",
|
|
739
|
+
"forge.checkout_link_payment.unpaid_body": "Ihre Zahlung ist nicht durchgegangen, und es wurde nichts berechnet. Ihre Reservierung besteht weiter, Sie können es erneut versuchen.",
|
|
740
|
+
"forge.checkout_link_payment.unpaid_title": "Zahlung nicht abgeschlossen",
|
|
716
741
|
"forge.coaching_booking.aria_close": "Schließen",
|
|
717
742
|
"forge.coaching_booking.aria_next_month": "Nächster Monat",
|
|
718
743
|
"forge.coaching_booking.aria_next_week": "Nächste Woche",
|
package/src/i18n/en.json
CHANGED
|
@@ -713,6 +713,31 @@
|
|
|
713
713
|
"forge.checkout_confirmation.total": "Total",
|
|
714
714
|
"forge.checkout_confirmation.total_paid": "Total paid",
|
|
715
715
|
"forge.checkout_confirmation.view_orders": "View your orders",
|
|
716
|
+
"forge.checkout_link_payment.browse_store": "Browse the store",
|
|
717
|
+
"forge.checkout_link_payment.cancelled_body": "The business withdrew this link. Nothing was charged.",
|
|
718
|
+
"forge.checkout_link_payment.cancelled_title": "This link is no longer available",
|
|
719
|
+
"forge.checkout_link_payment.each": "{price} each",
|
|
720
|
+
"forge.checkout_link_payment.ended_body": "We could not hold this for you any longer, so it went back on sale. Nothing was charged.",
|
|
721
|
+
"forge.checkout_link_payment.ended_title": "This reservation has ended",
|
|
722
|
+
"forge.checkout_link_payment.held_until": "Held for you until {time}",
|
|
723
|
+
"forge.checkout_link_payment.hold_noun": "items",
|
|
724
|
+
"forge.checkout_link_payment.hold_noun_booking": "this time",
|
|
725
|
+
"forge.checkout_link_payment.not_found_body": "This link is not valid. Check the address in your email.",
|
|
726
|
+
"forge.checkout_link_payment.not_found_title": "Link not found",
|
|
727
|
+
"forge.checkout_link_payment.other_times": "Pick another time",
|
|
728
|
+
"forge.checkout_link_payment.paid_body": "Thank you. The details are on their way to your email.",
|
|
729
|
+
"forge.checkout_link_payment.paid_title": "Payment received",
|
|
730
|
+
"forge.checkout_link_payment.pay_amount": "Pay {amount}",
|
|
731
|
+
"forge.checkout_link_payment.payment_unavailable": "Payment is not available right now. Please try again in a moment.",
|
|
732
|
+
"forge.checkout_link_payment.preparing_payment": "Preparing payment…",
|
|
733
|
+
"forge.checkout_link_payment.quantity": "Quantity: {quantity}",
|
|
734
|
+
"forge.checkout_link_payment.title": "Held for you",
|
|
735
|
+
"forge.checkout_link_payment.too_late_body": "Your payment arrived after this reservation had ended, so nothing was booked. Your payment will be refunded.",
|
|
736
|
+
"forge.checkout_link_payment.too_late_title": "Your payment will be refunded",
|
|
737
|
+
"forge.checkout_link_payment.total": "Total",
|
|
738
|
+
"forge.checkout_link_payment.try_again": "Try again",
|
|
739
|
+
"forge.checkout_link_payment.unpaid_body": "Your payment did not go through, and nothing was charged. Your reservation is still held, so you can try again.",
|
|
740
|
+
"forge.checkout_link_payment.unpaid_title": "Payment not completed",
|
|
716
741
|
"forge.coaching_booking.aria_close": "Close",
|
|
717
742
|
"forge.coaching_booking.aria_next_month": "Next month",
|
|
718
743
|
"forge.coaching_booking.aria_next_week": "Next week",
|
package/src/index.ts
CHANGED
|
@@ -104,6 +104,7 @@ export * from "./data/queries/useMusicLink";
|
|
|
104
104
|
export * from "./data/queries/useEventSeries";
|
|
105
105
|
export * from "./data/queries/useInvoice";
|
|
106
106
|
export * from "./data/queries/usePaymentLink";
|
|
107
|
+
export * from "./data/queries/useCheckoutLink";
|
|
107
108
|
export * from "./data/queries/useLeadMagnet";
|
|
108
109
|
export * from "./data/queries/useBlogComments";
|
|
109
110
|
export * from "./data/queries/usePageActions";
|
package/src/runtime/pages.ts
CHANGED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { useCallback, useState } from "react";
|
|
2
|
+
import { useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import { useCheckoutLink, useCheckoutLinkFinalize } from "../../../data/queries/useCheckoutLink";
|
|
4
|
+
import { usePaymentFlow } from "../../../data/queries/usePaymentFlow";
|
|
5
|
+
|
|
6
|
+
export interface UseCheckoutLinkPaymentOptions {
|
|
7
|
+
/** Where the provider returns the buyer. Default: this page with `?finalise=1`. */
|
|
8
|
+
returnPath?: string;
|
|
9
|
+
/**
|
|
10
|
+
* True on the return leg (the provider sent the buyer back): the hook then
|
|
11
|
+
* settles the link instead of offering to pay.
|
|
12
|
+
*/
|
|
13
|
+
returning?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type ApiError = { response?: { status?: number; data?: { message?: string; code?: string; details?: { status?: string } } } };
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Headless checkout link payment: the hosted pay page `/i/checkout/pay/<token>`.
|
|
20
|
+
*
|
|
21
|
+
* Loads the link (`open`, which extends its hold to the payment window), starts
|
|
22
|
+
* its ONE payment only when the buyer presses Pay (`start-payment` runs the
|
|
23
|
+
* server's checks first: a closed link or a hold whose items were taken is
|
|
24
|
+
* refused before any payment exists), and on the return leg settles it
|
|
25
|
+
* (`finalize`). Stripe renders from `clientSecret`; Paystack opens its modal.
|
|
26
|
+
*/
|
|
27
|
+
export function useCheckoutLinkPayment(token?: string | null, opts: UseCheckoutLinkPaymentOptions = {}) {
|
|
28
|
+
const queryClient = useQueryClient();
|
|
29
|
+
const link = useCheckoutLink(opts.returning ? null : token);
|
|
30
|
+
const finalize = useCheckoutLinkFinalize(token, !!opts.returning);
|
|
31
|
+
|
|
32
|
+
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
|
33
|
+
const pathname = typeof window !== "undefined" ? window.location.pathname : "";
|
|
34
|
+
const returnUrl = token ? `${origin}${opts.returnPath ?? pathname}?finalise=1` : "";
|
|
35
|
+
|
|
36
|
+
const flow = usePaymentFlow({
|
|
37
|
+
path: "/public/checkout-links/start-payment",
|
|
38
|
+
body: { token },
|
|
39
|
+
returnUrl,
|
|
40
|
+
autoStart: false,
|
|
41
|
+
enabled: !!token,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const [refusal, setRefusal] = useState<{ message: string; code: string | null; status: string | null } | null>(null);
|
|
45
|
+
|
|
46
|
+
const pay = useCallback(async () => {
|
|
47
|
+
setRefusal(null);
|
|
48
|
+
try {
|
|
49
|
+
await flow.start();
|
|
50
|
+
} catch (error) {
|
|
51
|
+
const data = (error as ApiError)?.response?.data;
|
|
52
|
+
setRefusal({
|
|
53
|
+
message: data?.message ?? "",
|
|
54
|
+
code: data?.code ?? null,
|
|
55
|
+
status: data?.details?.status ?? null,
|
|
56
|
+
});
|
|
57
|
+
// The link's own state may have moved (paid in another tab, expired).
|
|
58
|
+
await queryClient.invalidateQueries({ queryKey: ["checkout-link", token] });
|
|
59
|
+
}
|
|
60
|
+
}, [flow, queryClient, token]);
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
link,
|
|
64
|
+
finalize,
|
|
65
|
+
returnUrl,
|
|
66
|
+
pay,
|
|
67
|
+
isStarting: flow.isStarting,
|
|
68
|
+
clientSecret: flow.clientSecret,
|
|
69
|
+
isPaystack: flow.isPaystack,
|
|
70
|
+
canOpenPaystack: flow.canOpenPaystack,
|
|
71
|
+
openPaystackCheckout: flow.openPaystackCheckout,
|
|
72
|
+
/** The server's refusal of the last Pay, in its own words, with its code. */
|
|
73
|
+
refusal,
|
|
74
|
+
/** When the hold ends: the link's own deadline, as Pay last extended it. */
|
|
75
|
+
holdExpiresAt:
|
|
76
|
+
(flow.result as { link?: { expiresAt?: string } } | null)?.link?.expiresAt ?? link.data?.expiresAt ?? null,
|
|
77
|
+
};
|
|
78
|
+
}
|
package/src/ui/headless/index.ts
CHANGED
|
@@ -159,6 +159,7 @@ export {
|
|
|
159
159
|
} from "./membership/membershipQuestionnaire";
|
|
160
160
|
export { useInvoicePayment, type UseInvoicePaymentOptions } from "./invoice/useInvoicePayment";
|
|
161
161
|
export { usePaymentLinkPayment, type UsePaymentLinkPaymentOptions } from "./paymentLink/usePaymentLinkPayment";
|
|
162
|
+
export { useCheckoutLinkPayment, type UseCheckoutLinkPaymentOptions } from "./checkoutLink/useCheckoutLinkPayment";
|
|
162
163
|
export { useBroadcastWatch } from "./broadcast/useBroadcastWatch";
|
|
163
164
|
export {
|
|
164
165
|
broadcastListStatus,
|
package/src/ui/index.ts
CHANGED
|
@@ -94,6 +94,8 @@ export {
|
|
|
94
94
|
export { Cart, type CartProps } from "./styled/Cart";
|
|
95
95
|
/** The page a cart-recovery email lands on. Drop in at `/i/checkout/resume`. */
|
|
96
96
|
export { ResumeCart, type ResumeCartProps } from "./styled/ResumeCart";
|
|
97
|
+
/** The page a checkout link lands on: an item held for this person. Drop in at `/i/checkout/pay/$token`. */
|
|
98
|
+
export { CheckoutLinkPayment, type CheckoutLinkPaymentProps } from "./styled/CheckoutLinkPayment";
|
|
97
99
|
// The resolver is exported alongside the component because the Craft themes
|
|
98
100
|
// are a separate rendering stack (Tailwind, not inline styles) and cannot use
|
|
99
101
|
// the component: but the legacy `color`/`size` fallback must not diverge.
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
import { useForgeT } from "../../i18n";
|
|
3
|
+
import { useCheckoutLinkPayment } from "../headless/checkoutLink/useCheckoutLinkPayment";
|
|
4
|
+
import { useInventoryHold } from "../headless/checkout/useInventoryHold";
|
|
5
|
+
import { usePaymentRenderer } from "../payment/ForgePaymentProvider";
|
|
6
|
+
import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
7
|
+
import { useAmountFormatter } from "../format/useFormatCurrency";
|
|
8
|
+
import type { CheckoutLinkView } from "../../data/queries/useCheckoutLink";
|
|
9
|
+
import { Button } from "./Button";
|
|
10
|
+
import { HoldNotice } from "./HoldNotice";
|
|
11
|
+
import { Loading } from "./Loading";
|
|
12
|
+
import { PaystackPayButton } from "./PaystackPayButton";
|
|
13
|
+
|
|
14
|
+
export interface CheckoutLinkPaymentProps {
|
|
15
|
+
/** The token from the link (`/i/checkout/pay/<token>`). */
|
|
16
|
+
token?: string | null;
|
|
17
|
+
/**
|
|
18
|
+
* True when the payment provider has just sent the buyer back (`?finalise=1`
|
|
19
|
+
* or Stripe's `redirect_status`): the page settles the link instead of
|
|
20
|
+
* offering to pay.
|
|
21
|
+
*/
|
|
22
|
+
returning?: boolean;
|
|
23
|
+
/** Where "browse the store" goes when the link can no longer be paid. Default `/i/store`. */
|
|
24
|
+
storePath?: string;
|
|
25
|
+
/** Format a numeric amount for display. */
|
|
26
|
+
formatAmount?: (amount: number, currencyCode?: string) => string;
|
|
27
|
+
className?: string;
|
|
28
|
+
style?: CSSProperties;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The hosted pay page for a checkout link (`/i/checkout/pay/<token>`).
|
|
33
|
+
*
|
|
34
|
+
* A business (or its agent) held an item for this person and sent them here.
|
|
35
|
+
* The page shows exactly what is held, at what price and until when, and
|
|
36
|
+
* continues to the normal payment step. It does not build a checkout: the
|
|
37
|
+
* checkout already exists on the server, and the `<Checkout>` drop-in always
|
|
38
|
+
* makes a new one, which is why this is its own component.
|
|
39
|
+
*
|
|
40
|
+
* Pressing Pay asks the server first (the link still open, the hold still
|
|
41
|
+
* there or taken again); a refusal is shown in the server's own words, before
|
|
42
|
+
* any card form exists. A paid, expired or cancelled link says which, with a
|
|
43
|
+
* way to the store.
|
|
44
|
+
*/
|
|
45
|
+
export function CheckoutLinkPayment({
|
|
46
|
+
token,
|
|
47
|
+
returning = false,
|
|
48
|
+
storePath = "/i/store",
|
|
49
|
+
formatAmount,
|
|
50
|
+
className,
|
|
51
|
+
style,
|
|
52
|
+
}: CheckoutLinkPaymentProps) {
|
|
53
|
+
const tr = useForgeT();
|
|
54
|
+
const t = useThemeTokens();
|
|
55
|
+
const fmt = useAmountFormatter(formatAmount);
|
|
56
|
+
const renderPayment = usePaymentRenderer();
|
|
57
|
+
const page = useCheckoutLinkPayment(token, { returning });
|
|
58
|
+
const hold = useInventoryHold(page.holdExpiresAt);
|
|
59
|
+
|
|
60
|
+
const shell: CSSProperties = {
|
|
61
|
+
maxWidth: 560,
|
|
62
|
+
margin: "0 auto",
|
|
63
|
+
color: t.text,
|
|
64
|
+
fontFamily: t.fontFamily,
|
|
65
|
+
...style,
|
|
66
|
+
};
|
|
67
|
+
const heading: CSSProperties = { fontSize: 26, fontWeight: 800, fontFamily: t.headingFontFamily, marginBottom: 8 };
|
|
68
|
+
const muted: CSSProperties = { color: t.muted, fontSize: 15, lineHeight: 1.6 };
|
|
69
|
+
|
|
70
|
+
const toStore = (
|
|
71
|
+
<div style={{ marginTop: 24 }}>
|
|
72
|
+
<Button onClick={() => go(storePath)} data-testid="checkout-link-store">
|
|
73
|
+
{tr("forge.checkout_link_payment.browse_store")}
|
|
74
|
+
</Button>
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
// A booking whose time is gone: the way on is another time of the same service.
|
|
78
|
+
const otherTimes = (path: string | null | undefined) =>
|
|
79
|
+
path ? (
|
|
80
|
+
<div style={{ marginTop: 24 }}>
|
|
81
|
+
<Button onClick={() => go(path)} data-testid="checkout-link-other-times">
|
|
82
|
+
{tr("forge.checkout_link_payment.other_times")}
|
|
83
|
+
</Button>
|
|
84
|
+
</div>
|
|
85
|
+
) : (
|
|
86
|
+
toStore
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
// ── The return leg: settle, then say what is true ────────────────────────
|
|
90
|
+
if (returning) {
|
|
91
|
+
if (page.finalize.isLoading) return <Loading fullPage />;
|
|
92
|
+
const outcome = page.finalize.data?.outcome;
|
|
93
|
+
if (page.finalize.error || !page.finalize.data) {
|
|
94
|
+
return (
|
|
95
|
+
<div className={className} style={shell} data-testid="checkout-link-page" data-state="not-found">
|
|
96
|
+
<h1 style={heading}>{tr("forge.checkout_link_payment.not_found_title")}</h1>
|
|
97
|
+
<p style={muted}>{tr("forge.checkout_link_payment.not_found_body")}</p>
|
|
98
|
+
{toStore}
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
if (outcome === "needs_refund" || page.finalize.data.link.needsRefund) {
|
|
103
|
+
return (
|
|
104
|
+
<div className={className} style={shell} data-testid="checkout-link-page" data-state="needs-refund">
|
|
105
|
+
<h1 style={heading}>{tr("forge.checkout_link_payment.too_late_title")}</h1>
|
|
106
|
+
<p style={muted}>{tr("forge.checkout_link_payment.too_late_body")}</p>
|
|
107
|
+
{toStore}
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
if (outcome === "fulfilled" || outcome === "paid") {
|
|
112
|
+
return (
|
|
113
|
+
<div className={className} style={shell} data-testid="checkout-link-page" data-state="paid">
|
|
114
|
+
<h1 style={heading}>{tr("forge.checkout_link_payment.paid_title")}</h1>
|
|
115
|
+
<Summary view={page.finalize.data.link} fmt={fmt} />
|
|
116
|
+
<p style={{ ...muted, marginTop: 16 }}>{tr("forge.checkout_link_payment.paid_body")}</p>
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
// Not paid (a declined card, a closed window): the link is still open, so
|
|
121
|
+
// they can try again from the same address.
|
|
122
|
+
return (
|
|
123
|
+
<div className={className} style={shell} data-testid="checkout-link-page" data-state="unpaid">
|
|
124
|
+
<h1 style={heading}>{tr("forge.checkout_link_payment.unpaid_title")}</h1>
|
|
125
|
+
<p style={muted}>{tr("forge.checkout_link_payment.unpaid_body")}</p>
|
|
126
|
+
<div style={{ marginTop: 24 }}>
|
|
127
|
+
<Button onClick={() => go(typeof window !== "undefined" ? window.location.pathname : "")}>
|
|
128
|
+
{tr("forge.checkout_link_payment.try_again")}
|
|
129
|
+
</Button>
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── The page as the email opens it ───────────────────────────────────────
|
|
136
|
+
if (page.link.isLoading) return <Loading fullPage />;
|
|
137
|
+
const view = page.link.data;
|
|
138
|
+
if (page.link.error || !view) {
|
|
139
|
+
return (
|
|
140
|
+
<div className={className} style={shell} data-testid="checkout-link-page" data-state="not-found">
|
|
141
|
+
<h1 style={heading}>{tr("forge.checkout_link_payment.not_found_title")}</h1>
|
|
142
|
+
<p style={muted}>{tr("forge.checkout_link_payment.not_found_body")}</p>
|
|
143
|
+
{toStore}
|
|
144
|
+
</div>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (view.status !== "open") {
|
|
149
|
+
const closed = view.needsRefund
|
|
150
|
+
? { title: tr("forge.checkout_link_payment.too_late_title"), body: tr("forge.checkout_link_payment.too_late_body") }
|
|
151
|
+
: view.status === "paid"
|
|
152
|
+
? { title: tr("forge.checkout_link_payment.paid_title"), body: tr("forge.checkout_link_payment.paid_body") }
|
|
153
|
+
: view.status === "cancelled"
|
|
154
|
+
? {
|
|
155
|
+
title: tr("forge.checkout_link_payment.cancelled_title"),
|
|
156
|
+
body: tr("forge.checkout_link_payment.cancelled_body"),
|
|
157
|
+
}
|
|
158
|
+
: { title: tr("forge.checkout_link_payment.ended_title"), body: tr("forge.checkout_link_payment.ended_body") };
|
|
159
|
+
return (
|
|
160
|
+
<div className={className} style={shell} data-testid="checkout-link-page" data-state={view.status}>
|
|
161
|
+
<h1 style={heading}>{closed.title}</h1>
|
|
162
|
+
<p style={muted}>{closed.body}</p>
|
|
163
|
+
{view.status !== "paid" && (view.booking ? otherTimes(view.booking.servicePath) : toStore)}
|
|
164
|
+
</div>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const total = fmt(view.totalCents / 100, view.currency);
|
|
169
|
+
const refused = page.refusal;
|
|
170
|
+
const timeTaken = !!refused && refused.code === "INVENTORY_HOLD_EXPIRED" && !!view.booking;
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<div className={className} style={shell} data-testid="checkout-link-page" data-state="open">
|
|
174
|
+
<h1 style={heading}>{tr("forge.checkout_link_payment.title")}</h1>
|
|
175
|
+
<Summary view={view} fmt={fmt} />
|
|
176
|
+
|
|
177
|
+
<div style={{ marginTop: 16 }}>
|
|
178
|
+
<HoldNotice
|
|
179
|
+
hold={hold}
|
|
180
|
+
noun={tr(view.booking ? "forge.checkout_link_payment.hold_noun_booking" : "forge.checkout_link_payment.hold_noun")}
|
|
181
|
+
/>
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
{refused && (
|
|
185
|
+
<p role="alert" data-testid="checkout-link-refusal" style={{ color: t.secondary, fontSize: 14, marginTop: 8 }}>
|
|
186
|
+
{refused.message || tr("forge.checkout_link_payment.payment_unavailable")}
|
|
187
|
+
</p>
|
|
188
|
+
)}
|
|
189
|
+
{timeTaken && otherTimes(view.booking?.servicePath)}
|
|
190
|
+
|
|
191
|
+
<div style={{ marginTop: 16 }}>
|
|
192
|
+
{page.isPaystack ? (
|
|
193
|
+
page.canOpenPaystack ? (
|
|
194
|
+
<PaystackPayButton
|
|
195
|
+
onPay={page.openPaystackCheckout}
|
|
196
|
+
label={tr("forge.checkout_link_payment.pay_amount", { amount: total })}
|
|
197
|
+
/>
|
|
198
|
+
) : (
|
|
199
|
+
<Loading label={tr("forge.checkout_link_payment.preparing_payment")} size={22} />
|
|
200
|
+
)
|
|
201
|
+
) : page.clientSecret ? (
|
|
202
|
+
renderPayment?.({
|
|
203
|
+
clientSecret: page.clientSecret,
|
|
204
|
+
returnUrl: page.returnUrl,
|
|
205
|
+
amount: view.totalCents / 100,
|
|
206
|
+
currency: view.currency,
|
|
207
|
+
mode: "redirect",
|
|
208
|
+
})
|
|
209
|
+
) : timeTaken ? null : (
|
|
210
|
+
<Button
|
|
211
|
+
fullWidth
|
|
212
|
+
loading={page.isStarting}
|
|
213
|
+
disabled={hold.hasLapsed && !refused}
|
|
214
|
+
onClick={() => void page.pay()}
|
|
215
|
+
data-testid="checkout-link-pay"
|
|
216
|
+
>
|
|
217
|
+
{tr("forge.checkout_link_payment.pay_amount", { amount: total })}
|
|
218
|
+
</Button>
|
|
219
|
+
)}
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function Summary({
|
|
226
|
+
view,
|
|
227
|
+
fmt,
|
|
228
|
+
}: {
|
|
229
|
+
view: CheckoutLinkView;
|
|
230
|
+
fmt: (amount: number, currencyCode?: string) => string;
|
|
231
|
+
}) {
|
|
232
|
+
const tr = useForgeT();
|
|
233
|
+
const t = useThemeTokens();
|
|
234
|
+
return (
|
|
235
|
+
<div
|
|
236
|
+
data-testid="checkout-link-summary"
|
|
237
|
+
style={{
|
|
238
|
+
border: `1px solid ${t.border}`,
|
|
239
|
+
borderRadius: t.cornerRadius,
|
|
240
|
+
padding: 20,
|
|
241
|
+
background: t.surface,
|
|
242
|
+
}}
|
|
243
|
+
>
|
|
244
|
+
<div style={{ fontSize: 18, fontWeight: 700 }} data-testid="checkout-link-item">
|
|
245
|
+
{view.item?.title}
|
|
246
|
+
</div>
|
|
247
|
+
{view.booking ? (
|
|
248
|
+
<div style={{ marginTop: 12, color: t.muted }}>
|
|
249
|
+
{view.booking.sessions.map((session) => (
|
|
250
|
+
// The buyer's own clock: the time is shown in the zone of the
|
|
251
|
+
// device reading it, with the zone named.
|
|
252
|
+
<div key={session.startsAt} data-testid="checkout-link-session" suppressHydrationWarning>
|
|
253
|
+
{formatLocalTime(session.startsAt)}
|
|
254
|
+
</div>
|
|
255
|
+
))}
|
|
256
|
+
{view.status === "open" && (
|
|
257
|
+
<div style={{ marginTop: 8, fontSize: 14 }} data-testid="checkout-link-held-until" suppressHydrationWarning>
|
|
258
|
+
{tr("forge.checkout_link_payment.held_until", { time: formatLocalTime(view.expiresAt) })}
|
|
259
|
+
</div>
|
|
260
|
+
)}
|
|
261
|
+
</div>
|
|
262
|
+
) : (
|
|
263
|
+
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 12, color: t.muted }}>
|
|
264
|
+
<span data-testid="checkout-link-quantity">
|
|
265
|
+
{tr("forge.checkout_link_payment.quantity", { quantity: view.quantity })}
|
|
266
|
+
</span>
|
|
267
|
+
{view.unitPriceCents !== null && (
|
|
268
|
+
<span>{tr("forge.checkout_link_payment.each", { price: fmt(view.unitPriceCents / 100, view.currency) })}</span>
|
|
269
|
+
)}
|
|
270
|
+
</div>
|
|
271
|
+
)}
|
|
272
|
+
<div
|
|
273
|
+
style={{ display: "flex", justifyContent: "space-between", marginTop: 12, fontSize: 20, fontWeight: 800 }}
|
|
274
|
+
>
|
|
275
|
+
<span>{tr("forge.checkout_link_payment.total")}</span>
|
|
276
|
+
<span data-testid="checkout-link-total" style={{ color: t.primary }}>
|
|
277
|
+
{fmt(view.totalCents / 100, view.currency)}
|
|
278
|
+
</span>
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function go(path: string) {
|
|
285
|
+
if (typeof window !== "undefined") window.location.href = path;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** A moment in the reader's own time zone, with the zone named ("Tuesday 14 October, 10:00 CEST"). */
|
|
289
|
+
function formatLocalTime(iso: string, timeZone?: string): string {
|
|
290
|
+
try {
|
|
291
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
292
|
+
weekday: "long",
|
|
293
|
+
day: "numeric",
|
|
294
|
+
month: "long",
|
|
295
|
+
hour: "2-digit",
|
|
296
|
+
minute: "2-digit",
|
|
297
|
+
timeZoneName: "short",
|
|
298
|
+
...(timeZone ? { timeZone } : {}),
|
|
299
|
+
}).format(new Date(iso));
|
|
300
|
+
} catch {
|
|
301
|
+
return new Date(iso).toISOString();
|
|
302
|
+
}
|
|
303
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
4
|
+
import { ForgeClientProvider } from "../../../provider/ForgeProvider";
|
|
5
|
+
import { PublicAuthContext } from "../../../contexts/PublicAuthContext";
|
|
6
|
+
import { ForgeThemeProvider } from "../../theme/ForgeThemeProvider";
|
|
7
|
+
import type { CheckoutLinkView } from "../../../data/queries/useCheckoutLink";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The hosted pay page for a checkout link (`/i/checkout/pay/<token>`).
|
|
11
|
+
*
|
|
12
|
+
* `useCheckoutLinkPayment` is stubbed at the module boundary: the server
|
|
13
|
+
* decides everything (open, closed, paid too late); what can only go wrong
|
|
14
|
+
* here is the page drawing the wrong state. Each state is a different fact
|
|
15
|
+
* the buyer acts on: pay now, it is already paid, the hold ended, it was
|
|
16
|
+
* withdrawn, or a refund is coming.
|
|
17
|
+
*
|
|
18
|
+
* Rendered through `react-dom/server`, so no DOM and no effects.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
type Page = ReturnType<typeof import("../../headless/checkoutLink/useCheckoutLinkPayment").useCheckoutLinkPayment>;
|
|
22
|
+
const state = vi.hoisted(() => ({ current: null as unknown }));
|
|
23
|
+
|
|
24
|
+
vi.mock("../../headless/checkoutLink/useCheckoutLinkPayment", () => ({
|
|
25
|
+
useCheckoutLinkPayment: () => state.current,
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
const { CheckoutLinkPayment } = await import("../CheckoutLinkPayment");
|
|
29
|
+
|
|
30
|
+
const view = (overrides: Partial<CheckoutLinkView> = {}): CheckoutLinkView => ({
|
|
31
|
+
status: "open",
|
|
32
|
+
needsRefund: false,
|
|
33
|
+
item: { type: "event_ticket", title: "Spring Show: General" },
|
|
34
|
+
quantity: 2,
|
|
35
|
+
unitPriceCents: 2500,
|
|
36
|
+
totalCents: 5000,
|
|
37
|
+
currency: "USD",
|
|
38
|
+
expiresAt: new Date(Date.now() + 20 * 60_000).toISOString(),
|
|
39
|
+
...overrides,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const page = (overrides: Record<string, unknown> = {}) =>
|
|
43
|
+
({
|
|
44
|
+
link: { isLoading: false, error: null, data: view() },
|
|
45
|
+
finalize: { isLoading: false, error: null, data: undefined },
|
|
46
|
+
returnUrl: "https://shop.test/i/checkout/pay/tok?finalise=1",
|
|
47
|
+
pay: async () => {},
|
|
48
|
+
isStarting: false,
|
|
49
|
+
clientSecret: undefined,
|
|
50
|
+
isPaystack: false,
|
|
51
|
+
canOpenPaystack: false,
|
|
52
|
+
openPaystackCheckout: async () => "unavailable",
|
|
53
|
+
refusal: null,
|
|
54
|
+
holdExpiresAt: view().expiresAt,
|
|
55
|
+
...overrides,
|
|
56
|
+
}) as unknown as Page;
|
|
57
|
+
|
|
58
|
+
function render(current: Page, props: { returning?: boolean } = {}): string {
|
|
59
|
+
state.current = current;
|
|
60
|
+
return renderToStaticMarkup(
|
|
61
|
+
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
|
62
|
+
<ForgeClientProvider apiUrl="http://api.test" profileId="profile-1">
|
|
63
|
+
<PublicAuthContext.Provider value={{ currencies: null, userSelectedCurrency: "USD" } as never}>
|
|
64
|
+
<ForgeThemeProvider
|
|
65
|
+
theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}
|
|
66
|
+
>
|
|
67
|
+
<CheckoutLinkPayment token="tok" formatAmount={(n) => `$${n.toFixed(2)}`} {...props} />
|
|
68
|
+
</ForgeThemeProvider>
|
|
69
|
+
</PublicAuthContext.Provider>
|
|
70
|
+
</ForgeClientProvider>
|
|
71
|
+
</QueryClientProvider>,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe("CheckoutLinkPayment", () => {
|
|
76
|
+
it("an open link shows the item, the quantity, the price and a Pay button for the total", () => {
|
|
77
|
+
const html = render(page());
|
|
78
|
+
expect(html).toContain('data-state="open"');
|
|
79
|
+
expect(html).toContain("Held for you");
|
|
80
|
+
expect(html).toContain("Spring Show: General");
|
|
81
|
+
expect(html).toContain("Quantity: 2");
|
|
82
|
+
expect(html).toContain("$25.00 each");
|
|
83
|
+
expect(html).toContain("$50.00");
|
|
84
|
+
expect(html).toContain('data-testid="checkout-link-pay"');
|
|
85
|
+
expect(html).toContain("Pay $50.00");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("a refused Pay shows the server's own words", () => {
|
|
89
|
+
const html = render(
|
|
90
|
+
page({ refusal: { message: "These tickets are no longer held for you.", code: "INVENTORY_HOLD_EXPIRED", status: null } }),
|
|
91
|
+
);
|
|
92
|
+
expect(html).toContain('data-testid="checkout-link-refusal"');
|
|
93
|
+
expect(html).toContain("These tickets are no longer held for you.");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it.each([
|
|
97
|
+
[{ status: "paid" as const }, "Payment received", false],
|
|
98
|
+
[{ status: "expired" as const }, "This reservation has ended", true],
|
|
99
|
+
[{ status: "cancelled" as const }, "This link is no longer available", true],
|
|
100
|
+
[{ status: "expired" as const, needsRefund: true }, "Your payment will be refunded", true],
|
|
101
|
+
])("a closed link (%o) says so, and offers the store unless it was paid", (overrides, title, offersStore) => {
|
|
102
|
+
const html = render(page({ link: { isLoading: false, error: null, data: view(overrides) } }));
|
|
103
|
+
expect(html).toContain(title);
|
|
104
|
+
expect(html).not.toContain('data-testid="checkout-link-pay"');
|
|
105
|
+
expect(html.includes('data-testid="checkout-link-store"')).toBe(offersStore);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("a booking", () => {
|
|
109
|
+
const START = "2026-11-03T15:00:00.000Z";
|
|
110
|
+
const booking = (overrides: Partial<CheckoutLinkView> = {}) =>
|
|
111
|
+
view({
|
|
112
|
+
item: { type: "booking", title: "Initial consultation" },
|
|
113
|
+
quantity: 1,
|
|
114
|
+
unitPriceCents: 15000,
|
|
115
|
+
totalCents: 15000,
|
|
116
|
+
expiresAt: "2026-11-01T12:30:00.000Z",
|
|
117
|
+
booking: {
|
|
118
|
+
sessions: [{ startsAt: START, endsAt: "2026-11-03T16:00:00.000Z" }],
|
|
119
|
+
timezone: "America/New_York",
|
|
120
|
+
servicePath: "/i/coaching/initial-consultation",
|
|
121
|
+
},
|
|
122
|
+
...overrides,
|
|
123
|
+
});
|
|
124
|
+
// What the page must print: the buyer's own clock, the zone named.
|
|
125
|
+
const local = (iso: string) =>
|
|
126
|
+
new Intl.DateTimeFormat(undefined, {
|
|
127
|
+
weekday: "long",
|
|
128
|
+
day: "numeric",
|
|
129
|
+
month: "long",
|
|
130
|
+
hour: "2-digit",
|
|
131
|
+
minute: "2-digit",
|
|
132
|
+
timeZoneName: "short",
|
|
133
|
+
}).format(new Date(iso));
|
|
134
|
+
|
|
135
|
+
it("shows the service, the session in the reader's own zone, until when it is held, and the price, with no quantity", () => {
|
|
136
|
+
const html = render(page({ link: { isLoading: false, error: null, data: booking() } }));
|
|
137
|
+
expect(html).toContain('data-state="open"');
|
|
138
|
+
expect(html).toContain("Initial consultation");
|
|
139
|
+
expect(html).toContain('data-testid="checkout-link-session"');
|
|
140
|
+
expect(html).toContain(local(START));
|
|
141
|
+
expect(html).toContain(`Held for you until ${local("2026-11-01T12:30:00.000Z")}`);
|
|
142
|
+
expect(html).toContain("Pay $150.00");
|
|
143
|
+
expect(html).not.toContain('data-testid="checkout-link-quantity"');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("lists every session of a package", () => {
|
|
147
|
+
const second = "2026-11-10T15:00:00.000Z";
|
|
148
|
+
const html = render(
|
|
149
|
+
page({
|
|
150
|
+
link: {
|
|
151
|
+
isLoading: false,
|
|
152
|
+
error: null,
|
|
153
|
+
data: booking({
|
|
154
|
+
booking: {
|
|
155
|
+
sessions: [
|
|
156
|
+
{ startsAt: START, endsAt: "2026-11-03T16:00:00.000Z" },
|
|
157
|
+
{ startsAt: second, endsAt: "2026-11-10T16:00:00.000Z" },
|
|
158
|
+
],
|
|
159
|
+
timezone: "UTC",
|
|
160
|
+
servicePath: null,
|
|
161
|
+
},
|
|
162
|
+
}),
|
|
163
|
+
},
|
|
164
|
+
}),
|
|
165
|
+
);
|
|
166
|
+
expect(html.split('data-testid="checkout-link-session"')).toHaveLength(3);
|
|
167
|
+
expect(html).toContain(local(second));
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("a time that was taken while unpaid: the refusal, no Pay button, and a way to pick another time", () => {
|
|
171
|
+
const html = render(
|
|
172
|
+
page({
|
|
173
|
+
link: { isLoading: false, error: null, data: booking() },
|
|
174
|
+
refusal: {
|
|
175
|
+
message: "Your hold on this time expired and it is no longer available. Please pick another time",
|
|
176
|
+
code: "INVENTORY_HOLD_EXPIRED",
|
|
177
|
+
status: null,
|
|
178
|
+
},
|
|
179
|
+
}),
|
|
180
|
+
);
|
|
181
|
+
expect(html).toContain('data-testid="checkout-link-refusal"');
|
|
182
|
+
expect(html).toContain('data-testid="checkout-link-other-times"');
|
|
183
|
+
expect(html).toContain("Pick another time");
|
|
184
|
+
expect(html).not.toContain('data-testid="checkout-link-pay"');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("an ended booking link offers another time instead of the store", () => {
|
|
188
|
+
const html = render(page({ link: { isLoading: false, error: null, data: booking({ status: "expired" }) } }));
|
|
189
|
+
expect(html).toContain("This reservation has ended");
|
|
190
|
+
expect(html).toContain('data-testid="checkout-link-other-times"');
|
|
191
|
+
expect(html).not.toContain('data-testid="checkout-link-store"');
|
|
192
|
+
expect(html).not.toContain('data-testid="checkout-link-held-until"');
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("an unknown link says it is not valid", () => {
|
|
197
|
+
const html = render(page({ link: { isLoading: false, error: new Error("404"), data: undefined } }));
|
|
198
|
+
expect(html).toContain('data-state="not-found"');
|
|
199
|
+
expect(html).toContain("Link not found");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("the return leg: paid shows the confirmation, unpaid offers another try, too late says a refund is coming", () => {
|
|
203
|
+
const paid = render(
|
|
204
|
+
page({ finalize: { isLoading: false, error: null, data: { outcome: "fulfilled", link: view({ status: "paid" }) } } }),
|
|
205
|
+
{ returning: true },
|
|
206
|
+
);
|
|
207
|
+
expect(paid).toContain('data-state="paid"');
|
|
208
|
+
expect(paid).toContain("Payment received");
|
|
209
|
+
expect(paid).toContain("Spring Show: General");
|
|
210
|
+
|
|
211
|
+
const unpaid = render(
|
|
212
|
+
page({ finalize: { isLoading: false, error: null, data: { outcome: "unpaid", link: view() } } }),
|
|
213
|
+
{ returning: true },
|
|
214
|
+
);
|
|
215
|
+
expect(unpaid).toContain('data-state="unpaid"');
|
|
216
|
+
expect(unpaid).toContain("Try again");
|
|
217
|
+
|
|
218
|
+
const late = render(
|
|
219
|
+
page({
|
|
220
|
+
finalize: {
|
|
221
|
+
isLoading: false,
|
|
222
|
+
error: null,
|
|
223
|
+
data: { outcome: "needs_refund", link: view({ status: "expired", needsRefund: true }) },
|
|
224
|
+
},
|
|
225
|
+
}),
|
|
226
|
+
{ returning: true },
|
|
227
|
+
);
|
|
228
|
+
expect(late).toContain('data-state="needs-refund"');
|
|
229
|
+
expect(late).toContain("Your payment will be refunded");
|
|
230
|
+
});
|
|
231
|
+
});
|