@tribe-nest/forge 3.53.0 → 3.57.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/contexts/AppAuthContext.tsx +3 -2
- package/src/contexts/AudioPlayerContext.tsx +3 -2
- package/src/contexts/CartContext.tsx +3 -2
- package/src/contexts/PublicAuthContext.tsx +3 -2
- package/src/data/queries/useCoachingProducts.ts +15 -1
- package/src/data/queries/useCourses.ts +15 -1
- package/src/data/queries/useEvents.ts +26 -1
- package/src/data/queries/useMembership.ts +176 -4
- package/src/data/queries/useMyTickets.ts +49 -0
- package/src/data/queries/usePaymentFlow.ts +8 -0
- package/src/data/queries/useProducts.ts +15 -1
- package/src/data/queries/useSubscriptions.ts +21 -1
- package/src/i18n/de.json +116 -1
- package/src/i18n/en.json +116 -1
- package/src/i18n/index.ts +3 -2
- package/src/index.ts +22 -0
- package/src/provider/ForgeAppProvider.tsx +8 -1
- package/src/provider/ForgeProvider.tsx +3 -2
- package/src/provider/SiteConfigProvider.tsx +3 -2
- package/src/runtime/RemotePage.tsx +110 -0
- package/src/runtime/hostRuntime.ts +84 -0
- package/src/runtime/pages.spec.ts +41 -0
- package/src/runtime/pages.ts +183 -0
- package/src/runtime/pagesClient.ts +127 -0
- package/src/runtime/registry.spec.ts +45 -0
- package/src/runtime/registry.ts +102 -0
- package/src/server/index.ts +74 -0
- package/src/server/jobs.ts +4 -1
- package/src/server/platformEvents.generated.ts +152 -0
- package/src/types/models.ts +185 -0
- package/src/ui/headless/event/_tests/ticketApproval.spec.ts +137 -0
- package/src/ui/headless/event/_tests/useEventCheckoutApproval.spec.tsx +250 -0
- package/src/ui/headless/event/ticketApproval.ts +109 -0
- package/src/ui/headless/event/useEventCheckout.ts +74 -4
- package/src/ui/headless/index.ts +34 -0
- 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 +27 -0
- package/src/ui/payment/ForgePaymentProvider.tsx +29 -0
- package/src/ui/payment/ForgeStripePayment.tsx +110 -5
- package/src/ui/payment/_tests/ForgeStripePaymentTrial.spec.tsx +101 -0
- package/src/ui/payment/_tests/stripeConfirmOutcome.spec.ts +61 -0
- package/src/ui/payment/_tests/stripeIntentKind.spec.ts +43 -0
- package/src/ui/payment/stripeConfirmOutcome.ts +50 -0
- package/src/ui/payment/stripeIntentKind.ts +32 -0
- package/src/ui/shell/TribeNestApp.tsx +6 -0
- package/src/ui/styled/AccountDashboard.tsx +239 -15
- package/src/ui/styled/DocumentSigningPage.tsx +516 -0
- package/src/ui/styled/EventConfirmation.tsx +106 -9
- package/src/ui/styled/EventTickets.tsx +107 -13
- 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/ProductGrid.tsx +14 -2
- package/src/ui/styled/ReviewRequestPage.tsx +214 -0
- package/src/ui/styled/SignupForm.tsx +4 -2
- package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +105 -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 +310 -0
- package/src/ui/theme/ForgeThemeProvider.tsx +3 -2
- package/src/utils/_tests/ticketOrderOutcome.spec.ts +82 -0
- package/src/utils/ticketOrderOutcome.ts +57 -1
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
eventHasApprovalTiers,
|
|
4
|
+
nextStepAfterTicketOrder,
|
|
5
|
+
selectionMode,
|
|
6
|
+
selectionRequiresApproval,
|
|
7
|
+
tierBlockedByApprovalMix,
|
|
8
|
+
tierRequiresApproval,
|
|
9
|
+
} from "../ticketApproval";
|
|
10
|
+
import type { IEvent, ITicket } from "../../../../types/models";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Ticket requests (curated access, feature 3): the pure decisions the picker
|
|
14
|
+
* and the checkout make. Two rules are load-bearing and both are here:
|
|
15
|
+
*
|
|
16
|
+
* 1. ONE ORDER IS A REQUEST OR A PURCHASE, NEVER BOTH. The API refuses a
|
|
17
|
+
* mixed cart; the picker must stop one being built.
|
|
18
|
+
* 2. A FREE REQUEST HAS NO PAYMENT STEP. It comes back `pending_approval`
|
|
19
|
+
* with `isFreeCheckout: false`, so the old "free means isFreeCheckout"
|
|
20
|
+
* test alone would send it to a card form for a charge of nothing.
|
|
21
|
+
*/
|
|
22
|
+
const tier = (overrides: Partial<ITicket> = {}): ITicket =>
|
|
23
|
+
({
|
|
24
|
+
id: "t-ga",
|
|
25
|
+
title: "General admission",
|
|
26
|
+
description: "",
|
|
27
|
+
price: 25,
|
|
28
|
+
quantity: 100,
|
|
29
|
+
order: 0,
|
|
30
|
+
sold: 0,
|
|
31
|
+
maxPerPerson: 4,
|
|
32
|
+
...overrides,
|
|
33
|
+
}) as ITicket;
|
|
34
|
+
|
|
35
|
+
const ga = tier();
|
|
36
|
+
const vip = tier({ id: "t-vip", title: "Front row", requiresApproval: true });
|
|
37
|
+
const backstage = tier({ id: "t-back", title: "Backstage", requiresApproval: true, price: 0 });
|
|
38
|
+
const tiers = [ga, vip, backstage];
|
|
39
|
+
|
|
40
|
+
describe("tierRequiresApproval / eventHasApprovalTiers", () => {
|
|
41
|
+
it("reads the flag, and treats its absence (an older API) as an ordinary tier", () => {
|
|
42
|
+
expect(tierRequiresApproval(vip)).toBe(true);
|
|
43
|
+
expect(tierRequiresApproval(ga)).toBe(false);
|
|
44
|
+
expect(tierRequiresApproval(undefined)).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("finds an approval tier on the event even when the event-level flag did not come", () => {
|
|
48
|
+
expect(eventHasApprovalTiers({ tickets: [ga, vip] } as IEvent)).toBe(true);
|
|
49
|
+
expect(eventHasApprovalTiers({ tickets: [ga] } as IEvent)).toBe(false);
|
|
50
|
+
expect(eventHasApprovalTiers({ tickets: [], hasApprovalTiers: true } as unknown as IEvent)).toBe(true);
|
|
51
|
+
expect(eventHasApprovalTiers(undefined)).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("selectionMode / selectionRequiresApproval", () => {
|
|
56
|
+
it("is null while nothing is selected, so either kind is still addable", () => {
|
|
57
|
+
expect(selectionMode(tiers, {})).toBeNull();
|
|
58
|
+
expect(selectionMode(tiers, { "t-ga": 0 })).toBeNull();
|
|
59
|
+
expect(selectionRequiresApproval(tiers, {})).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("is a request once an approval tier is in", () => {
|
|
63
|
+
expect(selectionMode(tiers, { "t-vip": 1 })).toBe("request");
|
|
64
|
+
expect(selectionRequiresApproval(tiers, { "t-vip": 2 })).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("is a purchase once an ordinary tier is in", () => {
|
|
68
|
+
expect(selectionMode(tiers, { "t-ga": 1 })).toBe("purchase");
|
|
69
|
+
expect(selectionRequiresApproval(tiers, { "t-ga": 1 })).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("reads a mixed selection as a request, the kind the server will refuse rather than silently sell", () => {
|
|
73
|
+
expect(selectionMode(tiers, { "t-ga": 1, "t-vip": 1 })).toBe("request");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("ignores quantities for tiers the event no longer lists", () => {
|
|
77
|
+
expect(selectionMode(tiers, { "t-gone": 3 })).toBeNull();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("tierBlockedByApprovalMix: one order is a request or a purchase", () => {
|
|
82
|
+
it("blocks nothing on an empty selection", () => {
|
|
83
|
+
expect(tierBlockedByApprovalMix(ga, tiers, {})).toBe(false);
|
|
84
|
+
expect(tierBlockedByApprovalMix(vip, tiers, {})).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("blocks an ordinary tier once an approval tier is selected, and the reverse", () => {
|
|
88
|
+
expect(tierBlockedByApprovalMix(ga, tiers, { "t-vip": 1 })).toBe(true);
|
|
89
|
+
expect(tierBlockedByApprovalMix(vip, tiers, { "t-ga": 1 })).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("lets two approval tiers share a request, and two ordinary tiers share a purchase", () => {
|
|
93
|
+
expect(tierBlockedByApprovalMix(backstage, tiers, { "t-vip": 1 })).toBe(false);
|
|
94
|
+
expect(tierBlockedByApprovalMix(ga, tiers, { "t-ga": 1 })).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("never blocks a tier that is already in the selection, so its own stepper keeps working", () => {
|
|
98
|
+
// If it were blocked, a fan holding one seat could not add a second, and
|
|
99
|
+
// the `+` would go dark on the very tier that set the mode.
|
|
100
|
+
expect(tierBlockedByApprovalMix(vip, tiers, { "t-vip": 1 })).toBe(false);
|
|
101
|
+
expect(tierBlockedByApprovalMix(ga, tiers, { "t-ga": 2 })).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("nextStepAfterTicketOrder: where the checkout goes once the order exists", () => {
|
|
106
|
+
it("completes a free purchase, exactly as before", () => {
|
|
107
|
+
expect(nextStepAfterTicketOrder({ isFreeCheckout: true, totalAmount: 0 })).toBe("complete");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("REGRESSION: completes a FREE REQUEST, which is not a free checkout", () => {
|
|
111
|
+
// The server has already put it in `pending_approval` and minted nothing,
|
|
112
|
+
// so `isFreeCheckout` is false. There is no card to show.
|
|
113
|
+
expect(nextStepAfterTicketOrder({ isFreeCheckout: false, awaitingApproval: true, totalAmount: 0 })).toBe(
|
|
114
|
+
"complete",
|
|
115
|
+
);
|
|
116
|
+
// A bigint-ish string total, which is what some responses carry.
|
|
117
|
+
expect(nextStepAfterTicketOrder({ awaitingApproval: true, totalAmount: "0" })).toBe("complete");
|
|
118
|
+
expect(nextStepAfterTicketOrder({ awaitingApproval: true, totalAmount: null })).toBe("complete");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("sends a PRICED request to payment, like any sale", () => {
|
|
122
|
+
// The card is authorised rather than charged, but the fan still has to
|
|
123
|
+
// present it; only start-payment's wording differs.
|
|
124
|
+
expect(nextStepAfterTicketOrder({ isFreeCheckout: false, awaitingApproval: true, totalAmount: 40 })).toBe(
|
|
125
|
+
"payment",
|
|
126
|
+
);
|
|
127
|
+
expect(nextStepAfterTicketOrder({ awaitingApproval: true, totalAmount: "12.50" })).toBe("payment");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("sends an ordinary priced sale to payment, and never completes on a zero total alone", () => {
|
|
131
|
+
expect(nextStepAfterTicketOrder({ isFreeCheckout: false, totalAmount: 40 })).toBe("payment");
|
|
132
|
+
// A zero total without `isFreeCheckout` and without `awaitingApproval` is
|
|
133
|
+
// the old behaviour: the server did not say it was free, so start payment.
|
|
134
|
+
expect(nextStepAfterTicketOrder({ isFreeCheckout: false, totalAmount: 0 })).toBe("payment");
|
|
135
|
+
expect(nextStepAfterTicketOrder({})).toBe("payment");
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
3
|
+
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
4
|
+
import axios, { type AxiosAdapter, type InternalAxiosRequestConfig } from "axios";
|
|
5
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
6
|
+
import type { ReactNode } from "react";
|
|
7
|
+
import { ForgeClientProvider } from "../../../../provider/ForgeProvider";
|
|
8
|
+
import { PublicAuthProvider } from "../../../../contexts/PublicAuthContext";
|
|
9
|
+
import { CartProvider } from "../../../../contexts/CartContext";
|
|
10
|
+
import { useEventCheckout } from "../useEventCheckout";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Ticket requests (curated access, feature 3), end to end through the REAL
|
|
14
|
+
* hook with only the network faked.
|
|
15
|
+
*
|
|
16
|
+
* The case that matters most is the free request. The server answers it with
|
|
17
|
+
* `isFreeCheckout: false` (no passes were minted: the order is
|
|
18
|
+
* `pending_approval`) and `awaitingApproval: true`, so the branch that used to
|
|
19
|
+
* decide "free means done" would have sent the fan to a card form for a charge
|
|
20
|
+
* of nothing. The hook has to hand the order id to `onComplete` exactly as it
|
|
21
|
+
* does for a free purchase, and never call start-payment.
|
|
22
|
+
*
|
|
23
|
+
* Providers are real and the axios adapter is swapped, so the assertion is on
|
|
24
|
+
* what actually went over the wire.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const PROFILE_ID = "profile-1";
|
|
28
|
+
const SLUG = "summer-show";
|
|
29
|
+
|
|
30
|
+
const event = {
|
|
31
|
+
id: "ev-1",
|
|
32
|
+
slug: SLUG,
|
|
33
|
+
title: "Summer show",
|
|
34
|
+
dateTime: "2026-12-01T19:00:00.000Z",
|
|
35
|
+
type: "in_person",
|
|
36
|
+
status: "published",
|
|
37
|
+
tickets: [
|
|
38
|
+
{ id: "t-ga", title: "General admission", description: "", price: 25, quantity: 100, order: 0, sold: 0, maxPerPerson: 4 },
|
|
39
|
+
{
|
|
40
|
+
id: "t-vip",
|
|
41
|
+
title: "Front row",
|
|
42
|
+
description: "",
|
|
43
|
+
price: 0,
|
|
44
|
+
quantity: 10,
|
|
45
|
+
order: 1,
|
|
46
|
+
sold: 0,
|
|
47
|
+
maxPerPerson: 2,
|
|
48
|
+
requiresApproval: true,
|
|
49
|
+
approvalWindowHours: 72,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: "t-priced",
|
|
53
|
+
title: "Backstage",
|
|
54
|
+
description: "",
|
|
55
|
+
price: 40,
|
|
56
|
+
quantity: 10,
|
|
57
|
+
order: 2,
|
|
58
|
+
sold: 0,
|
|
59
|
+
maxPerPerson: 2,
|
|
60
|
+
requiresApproval: true,
|
|
61
|
+
approvalWindowHours: null,
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
media: [],
|
|
65
|
+
questionnaire: [],
|
|
66
|
+
bookingFee: null,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
type Sent = { method: string; url: string; body: Record<string, unknown> };
|
|
70
|
+
let sent: Sent[] = [];
|
|
71
|
+
let realAdapter: AxiosAdapter | undefined;
|
|
72
|
+
let orderResponse: Record<string, unknown> = {};
|
|
73
|
+
|
|
74
|
+
function installAdapter() {
|
|
75
|
+
sent = [];
|
|
76
|
+
const adapter: AxiosAdapter = async (config: InternalAxiosRequestConfig) => {
|
|
77
|
+
const url = config.url ?? "";
|
|
78
|
+
const body = typeof config.data === "string" ? (JSON.parse(config.data) as Record<string, unknown>) : {};
|
|
79
|
+
sent.push({ method: (config.method ?? "get").toLowerCase(), url, body });
|
|
80
|
+
const ok = (data: unknown) => ({ data, status: 200, statusText: "OK", headers: {}, config }) as never;
|
|
81
|
+
|
|
82
|
+
if (url.includes(`/public/events/${SLUG}`)) return ok(event);
|
|
83
|
+
if (url.includes(`/public/events/${event.id}/orders`)) return ok(orderResponse);
|
|
84
|
+
if (url.includes(`/public/events/${event.id}/start-payment`)) {
|
|
85
|
+
return ok({
|
|
86
|
+
paymentSecret: "pi_secret",
|
|
87
|
+
paymentId: "pi_1",
|
|
88
|
+
paymentProvider: "stripe",
|
|
89
|
+
totalAmount: 40,
|
|
90
|
+
settlement: "deferred",
|
|
91
|
+
deferredStrategy: "authorization",
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
if (url.includes("/public/currencies")) {
|
|
95
|
+
return ok({ supportedCurrencies: ["USD"], userCurrency: "USD", exchangeRates: {} });
|
|
96
|
+
}
|
|
97
|
+
return ok({});
|
|
98
|
+
};
|
|
99
|
+
realAdapter = axios.defaults.adapter as AxiosAdapter | undefined;
|
|
100
|
+
axios.defaults.adapter = adapter;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
104
|
+
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })}>
|
|
105
|
+
<ForgeClientProvider apiUrl="http://api.test" profileId={PROFILE_ID}>
|
|
106
|
+
<PublicAuthProvider>
|
|
107
|
+
<CartProvider>{children}</CartProvider>
|
|
108
|
+
</PublicAuthProvider>
|
|
109
|
+
</ForgeClientProvider>
|
|
110
|
+
</QueryClientProvider>
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const mount = (onComplete?: (info: { slug: string; orderId: string }) => void) =>
|
|
114
|
+
renderHook(() => useEventCheckout(SLUG, { onComplete }), { wrapper });
|
|
115
|
+
|
|
116
|
+
const fillDetails = (result: { current: ReturnType<typeof useEventCheckout> }) =>
|
|
117
|
+
act(() => {
|
|
118
|
+
result.current.setFirstName("Fan");
|
|
119
|
+
result.current.setLastName("Person");
|
|
120
|
+
result.current.setEmail("fan@example.com");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
beforeEach(() => {
|
|
124
|
+
localStorage.clear();
|
|
125
|
+
installAdapter();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
afterEach(() => {
|
|
129
|
+
if (realAdapter) axios.defaults.adapter = realAdapter;
|
|
130
|
+
localStorage.clear();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("useEventCheckout: a ticket request", () => {
|
|
134
|
+
it("REGRESSION: a FREE request completes with the order id and never starts a payment", async () => {
|
|
135
|
+
orderResponse = {
|
|
136
|
+
orderId: "order-free-request",
|
|
137
|
+
isFreeCheckout: false,
|
|
138
|
+
awaitingApproval: true,
|
|
139
|
+
totalAmount: 0,
|
|
140
|
+
subTotal: 0,
|
|
141
|
+
discountAmount: 0,
|
|
142
|
+
feeAmount: 0,
|
|
143
|
+
requestExpiresAt: "2026-09-19T12:00:00.000Z",
|
|
144
|
+
holdExpiresAt: null,
|
|
145
|
+
};
|
|
146
|
+
const onComplete = vi.fn();
|
|
147
|
+
const { result } = mount(onComplete);
|
|
148
|
+
await waitFor(() => expect(result.current.event?.id).toBe("ev-1"));
|
|
149
|
+
|
|
150
|
+
act(() => result.current.setTicketQty("t-vip", 1));
|
|
151
|
+
expect(result.current.awaitingApproval).toBe(true);
|
|
152
|
+
expect(result.current.hasApprovalTiers).toBe(true);
|
|
153
|
+
await fillDetails(result);
|
|
154
|
+
|
|
155
|
+
await act(async () => {
|
|
156
|
+
await result.current.continueToPayment();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
expect(onComplete).toHaveBeenCalledTimes(1);
|
|
160
|
+
expect(onComplete).toHaveBeenCalledWith({ slug: SLUG, orderId: "order-free-request" });
|
|
161
|
+
// The order was posted, the charge was not.
|
|
162
|
+
expect(sent.some((r) => r.method === "post" && r.url.includes("/orders"))).toBe(true);
|
|
163
|
+
expect(sent.some((r) => r.url.includes("/start-payment"))).toBe(false);
|
|
164
|
+
// Still on details: no payment step was entered for a charge of nothing.
|
|
165
|
+
expect(result.current.step).not.toBe("payment");
|
|
166
|
+
expect(result.current.requestExpiresAt).toBe("2026-09-19T12:00:00.000Z");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("a PRICED request still goes to payment, and carries the deferred settlement through", async () => {
|
|
170
|
+
orderResponse = {
|
|
171
|
+
orderId: "order-priced-request",
|
|
172
|
+
isFreeCheckout: false,
|
|
173
|
+
awaitingApproval: true,
|
|
174
|
+
totalAmount: 40,
|
|
175
|
+
subTotal: 40,
|
|
176
|
+
discountAmount: 0,
|
|
177
|
+
feeAmount: 0,
|
|
178
|
+
requestExpiresAt: "2026-09-19T12:00:00.000Z",
|
|
179
|
+
holdExpiresAt: "2026-09-16T12:15:00.000Z",
|
|
180
|
+
};
|
|
181
|
+
const onComplete = vi.fn();
|
|
182
|
+
const { result } = mount(onComplete);
|
|
183
|
+
await waitFor(() => expect(result.current.event?.id).toBe("ev-1"));
|
|
184
|
+
|
|
185
|
+
act(() => result.current.setTicketQty("t-priced", 1));
|
|
186
|
+
await fillDetails(result);
|
|
187
|
+
await act(async () => {
|
|
188
|
+
await result.current.continueToPayment();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
expect(onComplete).not.toHaveBeenCalled();
|
|
192
|
+
const start = sent.find((r) => r.url.includes("/start-payment"));
|
|
193
|
+
expect(start).toBeDefined();
|
|
194
|
+
expect(start?.body).toMatchObject({ orderId: "order-priced-request", profileId: PROFILE_ID });
|
|
195
|
+
await waitFor(() => expect(result.current.step).toBe("payment"));
|
|
196
|
+
expect(result.current.clientSecret).toBe("pi_secret");
|
|
197
|
+
// What the payment renderer reads to say "Authorize" and "a hold will show".
|
|
198
|
+
expect(result.current.settlement).toBe("deferred");
|
|
199
|
+
expect(result.current.deferredStrategy).toBe("authorization");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("an ordinary free purchase completes exactly as it always did", async () => {
|
|
203
|
+
orderResponse = { orderId: "order-free", isFreeCheckout: true, totalAmount: 0, feeAmount: 0, holdExpiresAt: null };
|
|
204
|
+
const onComplete = vi.fn();
|
|
205
|
+
const { result } = mount(onComplete);
|
|
206
|
+
await waitFor(() => expect(result.current.event?.id).toBe("ev-1"));
|
|
207
|
+
|
|
208
|
+
act(() => result.current.setTicketQty("t-ga", 1));
|
|
209
|
+
expect(result.current.awaitingApproval).toBe(false);
|
|
210
|
+
await fillDetails(result);
|
|
211
|
+
await act(async () => {
|
|
212
|
+
await result.current.continueToPayment();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
expect(onComplete).toHaveBeenCalledWith({ slug: SLUG, orderId: "order-free" });
|
|
216
|
+
expect(sent.some((r) => r.url.includes("/start-payment"))).toBe(false);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("refuses to mix a request and a purchase in one selection", async () => {
|
|
220
|
+
const { result } = mount();
|
|
221
|
+
await waitFor(() => expect(result.current.event?.id).toBe("ev-1"));
|
|
222
|
+
|
|
223
|
+
act(() => result.current.setTicketQty("t-vip", 1));
|
|
224
|
+
expect(result.current.isTierBlockedByApprovalMix("t-ga")).toBe(true);
|
|
225
|
+
expect(result.current.isTierBlockedByApprovalMix("t-priced")).toBe(false);
|
|
226
|
+
// A host driving the hook from its own UI cannot build the cart either.
|
|
227
|
+
act(() => result.current.setTicketQty("t-ga", 1));
|
|
228
|
+
expect(result.current.selectedTickets).toEqual({ "t-vip": 1 });
|
|
229
|
+
|
|
230
|
+
// Clearing the request frees the ordinary tier again.
|
|
231
|
+
act(() => result.current.setTicketQty("t-vip", 0));
|
|
232
|
+
expect(result.current.isTierBlockedByApprovalMix("t-ga")).toBe(false);
|
|
233
|
+
act(() => result.current.setTicketQty("t-ga", 2));
|
|
234
|
+
expect(result.current.selectedTickets).toEqual({ "t-ga": 2 });
|
|
235
|
+
expect(result.current.isTierBlockedByApprovalMix("t-vip")).toBe(true);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("does not send a request to the cart", async () => {
|
|
239
|
+
const { result } = mount();
|
|
240
|
+
await waitFor(() => expect(result.current.event?.id).toBe("ev-1"));
|
|
241
|
+
|
|
242
|
+
act(() => result.current.setTicketQty("t-vip", 1));
|
|
243
|
+
let added: boolean | undefined;
|
|
244
|
+
act(() => {
|
|
245
|
+
added = result.current.addTicketsToCart();
|
|
246
|
+
});
|
|
247
|
+
expect(added).toBe(false);
|
|
248
|
+
expect(result.current.ticketsInCart).toBe(false);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { IEvent, ITicket } from "../../../types/models";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Ticket requests (curated access, feature 3): the pure decisions behind the
|
|
5
|
+
* picker and the checkout, kept out of the hook so they can be tested without
|
|
6
|
+
* standing up the providers the hook needs.
|
|
7
|
+
*
|
|
8
|
+
* A tier with `requiresApproval` is not sold, it is REQUESTED: the card is
|
|
9
|
+
* committed (authorised) and the seat held, and the organiser has a window to
|
|
10
|
+
* approve or decline. One order is either a request or a purchase, never
|
|
11
|
+
* both: the API refuses a mixed cart (`errors.event.mixed_approval_cart`), and
|
|
12
|
+
* the picker stops the fan building one in the first place.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Does this tier need the organiser's approval? Absent on an older API build means no. */
|
|
16
|
+
export function tierRequiresApproval(ticket: Pick<ITicket, "requiresApproval"> | undefined): boolean {
|
|
17
|
+
return ticket?.requiresApproval === true;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Does this event carry any approval-required tier at all? Read off the tiers, not the flag, so a partial payload still answers. */
|
|
21
|
+
export function eventHasApprovalTiers(event: Pick<IEvent, "tickets" | "hasApprovalTiers"> | undefined): boolean {
|
|
22
|
+
if (!event) return false;
|
|
23
|
+
if (event.hasApprovalTiers === true) return true;
|
|
24
|
+
return (event.tickets ?? []).some(tierRequiresApproval);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Which kind of order the current selection would make. `null` while nothing
|
|
29
|
+
* is selected (either kind is still addable), `"request"` once an approval
|
|
30
|
+
* tier is in, `"purchase"` once an ordinary one is. A mixed selection cannot
|
|
31
|
+
* arise through `tierBlockedByApprovalMix`, but if one is handed in (a stale
|
|
32
|
+
* cache, a tier that changed mode underneath the fan) it reads as a request,
|
|
33
|
+
* because that is the button the server will refuse rather than the one it
|
|
34
|
+
* will silently sell.
|
|
35
|
+
*/
|
|
36
|
+
export function selectionMode(
|
|
37
|
+
tickets: readonly Pick<ITicket, "id" | "requiresApproval">[],
|
|
38
|
+
quantities: Record<string, number>,
|
|
39
|
+
): "request" | "purchase" | null {
|
|
40
|
+
let approval = false;
|
|
41
|
+
let ordinary = false;
|
|
42
|
+
for (const ticket of tickets) {
|
|
43
|
+
if ((quantities[ticket.id] ?? 0) <= 0) continue;
|
|
44
|
+
if (tierRequiresApproval(ticket)) approval = true;
|
|
45
|
+
else ordinary = true;
|
|
46
|
+
}
|
|
47
|
+
if (approval) return "request";
|
|
48
|
+
if (ordinary) return "purchase";
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The selection contains at least one approval tier, so submitting it lodges a request. */
|
|
53
|
+
export function selectionRequiresApproval(
|
|
54
|
+
tickets: readonly Pick<ITicket, "id" | "requiresApproval">[],
|
|
55
|
+
quantities: Record<string, number>,
|
|
56
|
+
): boolean {
|
|
57
|
+
return selectionMode(tickets, quantities) === "request";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* May this tier be added to the current selection?
|
|
62
|
+
*
|
|
63
|
+
* Blocked when the selection already holds the OTHER kind: an approval tier
|
|
64
|
+
* beside a purchase, or a purchase beside a request. A tier that is already in
|
|
65
|
+
* the selection is never blocked (it is the kind that set the mode), and an
|
|
66
|
+
* empty selection blocks nothing.
|
|
67
|
+
*/
|
|
68
|
+
export function tierBlockedByApprovalMix(
|
|
69
|
+
ticket: Pick<ITicket, "id" | "requiresApproval">,
|
|
70
|
+
tickets: readonly Pick<ITicket, "id" | "requiresApproval">[],
|
|
71
|
+
quantities: Record<string, number>,
|
|
72
|
+
): boolean {
|
|
73
|
+
if ((quantities[ticket.id] ?? 0) > 0) return false;
|
|
74
|
+
const mode = selectionMode(tickets, quantities);
|
|
75
|
+
if (mode === null) return false;
|
|
76
|
+
return tierRequiresApproval(ticket) ? mode === "purchase" : mode === "request";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The fields of a `POST /public/events/:id/orders` response the routing reads. */
|
|
80
|
+
export interface TicketOrderRoutingInput {
|
|
81
|
+
isFreeCheckout?: boolean;
|
|
82
|
+
awaitingApproval?: boolean;
|
|
83
|
+
totalAmount?: number | string | null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Where the checkout goes once the order row exists.
|
|
88
|
+
*
|
|
89
|
+
* complete nothing to pay: a free purchase (passes minted, `isFreeCheckout`)
|
|
90
|
+
* or a FREE REQUEST (already `pending_approval`, `isFreeCheckout`
|
|
91
|
+
* false, `awaitingApproval` true, total 0). Both hand the order id
|
|
92
|
+
* to `onComplete`, and the host navigates to the finalise page,
|
|
93
|
+
* which renders the right outcome off the order's own status.
|
|
94
|
+
* payment there is money on it: start the charge. A priced request runs
|
|
95
|
+
* the payment step exactly as a sale does; only the wording and
|
|
96
|
+
* the settlement differ, and start-payment says which.
|
|
97
|
+
*
|
|
98
|
+
* `isFreeCheckout` wins outright, as it always has. The free-request branch is
|
|
99
|
+
* keyed on the TOTAL, not on `awaitingApproval` alone, because a priced
|
|
100
|
+
* request also carries `awaitingApproval: true` and must still reach the card.
|
|
101
|
+
*/
|
|
102
|
+
export function nextStepAfterTicketOrder(order: TicketOrderRoutingInput): "complete" | "payment" {
|
|
103
|
+
if (order.isFreeCheckout) return "complete";
|
|
104
|
+
if (order.awaitingApproval === true) {
|
|
105
|
+
const total = Number(order.totalAmount ?? 0);
|
|
106
|
+
if (!Number.isFinite(total) || total <= 0) return "complete";
|
|
107
|
+
}
|
|
108
|
+
return "payment";
|
|
109
|
+
}
|
|
@@ -22,6 +22,12 @@ import { checkoutAmounts, isPayWhatYouWant, resolveUnitPrice, ticketSubtotals }
|
|
|
22
22
|
import { parseMembershipGateError, type MembershipGateRefusal } from "../../format/membershipGate";
|
|
23
23
|
import { readAttributionRef } from "../../../utils/attribution";
|
|
24
24
|
import { readLanding } from "../../../utils/landing";
|
|
25
|
+
import {
|
|
26
|
+
eventHasApprovalTiers,
|
|
27
|
+
nextStepAfterTicketOrder,
|
|
28
|
+
selectionRequiresApproval,
|
|
29
|
+
tierBlockedByApprovalMix,
|
|
30
|
+
} from "./ticketApproval";
|
|
25
31
|
|
|
26
32
|
export type EventCheckoutStep = "tickets" | "details" | "payment";
|
|
27
33
|
|
|
@@ -32,7 +38,10 @@ export interface UseEventCheckoutOptions {
|
|
|
32
38
|
finalisePath?: (slug: string, orderId: string) => string;
|
|
33
39
|
/** Called when a FREE order completes in-app — the host navigates however it
|
|
34
40
|
* wants (e.g. its router), instead of Forge doing a `window.location` redirect.
|
|
35
|
-
* Omit to keep the default redirect.
|
|
41
|
+
* Omit to keep the default redirect. Also fired for a FREE TICKET REQUEST
|
|
42
|
+
* (`awaitingApproval` with nothing to pay), which has no payment step either:
|
|
43
|
+
* the finalise page it lands on reads the order's status and renders
|
|
44
|
+
* "request received". */
|
|
36
45
|
onComplete?: (info: { slug: string; orderId: string }) => void;
|
|
37
46
|
}
|
|
38
47
|
|
|
@@ -179,6 +188,27 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
|
|
|
179
188
|
[selectedTickets],
|
|
180
189
|
);
|
|
181
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Ticket requests (curated access, feature 3).
|
|
193
|
+
*
|
|
194
|
+
* `hasApprovalTiers` says the event carries one at all; `awaitingApproval`
|
|
195
|
+
* says THIS selection is a request rather than a purchase, which is what the
|
|
196
|
+
* primary button ("Request tickets") and the payment wording read. One order
|
|
197
|
+
* is either, never both: `isTierBlockedByApprovalMix` is what the picker
|
|
198
|
+
* disables the `+` on, so the server's mixed-cart refusal is never reached.
|
|
199
|
+
*/
|
|
200
|
+
const hasApprovalTiers = eventHasApprovalTiers(event);
|
|
201
|
+
const awaitingApproval = useMemo(
|
|
202
|
+
() => selectionRequiresApproval(event?.tickets ?? [], selectedTickets),
|
|
203
|
+
[event, selectedTickets],
|
|
204
|
+
);
|
|
205
|
+
const isTierBlockedByApprovalMix = (ticketId: string) => {
|
|
206
|
+
const ticket = event?.tickets.find((t) => t.id === ticketId);
|
|
207
|
+
return ticket ? tierBlockedByApprovalMix(ticket, event?.tickets ?? [], selectedTickets) : false;
|
|
208
|
+
};
|
|
209
|
+
/** When the request lapses if the organiser does not decide, from the order response. */
|
|
210
|
+
const [requestExpiresAt, setRequestExpiresAt] = useState<string | null>(null);
|
|
211
|
+
|
|
182
212
|
/**
|
|
183
213
|
* The artist's booking fee (1.4) — the thing the buyer was charged and never
|
|
184
214
|
* told about, on any surface, on either rendering stack.
|
|
@@ -212,6 +242,13 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
|
|
|
212
242
|
// rejects those with "quantity must be positive").
|
|
213
243
|
const setTicketQty = (ticketId: string, qty: number) =>
|
|
214
244
|
setSelectedTickets((prev) => {
|
|
245
|
+
// A request and a purchase cannot share an order. The picker disables
|
|
246
|
+
// the control, and this refuses the write too, so a host that drives the
|
|
247
|
+
// hook from its own UI cannot build the cart the server will refuse.
|
|
248
|
+
if (qty > 0 && (prev[ticketId] ?? 0) === 0 && event) {
|
|
249
|
+
const ticket = event.tickets.find((t) => t.id === ticketId);
|
|
250
|
+
if (ticket && tierBlockedByApprovalMix(ticket, event.tickets, prev)) return prev;
|
|
251
|
+
}
|
|
215
252
|
const next = { ...prev };
|
|
216
253
|
if (qty > 0) next[ticketId] = qty;
|
|
217
254
|
else delete next[ticketId];
|
|
@@ -253,6 +290,14 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
|
|
|
253
290
|
return false;
|
|
254
291
|
}
|
|
255
292
|
if (!event) return false;
|
|
293
|
+
// A request is not a purchase and cannot ride in a bundle: the bundle
|
|
294
|
+
// checkout forces payment and the API refuses approval tiers in one. Sent
|
|
295
|
+
// to the cart it would fail at the end of a longer journey, so it is
|
|
296
|
+
// refused here, and the surface hides the exit for a request anyway.
|
|
297
|
+
if (awaitingApproval) {
|
|
298
|
+
setError("Tickets that need our approval are requested on their own.");
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
256
301
|
setError(null);
|
|
257
302
|
setTickets({
|
|
258
303
|
eventId: event.id,
|
|
@@ -372,12 +417,15 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
|
|
|
372
417
|
// legitimately reports 0 — falling back to the estimate there would put a
|
|
373
418
|
// fee on a comp.
|
|
374
419
|
setServerFeeAmount(data.feeAmount ?? 0);
|
|
420
|
+
setRequestExpiresAt(data.requestExpiresAt ?? null);
|
|
375
421
|
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
|
376
422
|
const ru = `${origin}${finalisePath(slug, data.orderId)}`;
|
|
377
423
|
setReturnUrl(ru);
|
|
378
|
-
if (data
|
|
379
|
-
//
|
|
380
|
-
//
|
|
424
|
+
if (nextStepAfterTicketOrder(data) === "complete") {
|
|
425
|
+
// Nothing to pay: a free order, or a free ticket REQUEST that is already
|
|
426
|
+
// `pending_approval`. Hand navigation to the host if it asked, else
|
|
427
|
+
// default to an in-app redirect to the finalise page, which renders the
|
|
428
|
+
// right outcome off the order's own status.
|
|
381
429
|
if (opts.onComplete) opts.onComplete({ slug, orderId: data.orderId });
|
|
382
430
|
else if (typeof window !== "undefined") window.location.href = ru;
|
|
383
431
|
return;
|
|
@@ -436,6 +484,7 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
|
|
|
436
484
|
// subtotal, so keeping it would understate the fee on the fresh, full-price
|
|
437
485
|
// order the next `continueToPayment` creates.
|
|
438
486
|
setServerFeeAmount(null);
|
|
487
|
+
setRequestExpiresAt(null);
|
|
439
488
|
setStep("details");
|
|
440
489
|
};
|
|
441
490
|
|
|
@@ -493,6 +542,27 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
|
|
|
493
542
|
/** What each selected PWYW tier will actually be charged, per unit. */
|
|
494
543
|
effectiveAmounts,
|
|
495
544
|
ticketCount,
|
|
545
|
+
/**
|
|
546
|
+
* Ticket requests (curated access, feature 3). `hasApprovalTiers`: the
|
|
547
|
+
* event carries at least one tier the organiser approves. `awaitingApproval`:
|
|
548
|
+
* THIS selection is a request, so the primary button reads "Request
|
|
549
|
+
* tickets", the pay button "Authorize", and nothing is sold until the
|
|
550
|
+
* organiser decides. `isTierBlockedByApprovalMix(id)`: the tier cannot join
|
|
551
|
+
* the current selection because one order is either a request or a
|
|
552
|
+
* purchase, never both. `requestExpiresAt`: when an undecided request
|
|
553
|
+
* lapses, once the order exists.
|
|
554
|
+
*/
|
|
555
|
+
hasApprovalTiers,
|
|
556
|
+
awaitingApproval,
|
|
557
|
+
isTierBlockedByApprovalMix,
|
|
558
|
+
requestExpiresAt,
|
|
559
|
+
/**
|
|
560
|
+
* From start-payment: `"deferred"` on a priced request, so the payment
|
|
561
|
+
* renderer can label the button "Authorize" and say the card is charged
|
|
562
|
+
* only on approval. Undefined on a sale.
|
|
563
|
+
*/
|
|
564
|
+
settlement: flow.settlement,
|
|
565
|
+
deferredStrategy: flow.deferredStrategy,
|
|
496
566
|
// The server's figure wins the moment there is one: start-payment first,
|
|
497
567
|
// then the order response (which is already net of any discount), and only
|
|
498
568
|
// the locally summed ticket prices before either exists.
|
package/src/ui/headless/index.ts
CHANGED
|
@@ -53,6 +53,17 @@ export { useSignupForm, type UseSignupFormOptions } from "./auth/useSignupForm";
|
|
|
53
53
|
// Flow primitives (Part A2) — multi-step purchase/booking/subscribe/chat flows,
|
|
54
54
|
// each composed from the data hooks so a creator can rebuild any page.
|
|
55
55
|
export { useEventCheckout, type UseEventCheckoutOptions, type EventCheckoutStep } from "./event/useEventCheckout";
|
|
56
|
+
// Ticket requests (curated access): the pure decisions behind the picker and
|
|
57
|
+
// the post-order routing, for a host that drives the hook from its own UI.
|
|
58
|
+
export {
|
|
59
|
+
eventHasApprovalTiers,
|
|
60
|
+
nextStepAfterTicketOrder,
|
|
61
|
+
selectionMode,
|
|
62
|
+
selectionRequiresApproval,
|
|
63
|
+
tierBlockedByApprovalMix,
|
|
64
|
+
tierRequiresApproval,
|
|
65
|
+
type TicketOrderRoutingInput,
|
|
66
|
+
} from "./event/ticketApproval";
|
|
56
67
|
export {
|
|
57
68
|
usePresaleCode,
|
|
58
69
|
type PresaleCodeField,
|
|
@@ -89,6 +100,29 @@ export {
|
|
|
89
100
|
type MembershipCheckoutStep,
|
|
90
101
|
type BillingCycle,
|
|
91
102
|
} from "./membership/useMembershipCheckout";
|
|
103
|
+
// Curated access (invite-only and apply-to-join tiers): the pure decisions the
|
|
104
|
+
// tier card, the checkout and the account page share.
|
|
105
|
+
export {
|
|
106
|
+
canWithdrawMembershipApplication,
|
|
107
|
+
MEMBERSHIP_APPLICATION_MESSAGE_MAX,
|
|
108
|
+
membershipApplicationOutcome,
|
|
109
|
+
tierCardAction,
|
|
110
|
+
tierUsesApplication,
|
|
111
|
+
type MembershipApplicationOutcome,
|
|
112
|
+
type MembershipTierCardAction,
|
|
113
|
+
} from "./membership/membershipApplication";
|
|
114
|
+
export {
|
|
115
|
+
formatTrialDate,
|
|
116
|
+
isMembershipInTrial,
|
|
117
|
+
tierTrialDays,
|
|
118
|
+
trialEndDate,
|
|
119
|
+
} from "./membership/membershipTrial";
|
|
120
|
+
// Tier questionnaires: which questions a checkout asks and what it sends.
|
|
121
|
+
export {
|
|
122
|
+
buildQuestionnaireAnswers,
|
|
123
|
+
firstMissingAnswer,
|
|
124
|
+
tierQuestionnaire,
|
|
125
|
+
} from "./membership/membershipQuestionnaire";
|
|
92
126
|
export { useInvoicePayment, type UseInvoicePaymentOptions } from "./invoice/useInvoicePayment";
|
|
93
127
|
export { usePaymentLinkPayment, type UsePaymentLinkPaymentOptions } from "./paymentLink/usePaymentLinkPayment";
|
|
94
128
|
export { useBroadcastWatch } from "./broadcast/useBroadcastWatch";
|