@tribe-nest/forge 3.54.0 → 3.58.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 +4 -2
- package/src/_tests/workspaceAliases.ts +1 -0
- package/src/contexts/CartContext.tsx +88 -5
- package/src/data/queries/useCallBreakouts.ts +112 -0
- package/src/data/queries/useCheckouts.ts +15 -2
- package/src/data/queries/useCoachingProducts.ts +15 -1
- package/src/data/queries/useCourses.ts +15 -1
- package/src/data/queries/useEvents.ts +80 -1
- package/src/data/queries/useMembership.ts +176 -4
- package/src/data/queries/useMyBookings.ts +16 -0
- package/src/data/queries/useNotifications.ts +1 -1
- package/src/data/queries/useProducts.ts +15 -1
- package/src/data/queries/useSubscriptions.ts +21 -1
- package/src/i18n/de.json +153 -0
- package/src/i18n/en.json +153 -0
- package/src/index.ts +1 -0
- package/src/server/_tests/buildPwaHead.spec.ts +1 -1
- package/src/server/_tests/buildWebManifest.spec.ts +1 -1
- package/src/server/_tests/forgeServiceWorkerScript.spec.ts +1 -1
- package/src/server/index.ts +75 -1
- package/src/server/platformEvents.generated.ts +60 -0
- package/src/server/pwa.ts +1 -1
- package/src/types/models.ts +137 -0
- package/src/ui/analytics/ForgeAnalytics.tsx +55 -1
- package/src/ui/analytics/_tests/ForgeAnalytics.engagement.spec.tsx +78 -0
- package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +3 -1
- package/src/ui/headless/checkout/_tests/cartResume.spec.ts +3 -10
- package/src/ui/headless/checkout/_tests/seatConflict.spec.ts +187 -0
- package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +3 -11
- package/src/ui/headless/checkout/_tests/useCheckoutSeats.spec.tsx +313 -0
- package/src/ui/headless/checkout/seatConflict.ts +117 -0
- package/src/ui/headless/checkout/useCheckout.ts +178 -55
- package/src/ui/headless/event/_tests/seatingFixture.ts +111 -0
- package/src/ui/headless/event/_tests/useEventCheckoutSeats.spec.tsx +443 -0
- package/src/ui/headless/event/_tests/useEventSeating.spec.tsx +154 -0
- package/src/ui/headless/event/useEventCheckout.ts +362 -22
- package/src/ui/headless/event/useEventSeating.ts +233 -0
- package/src/ui/headless/forms/_tests/usePhoneInput.spec.ts +187 -0
- package/src/ui/headless/forms/useEmailListForm.ts +37 -6
- package/src/ui/headless/forms/usePhoneInput.ts +294 -0
- package/src/ui/headless/forms/useSectionedForm.ts +2 -2
- package/src/ui/headless/index.ts +48 -1
- package/src/ui/headless/membership/_tests/membershipApplication.spec.ts +85 -0
- package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +2 -0
- package/src/ui/headless/membership/_tests/membershipTrial.spec.ts +61 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +253 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +241 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +165 -0
- package/src/ui/headless/membership/membershipApplication.ts +109 -0
- package/src/ui/headless/membership/membershipQuestionnaire.ts +44 -0
- package/src/ui/headless/membership/membershipTrial.ts +53 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +227 -8
- package/src/ui/index.ts +16 -0
- package/src/ui/media/CallStage.tsx +107 -10
- package/src/ui/media/_tests/CallBreakoutsPanel.spec.tsx +271 -0
- package/src/ui/media/_tests/CallStageBreakouts.spec.tsx +212 -0
- package/src/ui/media/_tests/callState.spec.ts +103 -1
- package/src/ui/media/callBreakouts.ts +57 -0
- package/src/ui/media/callState.ts +83 -23
- package/src/ui/media/index.ts +11 -0
- package/src/ui/media/panels/CallBreakoutsPanel.tsx +395 -0
- package/src/ui/media/panels/CallSidePanel.tsx +37 -4
- package/src/ui/media/stage/CallCenterNotice.tsx +5 -3
- package/src/ui/media/stage/CallControlsBar.tsx +46 -0
- package/src/ui/media/stage/CallTopBar.tsx +1 -1
- package/src/ui/payment/ForgePaymentProvider.tsx +14 -0
- package/src/ui/payment/ForgeStripePayment.tsx +78 -7
- package/src/ui/payment/_tests/ForgeStripePaymentTrial.spec.tsx +101 -0
- package/src/ui/payment/_tests/stripeIntentKind.spec.ts +43 -0
- package/src/ui/payment/stripeIntentKind.ts +32 -0
- package/src/ui/shell/TribeNestApp.tsx +1 -1
- package/src/ui/styled/AccountDashboard.tsx +165 -2
- package/src/ui/styled/BundleConfirmation.tsx +47 -2
- package/src/ui/styled/Cart.tsx +211 -135
- package/src/ui/styled/Checkout.tsx +163 -32
- package/src/ui/styled/EmailListForm.tsx +7 -9
- package/src/ui/styled/EventTickets.tsx +143 -4
- package/src/ui/styled/LoginForm.tsx +5 -3
- package/src/ui/styled/MembershipCheckout.tsx +596 -256
- package/src/ui/styled/MembershipTierCallout.tsx +17 -3
- package/src/ui/styled/MembershipTiers.tsx +148 -6
- package/src/ui/styled/PhoneInput.tsx +127 -0
- package/src/ui/styled/ProductGrid.tsx +14 -2
- package/src/ui/styled/SignupForm.tsx +4 -2
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +36 -0
- package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +105 -0
- package/src/ui/styled/_tests/CartSeats.spec.tsx +109 -0
- package/src/ui/styled/_tests/CheckoutSeatsConflict.spec.tsx +194 -0
- package/src/ui/styled/_tests/EventSeatPicker.spec.tsx +199 -0
- package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +106 -0
- package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +139 -0
- package/src/ui/styled/_tests/membershipTiersTrial.spec.tsx +97 -0
- package/src/ui/styled/forge-utilities.css +67 -0
- package/src/ui/styled/seating/EventSeatPicker.tsx +190 -0
- package/src/ui/styled/seating/SeatLegend.tsx +73 -0
- package/src/ui/styled/seating/SeatMapCanvas.tsx +647 -0
- package/src/ui/styled/seating/paint.ts +79 -0
- package/src/ui/styled/seating/useSeatMapViewport.ts +248 -0
- package/src/ui/styled/seating/viewport.ts +86 -0
- package/src/utils/_tests/engagementTracker.spec.ts +111 -0
- package/src/utils/engagementTracker.ts +73 -0
- package/src/utils/landing.ts +1 -1
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
3
|
+
import { render, screen, waitFor, fireEvent, cleanup } from "@testing-library/react";
|
|
4
|
+
import axios, { type AxiosAdapter, type InternalAxiosRequestConfig } from "axios";
|
|
5
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
6
|
+
import { ForgeClientProvider } from "../../../provider/ForgeProvider";
|
|
7
|
+
import { PublicAuthContext } from "../../../contexts/PublicAuthContext";
|
|
8
|
+
import { ForgeThemeProvider } from "../../theme/ForgeThemeProvider";
|
|
9
|
+
import type { MembershipApplicationStatus, MembershipTier } from "../../../types/models";
|
|
10
|
+
import { MembershipTiers } from "../MembershipTiers";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The tier card under curated access, over the REAL query layer: what the card
|
|
14
|
+
* offers for each join mode and each state of the fan's own application, that
|
|
15
|
+
* the invite token on the url reaches the tier list request, and that Withdraw
|
|
16
|
+
* posts to the right application.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const PROFILE_ID = "profile-1";
|
|
20
|
+
let sent: { method: string; url: string; params: Record<string, unknown>; body: Record<string, unknown> | null }[] = [];
|
|
21
|
+
let realAdapter: AxiosAdapter | undefined;
|
|
22
|
+
|
|
23
|
+
const tier = (id: string, overrides: Partial<MembershipTier> = {}): MembershipTier => ({
|
|
24
|
+
id,
|
|
25
|
+
name: `Tier ${id}`,
|
|
26
|
+
description: "",
|
|
27
|
+
priceMonthly: 10,
|
|
28
|
+
payWhatYouWant: false,
|
|
29
|
+
benefits: [],
|
|
30
|
+
...overrides,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const withApplication = (id: string, status: MembershipApplicationStatus) =>
|
|
34
|
+
tier(id, { joinMode: "application", myApplication: { id: `app-${id}`, status, paymentWindowExpiresAt: null } });
|
|
35
|
+
|
|
36
|
+
function mount(tiers: MembershipTier[]) {
|
|
37
|
+
sent = [];
|
|
38
|
+
const adapter: AxiosAdapter = async (config: InternalAxiosRequestConfig) => {
|
|
39
|
+
const url = config.url ?? "";
|
|
40
|
+
const body = config.data ? (JSON.parse(config.data as string) as Record<string, unknown>) : null;
|
|
41
|
+
sent.push({ method: (config.method ?? "get").toLowerCase(), url, params: config.params ?? {}, body });
|
|
42
|
+
const reply = (data: unknown) =>
|
|
43
|
+
Promise.resolve({ data, status: 200, statusText: "OK", headers: {}, config } as never);
|
|
44
|
+
if (url.includes("/public/membership-tiers")) return reply(tiers);
|
|
45
|
+
if (url.includes("/withdraw")) return reply({ application: { id: "x", status: "withdrawn" } });
|
|
46
|
+
return reply({});
|
|
47
|
+
};
|
|
48
|
+
realAdapter = axios.defaults.adapter as AxiosAdapter | undefined;
|
|
49
|
+
axios.defaults.adapter = adapter;
|
|
50
|
+
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
51
|
+
return render(
|
|
52
|
+
<QueryClientProvider client={queryClient}>
|
|
53
|
+
<ForgeClientProvider apiUrl="http://api.test" profileId={PROFILE_ID}>
|
|
54
|
+
<PublicAuthContext.Provider value={{ user: null, isInitialized: true, isAuthenticated: false } as never}>
|
|
55
|
+
<ForgeThemeProvider theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}>
|
|
56
|
+
<MembershipTiers />
|
|
57
|
+
</ForgeThemeProvider>
|
|
58
|
+
</PublicAuthContext.Provider>
|
|
59
|
+
</ForgeClientProvider>
|
|
60
|
+
</QueryClientProvider>,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
cleanup();
|
|
66
|
+
if (realAdapter) axios.defaults.adapter = realAdapter;
|
|
67
|
+
window.history.replaceState(null, "", "/");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const cardOf = async (name: string) => {
|
|
71
|
+
const heading = await screen.findByRole("heading", { name });
|
|
72
|
+
return heading.parentElement as HTMLElement;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
describe("MembershipTiers: the card per join mode and application state", () => {
|
|
76
|
+
it("reads Select, Accept invite and Apply to join by join mode", async () => {
|
|
77
|
+
mount([tier("open"), tier("inv", { joinMode: "invite" }), tier("app", { joinMode: "application" })]);
|
|
78
|
+
|
|
79
|
+
expect((await cardOf("Tier open")).textContent).toContain("Select");
|
|
80
|
+
expect((await cardOf("Tier inv")).textContent).toContain("Accept invite");
|
|
81
|
+
expect((await cardOf("Tier app")).textContent).toContain("Apply to join");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("shows the application's state instead of the join button", async () => {
|
|
85
|
+
mount([
|
|
86
|
+
withApplication("p", "pending"),
|
|
87
|
+
withApplication("a", "approved"),
|
|
88
|
+
withApplication("f", "payment_failed"),
|
|
89
|
+
withApplication("d", "declined"),
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
const pending = await cardOf("Tier p");
|
|
93
|
+
expect(pending.textContent).toContain("Application pending");
|
|
94
|
+
expect(pending.textContent).toContain("Withdraw application");
|
|
95
|
+
expect(pending.textContent).not.toContain("Apply to join");
|
|
96
|
+
|
|
97
|
+
const approved = await cardOf("Tier a");
|
|
98
|
+
expect(approved.textContent).toContain("Approved, finishing up");
|
|
99
|
+
expect(approved.querySelector("button")).toBeNull();
|
|
100
|
+
|
|
101
|
+
const failed = await cardOf("Tier f");
|
|
102
|
+
expect(failed.textContent).toContain("Approved: complete your membership");
|
|
103
|
+
expect(failed.textContent).toContain("Complete your membership");
|
|
104
|
+
|
|
105
|
+
const declined = await cardOf("Tier d");
|
|
106
|
+
expect(declined.textContent).toContain("Not approved");
|
|
107
|
+
expect(declined.querySelector("button")).toBeNull();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("withdraws the pending application it shows", async () => {
|
|
111
|
+
mount([withApplication("p", "pending")]);
|
|
112
|
+
const card = await cardOf("Tier p");
|
|
113
|
+
fireEvent.click(card.querySelector("button") as HTMLButtonElement);
|
|
114
|
+
|
|
115
|
+
await waitFor(() => expect(sent.some((r) => r.method === "post" && r.url.includes("/withdraw"))).toBe(true));
|
|
116
|
+
const post = sent.find((r) => r.url.includes("/withdraw"))!;
|
|
117
|
+
expect(post.url).toContain("/public/membership-applications/app-p/withdraw");
|
|
118
|
+
expect(post.body).toEqual({ profileId: PROFILE_ID });
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("the invite token reaches the tier list", () => {
|
|
123
|
+
it("forwards `?invite=` as inviteToken", async () => {
|
|
124
|
+
window.history.replaceState(null, "", "/i/membership?invite=tok-123");
|
|
125
|
+
mount([tier("inv", { joinMode: "invite" })]);
|
|
126
|
+
await cardOf("Tier inv");
|
|
127
|
+
|
|
128
|
+
const get = sent.find((r) => r.url.includes("/public/membership-tiers"))!;
|
|
129
|
+
expect(get.params).toEqual({ profileId: PROFILE_ID, inviteToken: "tok-123" });
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("sends no inviteToken without one", async () => {
|
|
133
|
+
mount([tier("open")]);
|
|
134
|
+
await cardOf("Tier open");
|
|
135
|
+
|
|
136
|
+
const get = sent.find((r) => r.url.includes("/public/membership-tiers"))!;
|
|
137
|
+
expect(get.params).toEqual({ profileId: PROFILE_ID });
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
3
|
+
import { render, screen, cleanup } from "@testing-library/react";
|
|
4
|
+
import axios, { type AxiosAdapter, type InternalAxiosRequestConfig } from "axios";
|
|
5
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
6
|
+
import { ForgeClientProvider } from "../../../provider/ForgeProvider";
|
|
7
|
+
import { PublicAuthContext } from "../../../contexts/PublicAuthContext";
|
|
8
|
+
import { ForgeThemeProvider } from "../../theme/ForgeThemeProvider";
|
|
9
|
+
import type { MembershipTier } from "../../../types/models";
|
|
10
|
+
import { MembershipTiers } from "../MembershipTiers";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The tier card with a free trial: the badge and the "Start free trial" button
|
|
14
|
+
* appear only when this fan would get the trial, an application tier keeps
|
|
15
|
+
* "Apply to join", and a member with a live subscription (changing tier) is
|
|
16
|
+
* offered no trial.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
let realAdapter: AxiosAdapter | undefined;
|
|
20
|
+
|
|
21
|
+
const tier = (id: string, overrides: Partial<MembershipTier> = {}): MembershipTier => ({
|
|
22
|
+
id,
|
|
23
|
+
name: `Tier ${id}`,
|
|
24
|
+
description: "",
|
|
25
|
+
priceMonthly: 10,
|
|
26
|
+
payWhatYouWant: false,
|
|
27
|
+
benefits: [],
|
|
28
|
+
...overrides,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function mount(tiers: MembershipTier[], user: unknown = null) {
|
|
32
|
+
const adapter: AxiosAdapter = async (config: InternalAxiosRequestConfig) =>
|
|
33
|
+
({ data: (config.url ?? "").includes("/public/membership-tiers") ? tiers : {}, status: 200, statusText: "OK", headers: {}, config }) as never;
|
|
34
|
+
realAdapter = axios.defaults.adapter as AxiosAdapter | undefined;
|
|
35
|
+
axios.defaults.adapter = adapter;
|
|
36
|
+
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
37
|
+
return render(
|
|
38
|
+
<QueryClientProvider client={queryClient}>
|
|
39
|
+
<ForgeClientProvider apiUrl="http://api.test" profileId="profile-1">
|
|
40
|
+
<PublicAuthContext.Provider value={{ user, isInitialized: true, isAuthenticated: !!user } as never}>
|
|
41
|
+
<ForgeThemeProvider theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}>
|
|
42
|
+
<MembershipTiers />
|
|
43
|
+
</ForgeThemeProvider>
|
|
44
|
+
</PublicAuthContext.Provider>
|
|
45
|
+
</ForgeClientProvider>
|
|
46
|
+
</QueryClientProvider>,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
cleanup();
|
|
52
|
+
if (realAdapter) axios.defaults.adapter = realAdapter;
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const cardOf = async (name: string) => (await screen.findByRole("heading", { name })).parentElement as HTMLElement;
|
|
56
|
+
const badge = (card: HTMLElement) => card.querySelector('[data-testid="membership-tier-trial-badge"]');
|
|
57
|
+
|
|
58
|
+
describe("MembershipTiers: free trial badge and button", () => {
|
|
59
|
+
it("shows the badge and Start free trial when eligible, including for a signed-out visitor", async () => {
|
|
60
|
+
mount([tier("eligible", { trialDays: 14, trialEligible: true }), tier("anon", { trialDays: 7 })]);
|
|
61
|
+
|
|
62
|
+
const eligible = await cardOf("Tier eligible");
|
|
63
|
+
expect(badge(eligible)?.textContent).toBe("14-day free trial");
|
|
64
|
+
expect(eligible.querySelector("button")?.textContent).toBe("Start free trial");
|
|
65
|
+
|
|
66
|
+
const anon = await cardOf("Tier anon");
|
|
67
|
+
expect(badge(anon)?.textContent).toBe("7-day free trial");
|
|
68
|
+
expect(anon.querySelector("button")?.textContent).toBe("Start free trial");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("shows no badge and the plain button to a fan who already had a trial, or on a tier without one", async () => {
|
|
72
|
+
mount([tier("used", { trialDays: 14, trialEligible: false }), tier("none", { trialDays: null })]);
|
|
73
|
+
|
|
74
|
+
for (const name of ["Tier used", "Tier none"]) {
|
|
75
|
+
const card = await cardOf(name);
|
|
76
|
+
expect(badge(card), name).toBeNull();
|
|
77
|
+
expect(card.querySelector("button")?.textContent, name).toBe("Select");
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("keeps Apply to join on an application tier, with the trial badge", async () => {
|
|
82
|
+
mount([tier("app", { joinMode: "application", trialDays: 30 })]);
|
|
83
|
+
const card = await cardOf("Tier app");
|
|
84
|
+
expect(badge(card)?.textContent).toBe("30-day free trial");
|
|
85
|
+
expect(card.querySelector("button")?.textContent).toBe("Apply to join");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("offers no trial to a member with a live paid subscription (a tier change)", async () => {
|
|
89
|
+
mount([tier("t", { trialDays: 14, trialEligible: true })], {
|
|
90
|
+
id: "account-1",
|
|
91
|
+
membership: { paymentProviderSubscriptionId: "sub_1", status: "active", membershipTierId: "other" },
|
|
92
|
+
});
|
|
93
|
+
const card = await cardOf("Tier t");
|
|
94
|
+
expect(badge(card)).toBeNull();
|
|
95
|
+
expect(card.querySelector("button")?.textContent).toBe("Select");
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -65,6 +65,9 @@
|
|
|
65
65
|
.fg\:inset-0 {
|
|
66
66
|
inset: calc(var(--fg-spacing) * 0);
|
|
67
67
|
}
|
|
68
|
+
.fg\:top-2 {
|
|
69
|
+
top: calc(var(--fg-spacing) * 2);
|
|
70
|
+
}
|
|
68
71
|
.fg\:top-4 {
|
|
69
72
|
top: calc(var(--fg-spacing) * 4);
|
|
70
73
|
}
|
|
@@ -74,18 +77,30 @@
|
|
|
74
77
|
.fg\:right-0 {
|
|
75
78
|
right: calc(var(--fg-spacing) * 0);
|
|
76
79
|
}
|
|
80
|
+
.fg\:right-2 {
|
|
81
|
+
right: calc(var(--fg-spacing) * 2);
|
|
82
|
+
}
|
|
83
|
+
.fg\:right-14 {
|
|
84
|
+
right: calc(var(--fg-spacing) * 14);
|
|
85
|
+
}
|
|
77
86
|
.fg\:bottom-0 {
|
|
78
87
|
bottom: calc(var(--fg-spacing) * 0);
|
|
79
88
|
}
|
|
80
89
|
.fg\:left-0 {
|
|
81
90
|
left: calc(var(--fg-spacing) * 0);
|
|
82
91
|
}
|
|
92
|
+
.fg\:left-2 {
|
|
93
|
+
left: calc(var(--fg-spacing) * 2);
|
|
94
|
+
}
|
|
83
95
|
.fg\:z-40 {
|
|
84
96
|
z-index: 40;
|
|
85
97
|
}
|
|
86
98
|
.fg\:z-50 {
|
|
87
99
|
z-index: 50;
|
|
88
100
|
}
|
|
101
|
+
.fg\:m-0 {
|
|
102
|
+
margin: calc(var(--fg-spacing) * 0);
|
|
103
|
+
}
|
|
89
104
|
.fg\:mx-auto {
|
|
90
105
|
margin-inline: auto;
|
|
91
106
|
}
|
|
@@ -158,33 +173,54 @@
|
|
|
158
173
|
.fg\:hidden {
|
|
159
174
|
display: none;
|
|
160
175
|
}
|
|
176
|
+
.fg\:inline-block {
|
|
177
|
+
display: inline-block;
|
|
178
|
+
}
|
|
161
179
|
.fg\:inline-flex {
|
|
162
180
|
display: inline-flex;
|
|
163
181
|
}
|
|
164
182
|
.fg\:h-0\.5 {
|
|
165
183
|
height: calc(var(--fg-spacing) * 0.5);
|
|
166
184
|
}
|
|
185
|
+
.fg\:h-3 {
|
|
186
|
+
height: calc(var(--fg-spacing) * 3);
|
|
187
|
+
}
|
|
167
188
|
.fg\:h-4 {
|
|
168
189
|
height: calc(var(--fg-spacing) * 4);
|
|
169
190
|
}
|
|
170
191
|
.fg\:h-8 {
|
|
171
192
|
height: calc(var(--fg-spacing) * 8);
|
|
172
193
|
}
|
|
194
|
+
.fg\:h-9 {
|
|
195
|
+
height: calc(var(--fg-spacing) * 9);
|
|
196
|
+
}
|
|
173
197
|
.fg\:h-12 {
|
|
174
198
|
height: calc(var(--fg-spacing) * 12);
|
|
175
199
|
}
|
|
176
200
|
.fg\:h-\[70vh\] {
|
|
177
201
|
height: 70vh;
|
|
178
202
|
}
|
|
203
|
+
.fg\:h-\[320px\] {
|
|
204
|
+
height: 320px;
|
|
205
|
+
}
|
|
179
206
|
.fg\:h-full {
|
|
180
207
|
height: 100%;
|
|
181
208
|
}
|
|
209
|
+
.fg\:min-h-\[320px\] {
|
|
210
|
+
min-height: 320px;
|
|
211
|
+
}
|
|
212
|
+
.fg\:w-3 {
|
|
213
|
+
width: calc(var(--fg-spacing) * 3);
|
|
214
|
+
}
|
|
182
215
|
.fg\:w-4 {
|
|
183
216
|
width: calc(var(--fg-spacing) * 4);
|
|
184
217
|
}
|
|
185
218
|
.fg\:w-8 {
|
|
186
219
|
width: calc(var(--fg-spacing) * 8);
|
|
187
220
|
}
|
|
221
|
+
.fg\:w-9 {
|
|
222
|
+
width: calc(var(--fg-spacing) * 9);
|
|
223
|
+
}
|
|
188
224
|
.fg\:w-10 {
|
|
189
225
|
width: calc(var(--fg-spacing) * 10);
|
|
190
226
|
}
|
|
@@ -224,9 +260,15 @@
|
|
|
224
260
|
.fg\:cursor-pointer {
|
|
225
261
|
cursor: pointer;
|
|
226
262
|
}
|
|
263
|
+
.fg\:touch-none {
|
|
264
|
+
touch-action: none;
|
|
265
|
+
}
|
|
227
266
|
.fg\:resize-y {
|
|
228
267
|
resize: vertical;
|
|
229
268
|
}
|
|
269
|
+
.fg\:list-none {
|
|
270
|
+
list-style-type: none;
|
|
271
|
+
}
|
|
230
272
|
.fg\:grid-cols-1 {
|
|
231
273
|
grid-template-columns: repeat(1, minmax(0, 1fr));
|
|
232
274
|
}
|
|
@@ -236,6 +278,9 @@
|
|
|
236
278
|
.fg\:flex-col {
|
|
237
279
|
flex-direction: column;
|
|
238
280
|
}
|
|
281
|
+
.fg\:flex-row {
|
|
282
|
+
flex-direction: row;
|
|
283
|
+
}
|
|
239
284
|
.fg\:flex-wrap {
|
|
240
285
|
flex-wrap: wrap;
|
|
241
286
|
}
|
|
@@ -308,6 +353,9 @@
|
|
|
308
353
|
margin-block-end: calc(calc(var(--fg-spacing) * 6) * calc(1 - var(--tw-space-y-reverse)));
|
|
309
354
|
}
|
|
310
355
|
}
|
|
356
|
+
.fg\:gap-x-4 {
|
|
357
|
+
column-gap: calc(var(--fg-spacing) * 4);
|
|
358
|
+
}
|
|
311
359
|
.fg\:space-x-4 {
|
|
312
360
|
:where(& > :not(:last-child)) {
|
|
313
361
|
--tw-space-x-reverse: 0;
|
|
@@ -315,6 +363,9 @@
|
|
|
315
363
|
margin-inline-end: calc(calc(var(--fg-spacing) * 4) * calc(1 - var(--tw-space-x-reverse)));
|
|
316
364
|
}
|
|
317
365
|
}
|
|
366
|
+
.fg\:gap-y-2 {
|
|
367
|
+
row-gap: calc(var(--fg-spacing) * 2);
|
|
368
|
+
}
|
|
318
369
|
.fg\:overflow-hidden {
|
|
319
370
|
overflow: hidden;
|
|
320
371
|
}
|
|
@@ -340,6 +391,10 @@
|
|
|
340
391
|
border-style: var(--tw-border-style);
|
|
341
392
|
border-width: 1px;
|
|
342
393
|
}
|
|
394
|
+
.fg\:border-0 {
|
|
395
|
+
border-style: var(--tw-border-style);
|
|
396
|
+
border-width: 0px;
|
|
397
|
+
}
|
|
343
398
|
.fg\:border-2 {
|
|
344
399
|
border-style: var(--tw-border-style);
|
|
345
400
|
border-width: 2px;
|
|
@@ -387,6 +442,9 @@
|
|
|
387
442
|
.fg\:bg-transparent {
|
|
388
443
|
background-color: transparent;
|
|
389
444
|
}
|
|
445
|
+
.fg\:p-0 {
|
|
446
|
+
padding: calc(var(--fg-spacing) * 0);
|
|
447
|
+
}
|
|
390
448
|
.fg\:p-0\.5 {
|
|
391
449
|
padding: calc(var(--fg-spacing) * 0.5);
|
|
392
450
|
}
|
|
@@ -583,6 +641,10 @@
|
|
|
583
641
|
--tw-outline-style: none;
|
|
584
642
|
outline-style: none;
|
|
585
643
|
}
|
|
644
|
+
.fg\:select-none {
|
|
645
|
+
-webkit-user-select: none;
|
|
646
|
+
user-select: none;
|
|
647
|
+
}
|
|
586
648
|
.fg\:focus\:border-primary {
|
|
587
649
|
&:focus {
|
|
588
650
|
border-color: var(--fg-color-primary);
|
|
@@ -678,6 +740,11 @@
|
|
|
678
740
|
height: calc(var(--fg-spacing) * 64);
|
|
679
741
|
}
|
|
680
742
|
}
|
|
743
|
+
.fg\:md\:h-\[440px\] {
|
|
744
|
+
@media (width >= 48rem) {
|
|
745
|
+
height: 440px;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
681
748
|
.fg\:md\:w-64 {
|
|
682
749
|
@media (width >= 48rem) {
|
|
683
750
|
width: calc(var(--fg-spacing) * 64);
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState, type CSSProperties } from "react";
|
|
2
|
+
import { AlertCircle, X } from "lucide-react";
|
|
3
|
+
import type { IEvent } from "../../../types/models";
|
|
4
|
+
import type { EventSeat, EventSeatingState } from "../../headless/event/useEventSeating";
|
|
5
|
+
import type { ToggleSeatResult } from "../../headless/event/useEventCheckout";
|
|
6
|
+
import { useForgeT } from "../../../i18n";
|
|
7
|
+
import { useForgeTheme, type ForgeTheme } from "../../theme/ForgeThemeProvider";
|
|
8
|
+
import { CallHelpHint } from "../../media/CallHelpHint";
|
|
9
|
+
import { Loading } from "../Loading";
|
|
10
|
+
import { SeatMapCanvas } from "./SeatMapCanvas";
|
|
11
|
+
import { SeatLegend, type SeatLegendTier } from "./SeatLegend";
|
|
12
|
+
import { tierColorMap } from "./paint";
|
|
13
|
+
|
|
14
|
+
export interface EventSeatPickerProps {
|
|
15
|
+
/** The event whose tiers price the seats. Tier order fixes the palette. */
|
|
16
|
+
event: IEvent;
|
|
17
|
+
/** The joined seating read, from `useEventCheckout().seating` or `useEventSeating`. */
|
|
18
|
+
seating: EventSeatingState;
|
|
19
|
+
/** ticketId -> the EVENT seat ids the buyer holds. */
|
|
20
|
+
selectedSeats: Record<string, string[]>;
|
|
21
|
+
/**
|
|
22
|
+
* A tap on a seat. `useEventCheckout().toggleSeat` fits directly; a host
|
|
23
|
+
* driving its own state may return nothing, in which case no notice is shown.
|
|
24
|
+
*/
|
|
25
|
+
onToggleSeat: (ticketId: string, seatId: string) => ToggleSeatResult | void;
|
|
26
|
+
/** Seats that left the selection because somebody else got them. Rendered as an alert. */
|
|
27
|
+
seatsTaken?: { labels: string[] } | null;
|
|
28
|
+
onDismissSeatsTaken?: () => void;
|
|
29
|
+
/** Format a numeric amount for display. */
|
|
30
|
+
formatAmount: (amount: number) => string;
|
|
31
|
+
/** Host classes on the outer element, passed through untouched. */
|
|
32
|
+
className?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Append an alpha to a 6-digit hex color. */
|
|
36
|
+
const a = (hex: string, alpha: number) =>
|
|
37
|
+
hex +
|
|
38
|
+
Math.round(alpha * 255)
|
|
39
|
+
.toString(16)
|
|
40
|
+
.padStart(2, "0");
|
|
41
|
+
|
|
42
|
+
const NOTICE_MS = 2500;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The buyer's seat picker: the map, the legend, and the two notices a buyer
|
|
46
|
+
* must see without asking (seats taken from under them, a tier at its cap).
|
|
47
|
+
*
|
|
48
|
+
* Rendered inside `EventTickets`' ticket step when `event.hasSeatMap`, and
|
|
49
|
+
* exported on its own for a host that composes the checkout from the headless
|
|
50
|
+
* hook. Colours come from the site theme except the tier palette, which the
|
|
51
|
+
* map, the legend and the tier rows share through `tierColorMap`.
|
|
52
|
+
*/
|
|
53
|
+
export function EventSeatPicker({
|
|
54
|
+
event,
|
|
55
|
+
seating,
|
|
56
|
+
selectedSeats,
|
|
57
|
+
onToggleSeat,
|
|
58
|
+
seatsTaken,
|
|
59
|
+
onDismissSeatsTaken,
|
|
60
|
+
formatAmount,
|
|
61
|
+
className,
|
|
62
|
+
}: EventSeatPickerProps) {
|
|
63
|
+
const t = useForgeT();
|
|
64
|
+
const theme = useForgeTheme();
|
|
65
|
+
const [notice, setNotice] = useState<string | null>(null);
|
|
66
|
+
|
|
67
|
+
// A brief inline notice, cleared on its own: it answers "why did nothing
|
|
68
|
+
// happen?" for one tap and must not sit on the map for ever.
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (!notice) return;
|
|
71
|
+
const id = setTimeout(() => setNotice(null), NOTICE_MS);
|
|
72
|
+
return () => clearTimeout(id);
|
|
73
|
+
}, [notice]);
|
|
74
|
+
|
|
75
|
+
const tierColors = useMemo(() => tierColorMap(event.tickets.map((ticket) => ticket.id)), [event.tickets]);
|
|
76
|
+
|
|
77
|
+
const selectedIds = useMemo(() => new Set(Object.values(selectedSeats).flat()), [selectedSeats]);
|
|
78
|
+
|
|
79
|
+
const legendTiers = useMemo<SeatLegendTier[]>(
|
|
80
|
+
() =>
|
|
81
|
+
event.tickets
|
|
82
|
+
.filter((ticket) => seating.seatedTierIds.has(ticket.id))
|
|
83
|
+
.map((ticket) => ({
|
|
84
|
+
id: ticket.id,
|
|
85
|
+
title: ticket.title,
|
|
86
|
+
price: Number(ticket.price),
|
|
87
|
+
color: tierColors[ticket.id]!,
|
|
88
|
+
left: seating.availableByTier[ticket.id] ?? 0,
|
|
89
|
+
})),
|
|
90
|
+
[event.tickets, seating.seatedTierIds, seating.availableByTier, tierColors],
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const onSeatTap = useCallback(
|
|
94
|
+
(seat: EventSeat) => {
|
|
95
|
+
const result = onToggleSeat(seat.eventTicketId, seat.id);
|
|
96
|
+
if (!result || result.ok) return;
|
|
97
|
+
const tier = event.tickets.find((ticket) => ticket.id === seat.eventTicketId);
|
|
98
|
+
if (result.reason === "max") setNotice(t("forge.event_seat_picker.max_reached", { tier: tier?.title ?? "" }));
|
|
99
|
+
else if (result.reason === "unavailable") setNotice(t("forge.event_seat_picker.seat_unavailable"));
|
|
100
|
+
else if (result.reason === "mix") setNotice(t("forge.event_seat_picker.mix_blocked"));
|
|
101
|
+
},
|
|
102
|
+
[onToggleSeat, event.tickets, t],
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const layout = seating.seating?.layout;
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<div data-testid="event-seat-picker" className={className ?? "fg:flex fg:flex-col fg:gap-3"}>
|
|
109
|
+
<div className="fg:flex fg:items-center fg:gap-2">
|
|
110
|
+
<h3 style={{ fontSize: 16, fontWeight: 700, margin: 0 }}>{t("forge.event_seat_picker.title")}</h3>
|
|
111
|
+
<CallHelpHint label={t("forge.event_seat_picker.help_label")}>{t("forge.event_seat_picker.help_body")}</CallHelpHint>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
{/* State the buyer has to act on, so it is a visible alert and not a
|
|
115
|
+
`?`: their selection just shrank, and these are the seats that went. */}
|
|
116
|
+
{seatsTaken && seatsTaken.labels.length > 0 && (
|
|
117
|
+
<div role="alert" data-testid="seats-taken-alert" style={alertStyle("#dc2626", theme)}>
|
|
118
|
+
<AlertCircle size={16} style={{ flexShrink: 0, marginTop: 2 }} />
|
|
119
|
+
<div style={{ flex: 1 }}>
|
|
120
|
+
<div style={{ fontWeight: 700 }}>{t("forge.event_seat_picker.seats_taken_title")}</div>
|
|
121
|
+
<div style={{ fontWeight: 400, marginTop: 2 }}>
|
|
122
|
+
{t("forge.event_seat_picker.seats_taken_body", { seats: seatsTaken.labels.join(", ") })}
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
{onDismissSeatsTaken && (
|
|
126
|
+
<button
|
|
127
|
+
type="button"
|
|
128
|
+
aria-label={t("forge.event_seat_picker.dismiss")}
|
|
129
|
+
onClick={onDismissSeatsTaken}
|
|
130
|
+
className="fg:cursor-pointer fg:p-0 fg:bg-transparent fg:border-0"
|
|
131
|
+
style={{ color: "inherit" }}
|
|
132
|
+
>
|
|
133
|
+
<X size={16} />
|
|
134
|
+
</button>
|
|
135
|
+
)}
|
|
136
|
+
</div>
|
|
137
|
+
)}
|
|
138
|
+
|
|
139
|
+
{seating.isLoading && !layout && (
|
|
140
|
+
<div
|
|
141
|
+
data-testid="seat-picker-loading"
|
|
142
|
+
className="fg:flex fg:items-center fg:justify-center fg:min-h-[320px]"
|
|
143
|
+
style={{ border: `1px solid ${a(theme.colors.primary, 0.25)}`, borderRadius: theme.cornerRadius }}
|
|
144
|
+
>
|
|
145
|
+
<Loading label={t("forge.event_seat_picker.loading")} />
|
|
146
|
+
</div>
|
|
147
|
+
)}
|
|
148
|
+
|
|
149
|
+
{!seating.isLoading && !layout && (
|
|
150
|
+
<p data-testid="seat-picker-empty" style={{ opacity: 0.7, fontSize: 14, margin: 0 }}>
|
|
151
|
+
{t("forge.event_seat_picker.empty")}
|
|
152
|
+
</p>
|
|
153
|
+
)}
|
|
154
|
+
|
|
155
|
+
{layout && (
|
|
156
|
+
<>
|
|
157
|
+
<SeatMapCanvas
|
|
158
|
+
layout={layout}
|
|
159
|
+
bySource={seating.bySource}
|
|
160
|
+
tierColors={tierColors}
|
|
161
|
+
selectedIds={selectedIds}
|
|
162
|
+
onSeatTap={onSeatTap}
|
|
163
|
+
overlay={
|
|
164
|
+
notice ? (
|
|
165
|
+
<div role="status" data-testid="seat-picker-notice" style={{ ...alertStyle(theme.colors.primary, theme), marginTop: 0 }}>
|
|
166
|
+
<AlertCircle size={14} />
|
|
167
|
+
{notice}
|
|
168
|
+
</div>
|
|
169
|
+
) : null
|
|
170
|
+
}
|
|
171
|
+
/>
|
|
172
|
+
<SeatLegend tiers={legendTiers} formatAmount={formatAmount} />
|
|
173
|
+
</>
|
|
174
|
+
)}
|
|
175
|
+
</div>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const alertStyle = (color: string, t: ForgeTheme): CSSProperties => ({
|
|
180
|
+
padding: "8px 12px",
|
|
181
|
+
borderRadius: t.cornerRadius,
|
|
182
|
+
fontSize: 14,
|
|
183
|
+
fontWeight: 600,
|
|
184
|
+
background: a(color, 0.1),
|
|
185
|
+
color,
|
|
186
|
+
border: `1px solid ${a(color, 0.3)}`,
|
|
187
|
+
display: "flex",
|
|
188
|
+
alignItems: "flex-start",
|
|
189
|
+
gap: 8,
|
|
190
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { useForgeT } from "../../../i18n";
|
|
2
|
+
import { useForgeTheme } from "../../theme/ForgeThemeProvider";
|
|
3
|
+
import { UNAVAILABLE_FILL } from "./paint";
|
|
4
|
+
|
|
5
|
+
export interface SeatLegendTier {
|
|
6
|
+
id: string;
|
|
7
|
+
title: string;
|
|
8
|
+
/** Per-unit price, in the artist's currency. */
|
|
9
|
+
price: number;
|
|
10
|
+
/** The tier's colour on the map. */
|
|
11
|
+
color: string;
|
|
12
|
+
/** Seats still available on this tier. */
|
|
13
|
+
left: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SeatLegendProps {
|
|
17
|
+
tiers: SeatLegendTier[];
|
|
18
|
+
formatAmount: (amount: number) => string;
|
|
19
|
+
/** Host classes on the outer element, passed through untouched. */
|
|
20
|
+
className?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* What the colours on the map mean: one chip per seated tier (colour, name,
|
|
25
|
+
* price, how many are left), plus the two states every seat can be in
|
|
26
|
+
* regardless of tier. The colours come from the same `tierColorMap` the
|
|
27
|
+
* canvas and the tier rows use, so this is the key to the map by construction.
|
|
28
|
+
*/
|
|
29
|
+
export function SeatLegend({ tiers, formatAmount, className }: SeatLegendProps) {
|
|
30
|
+
const t = useForgeT();
|
|
31
|
+
const theme = useForgeTheme();
|
|
32
|
+
return (
|
|
33
|
+
<ul
|
|
34
|
+
data-testid="seat-legend"
|
|
35
|
+
aria-label={t("forge.seat_legend.aria_label")}
|
|
36
|
+
className={className ?? "fg:flex fg:flex-wrap fg:gap-x-4 fg:gap-y-2 fg:list-none fg:m-0 fg:p-0"}
|
|
37
|
+
style={{ fontSize: 13, color: theme.colors.text }}
|
|
38
|
+
>
|
|
39
|
+
{tiers.map((tier) => (
|
|
40
|
+
<li key={tier.id} data-testid="seat-legend-tier" className="fg:flex fg:items-center fg:gap-2">
|
|
41
|
+
<Swatch color={tier.color} />
|
|
42
|
+
<span style={{ fontWeight: 600 }}>{tier.title}</span>
|
|
43
|
+
<span style={{ opacity: 0.8 }}>{formatAmount(tier.price)}</span>
|
|
44
|
+
<span style={{ opacity: 0.65 }}>
|
|
45
|
+
{tier.left === 0
|
|
46
|
+
? t("forge.seat_legend.sold_out")
|
|
47
|
+
: tier.left === 1
|
|
48
|
+
? t("forge.seat_legend.left_one", { count: tier.left })
|
|
49
|
+
: t("forge.seat_legend.left_other", { count: tier.left })}
|
|
50
|
+
</span>
|
|
51
|
+
</li>
|
|
52
|
+
))}
|
|
53
|
+
<li className="fg:flex fg:items-center fg:gap-2">
|
|
54
|
+
<Swatch color={theme.colors.primary} ring={theme.colors.primary} />
|
|
55
|
+
<span>{t("forge.seat_legend.selected")}</span>
|
|
56
|
+
</li>
|
|
57
|
+
<li className="fg:flex fg:items-center fg:gap-2">
|
|
58
|
+
<Swatch color={UNAVAILABLE_FILL} />
|
|
59
|
+
<span>{t("forge.seat_legend.unavailable")}</span>
|
|
60
|
+
</li>
|
|
61
|
+
</ul>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function Swatch({ color, ring }: { color: string; ring?: string }) {
|
|
66
|
+
return (
|
|
67
|
+
<span
|
|
68
|
+
aria-hidden
|
|
69
|
+
className="fg:inline-block fg:w-3 fg:h-3 fg:rounded-full fg:shrink-0"
|
|
70
|
+
style={{ background: color, boxShadow: ring ? `0 0 0 2px #ffffff, 0 0 0 3.5px ${ring}` : undefined }}
|
|
71
|
+
/>
|
|
72
|
+
);
|
|
73
|
+
}
|