@tribe-nest/forge 3.67.0 → 3.74.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/_tests/formConsent.spec.ts +57 -0
- package/src/data/queries/_tests/websiteAgentChoice.spec.ts +155 -0
- package/src/data/queries/_tests/websiteAgentEmbed.spec.ts +52 -0
- package/src/data/queries/_tests/websiteAgentTranscript.spec.ts +119 -0
- package/src/data/queries/useCheckoutLink.ts +127 -0
- package/src/data/queries/useForms.ts +73 -4
- package/src/data/queries/useLeadMagnet.ts +9 -2
- package/src/data/queries/useWebsiteAgent.ts +205 -10
- package/src/i18n/de.json +35 -1
- package/src/i18n/en.json +35 -1
- package/src/index.ts +1 -0
- package/src/provider/ForgeProvider.tsx +12 -2
- package/src/runtime/pages.ts +1 -0
- package/src/types/models.ts +24 -0
- package/src/ui/headless/agent/useAiAgent.ts +171 -20
- package/src/ui/headless/agent/visitorId.ts +27 -0
- package/src/ui/headless/checkoutLink/useCheckoutLinkPayment.ts +116 -0
- package/src/ui/headless/forms/useEmailListForm.ts +45 -3
- package/src/ui/headless/forms/useSectionedForm.ts +26 -6
- package/src/ui/headless/index.ts +1 -0
- package/src/ui/index.ts +4 -2
- package/src/ui/styled/AiAgentWidget.tsx +202 -34
- package/src/ui/styled/Checkout.tsx +1 -113
- package/src/ui/styled/CheckoutLinkPayment.tsx +606 -0
- package/src/ui/styled/EmailListForm.tsx +28 -2
- package/src/ui/styled/FormRenderer.tsx +55 -3
- package/src/ui/styled/ShippingRateSelector.tsx +122 -0
- package/src/ui/styled/_tests/CheckoutLinkPayment.spec.tsx +316 -0
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
usePublicForm,
|
|
4
|
+
useSubmitPublicForm,
|
|
5
|
+
formConsentPayload,
|
|
6
|
+
consentWithoutPhone,
|
|
7
|
+
} from "../../data/queries/useForms";
|
|
3
8
|
import type { FormField } from "../../types/models";
|
|
4
9
|
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
5
|
-
import { useForgeT } from "../../i18n";
|
|
10
|
+
import { useForgeLocale, useForgeT } from "../../i18n";
|
|
6
11
|
import { Loading } from "./Loading";
|
|
12
|
+
import { PhoneInput } from "./PhoneInput";
|
|
13
|
+
|
|
7
14
|
|
|
8
15
|
export interface FormRendererProps {
|
|
9
16
|
formId: string;
|
|
@@ -17,6 +24,7 @@ export interface FormRendererProps {
|
|
|
17
24
|
export function FormRenderer({ formId }: FormRendererProps) {
|
|
18
25
|
const theme = useForgeTheme();
|
|
19
26
|
const t = useForgeT();
|
|
27
|
+
const locale = useForgeLocale();
|
|
20
28
|
const { data: form, isLoading } = usePublicForm(formId);
|
|
21
29
|
const submitMutation = useSubmitPublicForm(formId);
|
|
22
30
|
|
|
@@ -45,9 +53,13 @@ export function FormRenderer({ formId }: FormRendererProps) {
|
|
|
45
53
|
}
|
|
46
54
|
}
|
|
47
55
|
}
|
|
56
|
+
if (consentWithoutPhone(fields, answers)) {
|
|
57
|
+
setError(t("forge.form_renderer.consent_phone_required"));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
48
60
|
try {
|
|
49
61
|
const entries = fields.map((f) => ({ fieldId: f.id, value: answers[f.id] || null }));
|
|
50
|
-
await submitMutation.mutateAsync(entries);
|
|
62
|
+
await submitMutation.mutateAsync({ answers: entries, consent: formConsentPayload(fields, locale) });
|
|
51
63
|
setIsCompleted(true);
|
|
52
64
|
} catch {
|
|
53
65
|
setError(t("forge.form_renderer.submit_error"));
|
|
@@ -138,8 +150,28 @@ export function FieldInput({
|
|
|
138
150
|
</label>
|
|
139
151
|
);
|
|
140
152
|
|
|
153
|
+
// A consent box: unticked by default, never required, and its text is the
|
|
154
|
+
// server's locked wording shown exactly as served (it is the evidence).
|
|
155
|
+
if (field.type === "call_consent") {
|
|
156
|
+
if (!field.consent) return null;
|
|
157
|
+
return (
|
|
158
|
+
<label style={{ display: "flex", alignItems: "flex-start", gap: 10, fontSize: 14, lineHeight: 1.5 }}>
|
|
159
|
+
<input
|
|
160
|
+
type="checkbox"
|
|
161
|
+
checked={value === "true"}
|
|
162
|
+
onChange={(e) => onChange(e.target.checked ? "true" : "")}
|
|
163
|
+
style={{ marginTop: 4, flexShrink: 0 }}
|
|
164
|
+
/>
|
|
165
|
+
<span>{field.consent.text}</span>
|
|
166
|
+
</label>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
141
170
|
let control: React.ReactNode;
|
|
142
171
|
switch (field.type) {
|
|
172
|
+
case "phone":
|
|
173
|
+
control = <PhoneFieldControl fieldId={field.id} value={value} onChange={onChange} />;
|
|
174
|
+
break;
|
|
143
175
|
case "long_text":
|
|
144
176
|
control = (
|
|
145
177
|
<textarea
|
|
@@ -262,3 +294,23 @@ export function FieldInput({
|
|
|
262
294
|
</div>
|
|
263
295
|
);
|
|
264
296
|
}
|
|
297
|
+
|
|
298
|
+
/** A form's phone question: country select plus number, submitted as E.164. */
|
|
299
|
+
function PhoneFieldControl({
|
|
300
|
+
fieldId,
|
|
301
|
+
value,
|
|
302
|
+
onChange,
|
|
303
|
+
}: {
|
|
304
|
+
fieldId: string;
|
|
305
|
+
value: string;
|
|
306
|
+
onChange: (v: string) => void;
|
|
307
|
+
}) {
|
|
308
|
+
return (
|
|
309
|
+
<PhoneInput
|
|
310
|
+
id={`forge-form-phone-${fieldId}`}
|
|
311
|
+
mode="shipping"
|
|
312
|
+
initialValue={value || undefined}
|
|
313
|
+
onChange={(e164) => onChange(e164 ?? "")}
|
|
314
|
+
/>
|
|
315
|
+
);
|
|
316
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { SelectedShippingRate } from "../headless/checkout/useCheckout";
|
|
2
|
+
import type { ShippingRate, ShippingGroup } from "../../data/queries/useShipping";
|
|
3
|
+
import { useForgeT } from "../../i18n";
|
|
4
|
+
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
5
|
+
|
|
6
|
+
function alpha(value: number) {
|
|
7
|
+
return Math.round(value * 255)
|
|
8
|
+
.toString(16)
|
|
9
|
+
.padStart(2, "0");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Shared by the store checkout and the checkout link pay page (phase 2), so a
|
|
13
|
+
// buyer picks shipping the same way on both.
|
|
14
|
+
/**
|
|
15
|
+
* Renders the shipping groups: fixed lines for auto-selected flat/external
|
|
16
|
+
* groups, and a radio list for the selectable (dynamic/carrier) group. Selection
|
|
17
|
+
* state is owned by `useCheckout`; this component just renders + calls `onSelect`.
|
|
18
|
+
*/
|
|
19
|
+
export function ShippingRateSelector({
|
|
20
|
+
groups,
|
|
21
|
+
selections,
|
|
22
|
+
onSelect,
|
|
23
|
+
formatCurrency,
|
|
24
|
+
}: {
|
|
25
|
+
groups: ShippingGroup[];
|
|
26
|
+
selections: Record<string, SelectedShippingRate>;
|
|
27
|
+
onSelect: (group: ShippingGroup, rate: ShippingRate) => void;
|
|
28
|
+
formatCurrency: (amount: number, currencyCode?: string) => string;
|
|
29
|
+
}) {
|
|
30
|
+
const theme = useForgeTheme();
|
|
31
|
+
const tr = useForgeT();
|
|
32
|
+
if (groups.length === 0) return null;
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div className="fg:space-y-6">
|
|
36
|
+
{groups.map((group) => (
|
|
37
|
+
<div
|
|
38
|
+
key={group.id}
|
|
39
|
+
className="fg:p-4 fg:rounded-lg"
|
|
40
|
+
style={{ border: `1px solid ${theme.colors.primary}${alpha(0.15)}`, borderRadius: `${theme.cornerRadius}px` }}
|
|
41
|
+
>
|
|
42
|
+
<p className="fg:font-medium fg:mb-3" style={{ color: theme.colors.text }}>
|
|
43
|
+
{group.title}
|
|
44
|
+
</p>
|
|
45
|
+
|
|
46
|
+
{!group.selectable && group.rates.length > 0 ? (
|
|
47
|
+
// Flat or external: a single, auto-selected rate.
|
|
48
|
+
<div
|
|
49
|
+
className="fg:flex fg:items-center fg:justify-between fg:p-3 fg:rounded-md"
|
|
50
|
+
style={{
|
|
51
|
+
backgroundColor: `${theme.colors.primary}${alpha(0.05)}`,
|
|
52
|
+
borderRadius: `${theme.cornerRadius}px`,
|
|
53
|
+
}}
|
|
54
|
+
>
|
|
55
|
+
<div>
|
|
56
|
+
<span className="fg:text-sm fg:font-medium" style={{ color: theme.colors.text }}>
|
|
57
|
+
{group.rates[0].carrier} · {group.rates[0].service}
|
|
58
|
+
</span>
|
|
59
|
+
{group.rates[0].estimatedDays != null && (
|
|
60
|
+
<span className="fg:text-xs fg:ml-2" style={{ color: `${theme.colors.text}${alpha(0.6)}` }}>
|
|
61
|
+
{tr("forge.checkout.rate_days", { days: group.rates[0].estimatedDays })}
|
|
62
|
+
</span>
|
|
63
|
+
)}
|
|
64
|
+
</div>
|
|
65
|
+
<span className="fg:text-sm fg:font-semibold" style={{ color: theme.colors.primary }}>
|
|
66
|
+
{formatCurrency(group.rates[0].amount, group.rates[0].currency)}
|
|
67
|
+
</span>
|
|
68
|
+
</div>
|
|
69
|
+
) : (
|
|
70
|
+
// Dynamic: the buyer picks a carrier option.
|
|
71
|
+
<div className="fg:space-y-2">
|
|
72
|
+
{group.rates.length === 0 && (
|
|
73
|
+
<p className="fg:text-sm" style={{ color: `${theme.colors.text}${alpha(0.6)}` }}>
|
|
74
|
+
{tr("forge.checkout.no_shipping_options")}
|
|
75
|
+
</p>
|
|
76
|
+
)}
|
|
77
|
+
{group.rates.map((rate) => {
|
|
78
|
+
const isSelected = selections[group.id]?.rateId === rate.rateId;
|
|
79
|
+
return (
|
|
80
|
+
<label
|
|
81
|
+
key={rate.rateId}
|
|
82
|
+
className="fg:flex fg:items-center fg:justify-between fg:p-3 fg:rounded-md fg:cursor-pointer fg:transition-colors"
|
|
83
|
+
style={{
|
|
84
|
+
backgroundColor: isSelected ? `${theme.colors.primary}${alpha(0.1)}` : "transparent",
|
|
85
|
+
border: `1px solid ${isSelected ? theme.colors.primary : `${theme.colors.primary}${alpha(0.2)}`}`,
|
|
86
|
+
borderRadius: `${theme.cornerRadius}px`,
|
|
87
|
+
}}
|
|
88
|
+
>
|
|
89
|
+
<div className="fg:flex fg:items-center fg:gap-3">
|
|
90
|
+
<input
|
|
91
|
+
type="radio"
|
|
92
|
+
name={`shipping-rate-${group.id}`}
|
|
93
|
+
checked={isSelected}
|
|
94
|
+
onChange={() => onSelect(group, rate)}
|
|
95
|
+
style={{ accentColor: theme.colors.primary }}
|
|
96
|
+
/>
|
|
97
|
+
<div>
|
|
98
|
+
<span className="fg:text-sm fg:font-medium" style={{ color: theme.colors.text }}>
|
|
99
|
+
{rate.carrier} - {rate.service}
|
|
100
|
+
</span>
|
|
101
|
+
{rate.estimatedDays && (
|
|
102
|
+
<p className="fg:text-xs" style={{ color: `${theme.colors.text}${alpha(0.6)}` }}>
|
|
103
|
+
{rate.estimatedDays === 1
|
|
104
|
+
? tr("forge.checkout.rate_estimated_day", { days: rate.estimatedDays })
|
|
105
|
+
: tr("forge.checkout.rate_estimated_days", { days: rate.estimatedDays })}
|
|
106
|
+
</p>
|
|
107
|
+
)}
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
<span className="fg:text-sm fg:font-semibold" style={{ color: theme.colors.primary }}>
|
|
111
|
+
{formatCurrency(rate.amount, rate.currency)}
|
|
112
|
+
</span>
|
|
113
|
+
</label>
|
|
114
|
+
);
|
|
115
|
+
})}
|
|
116
|
+
</div>
|
|
117
|
+
)}
|
|
118
|
+
</div>
|
|
119
|
+
))}
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
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
|
+
describe("several items and shipping (phase 2)", () => {
|
|
197
|
+
const multi = (shipping: CheckoutLinkView["shipping"]) =>
|
|
198
|
+
view({
|
|
199
|
+
item: { type: "product", title: "Tour Tee (Large)" },
|
|
200
|
+
items: [
|
|
201
|
+
{
|
|
202
|
+
type: "product",
|
|
203
|
+
title: "Tour Tee (Large)",
|
|
204
|
+
quantity: 2,
|
|
205
|
+
unitPriceCents: 3000,
|
|
206
|
+
physical: true,
|
|
207
|
+
productId: "p-1",
|
|
208
|
+
productVariantId: "v-1",
|
|
209
|
+
},
|
|
210
|
+
{ type: "event_ticket", title: "Spring Show: General", quantity: 1, unitPriceCents: 2000, physical: false },
|
|
211
|
+
],
|
|
212
|
+
quantity: 3,
|
|
213
|
+
unitPriceCents: null,
|
|
214
|
+
totalCents: 8000,
|
|
215
|
+
shipping,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("lists every line with its amount, and holds Pay until the delivery address is in", () => {
|
|
219
|
+
const html = render(
|
|
220
|
+
page({
|
|
221
|
+
link: { isLoading: false, error: null, data: multi({ required: true, set: false, address: null, costCents: null }) },
|
|
222
|
+
}),
|
|
223
|
+
);
|
|
224
|
+
expect(html).toContain('data-testid="checkout-link-items"');
|
|
225
|
+
expect(html).toContain("2 x Tour Tee (Large)");
|
|
226
|
+
expect(html).toContain("$60.00");
|
|
227
|
+
expect(html).toContain("Spring Show: General");
|
|
228
|
+
expect(html).not.toContain("Quantity:");
|
|
229
|
+
expect(html).toContain('data-testid="checkout-link-shipping" data-state="address"');
|
|
230
|
+
expect(html).toContain("Delivery address");
|
|
231
|
+
expect(html).toMatch(/<button disabled=""[^>]*data-testid="checkout-link-pay"/);
|
|
232
|
+
expect(html).not.toContain('data-testid="checkout-link-shipping-error"');
|
|
233
|
+
expect(html).toContain("Pay $80.00");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("once the address is in: the address, the shipping line, the total with shipping, a way to change it", () => {
|
|
237
|
+
const html = render(
|
|
238
|
+
page({
|
|
239
|
+
link: {
|
|
240
|
+
isLoading: false,
|
|
241
|
+
error: null,
|
|
242
|
+
data: multi({
|
|
243
|
+
required: true,
|
|
244
|
+
set: true,
|
|
245
|
+
address: { address1: "1 Main St", city: "Austin", stateCode: "TX", countryCode: "US", zip: "78701" },
|
|
246
|
+
costCents: 1000,
|
|
247
|
+
}),
|
|
248
|
+
},
|
|
249
|
+
}),
|
|
250
|
+
);
|
|
251
|
+
expect(html).toContain('data-state="set"');
|
|
252
|
+
expect(html).toContain("1 Main St, Austin, TX, 78701, US");
|
|
253
|
+
expect(html).toContain('data-testid="checkout-link-shipping-cost"');
|
|
254
|
+
expect(html).toContain("$10.00");
|
|
255
|
+
expect(html).toContain("Pay $90.00");
|
|
256
|
+
expect(html).toContain('data-testid="checkout-link-change-address"');
|
|
257
|
+
expect(html).not.toMatch(/<button disabled=""[^>]*data-testid="checkout-link-pay"/);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("the address cannot be changed once a payment has started", () => {
|
|
261
|
+
const html = render(
|
|
262
|
+
page({
|
|
263
|
+
paymentStarted: true,
|
|
264
|
+
clientSecret: "pi_secret",
|
|
265
|
+
link: {
|
|
266
|
+
isLoading: false,
|
|
267
|
+
error: null,
|
|
268
|
+
data: multi({
|
|
269
|
+
required: true,
|
|
270
|
+
set: true,
|
|
271
|
+
address: { address1: "1 Main St", city: "Austin", stateCode: "TX", countryCode: "US", zip: "78701" },
|
|
272
|
+
costCents: 1000,
|
|
273
|
+
}),
|
|
274
|
+
},
|
|
275
|
+
}),
|
|
276
|
+
);
|
|
277
|
+
expect(html).not.toContain('data-testid="checkout-link-change-address"');
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("an unknown link says it is not valid", () => {
|
|
282
|
+
const html = render(page({ link: { isLoading: false, error: new Error("404"), data: undefined } }));
|
|
283
|
+
expect(html).toContain('data-state="not-found"');
|
|
284
|
+
expect(html).toContain("Link not found");
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("the return leg: paid shows the confirmation, unpaid offers another try, too late says a refund is coming", () => {
|
|
288
|
+
const paid = render(
|
|
289
|
+
page({ finalize: { isLoading: false, error: null, data: { outcome: "fulfilled", link: view({ status: "paid" }) } } }),
|
|
290
|
+
{ returning: true },
|
|
291
|
+
);
|
|
292
|
+
expect(paid).toContain('data-state="paid"');
|
|
293
|
+
expect(paid).toContain("Payment received");
|
|
294
|
+
expect(paid).toContain("Spring Show: General");
|
|
295
|
+
|
|
296
|
+
const unpaid = render(
|
|
297
|
+
page({ finalize: { isLoading: false, error: null, data: { outcome: "unpaid", link: view() } } }),
|
|
298
|
+
{ returning: true },
|
|
299
|
+
);
|
|
300
|
+
expect(unpaid).toContain('data-state="unpaid"');
|
|
301
|
+
expect(unpaid).toContain("Try again");
|
|
302
|
+
|
|
303
|
+
const late = render(
|
|
304
|
+
page({
|
|
305
|
+
finalize: {
|
|
306
|
+
isLoading: false,
|
|
307
|
+
error: null,
|
|
308
|
+
data: { outcome: "needs_refund", link: view({ status: "expired", needsRefund: true }) },
|
|
309
|
+
},
|
|
310
|
+
}),
|
|
311
|
+
{ returning: true },
|
|
312
|
+
);
|
|
313
|
+
expect(late).toContain('data-state="needs-refund"');
|
|
314
|
+
expect(late).toContain("Your payment will be refunded");
|
|
315
|
+
});
|
|
316
|
+
});
|