@tribe-nest/forge 3.29.0 → 3.34.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 +11 -3
- package/src/_tests/publishedResolvability.spec.ts +184 -0
- package/src/_tests/specsRunWorkspaceSource.spec.ts +116 -0
- package/src/_tests/workspaceAliases.ts +40 -0
- package/src/contexts/CartContext.tsx +17 -1
- package/src/contexts/PublicAuthContext.tsx +34 -5
- package/src/contexts/_tests/CartContext.spec.tsx +36 -0
- package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
- package/src/data/queries/useBroadcasts.ts +151 -0
- package/src/data/queries/useMyBookings.ts +18 -1
- package/src/i18n/_tests/translationKeys.spec.ts +15 -0
- package/src/i18n/de.json +97 -0
- package/src/i18n/en.json +97 -0
- package/src/ui/format/_tests/membershipPwyw.spec.ts +185 -0
- package/src/ui/format/_tests/pwyw.spec.ts +65 -8
- package/src/ui/format/membershipPwyw.ts +164 -0
- package/src/ui/format/pwyw.ts +37 -0
- package/src/ui/headless/broadcast/_tests/broadcastState.spec.ts +235 -0
- package/src/ui/headless/broadcast/broadcastState.ts +158 -0
- package/src/ui/headless/broadcast/useBroadcastWatch.ts +174 -21
- package/src/ui/headless/event/useEventCheckout.ts +8 -13
- package/src/ui/headless/index.ts +14 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +160 -32
- package/src/ui/index.ts +61 -0
- package/src/ui/media/BookingCallScreen.tsx +33 -0
- package/src/ui/media/CallHelpHint.tsx +87 -0
- package/src/ui/media/CallStage.tsx +633 -0
- package/src/ui/media/_tests/CallStage.spec.tsx +931 -0
- package/src/ui/media/_tests/bookingSession.spec.tsx +227 -0
- package/src/ui/media/_tests/callState.spec.ts +499 -0
- package/src/ui/media/_tests/fakeNode.ts +178 -0
- package/src/ui/media/bookingSession.tsx +182 -0
- package/src/ui/media/bookingWindow.ts +81 -0
- package/src/ui/media/callState.ts +360 -0
- package/src/ui/media/index.ts +135 -0
- package/src/ui/styled/AccountDashboard.tsx +193 -4
- package/src/ui/styled/BroadcastWatch.tsx +107 -0
- package/src/ui/styled/ForgotPasswordForm.tsx +5 -0
- package/src/ui/styled/LiveBroadcastList.tsx +171 -0
- package/src/ui/styled/LoginForm.tsx +10 -0
- package/src/ui/styled/MembershipCheckout.tsx +318 -45
- package/src/ui/styled/MembershipTiers.tsx +10 -3
- package/src/ui/styled/ResetPasswordForm.tsx +5 -0
- package/src/ui/styled/SignupForm.tsx +5 -0
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +166 -0
- package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +134 -0
- package/src/ui/styled/_tests/BroadcastPassValidation.spec.tsx +125 -0
- package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +364 -0
- package/src/ui/styled/broadcast/BroadcastPassValidation.tsx +187 -0
- package/src/ui/styled/broadcast/BroadcastPlayer.tsx +536 -0
- package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +74 -0
- package/src/ui/styled/broadcast/EndedBroadcast.tsx +103 -0
- package/src/ui/styled/community/CommunityComposer.tsx +182 -3
- package/src/ui/styled/community/CommunityFeed.tsx +36 -51
- package/src/ui/styled/community/CommunityPostDetail.tsx +16 -2
- package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +281 -0
- package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +175 -0
- package/src/ui/styled/forge-utilities.css +832 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useMemo, useState } from "react";
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
2
|
import { usePublicAuth } from "../../../contexts/PublicAuthContext";
|
|
3
3
|
import { useGetMembershipTiers } from "../../../data/queries/useMembership";
|
|
4
4
|
import {
|
|
@@ -14,9 +14,30 @@ import {
|
|
|
14
14
|
type PaystackCheckoutOutcome,
|
|
15
15
|
type PaystackCheckoutSession,
|
|
16
16
|
} from "../../../utils/paystackCheckout";
|
|
17
|
+
import type { MembershipTier } from "../../../types/models";
|
|
18
|
+
import {
|
|
19
|
+
cycleCeiling,
|
|
20
|
+
cycleFloor,
|
|
21
|
+
cycleIsFree,
|
|
22
|
+
defaultChosenAmount,
|
|
23
|
+
defaultCycle,
|
|
24
|
+
offeredCycles,
|
|
25
|
+
refuseMembershipAmount,
|
|
26
|
+
resolveSubscriptionAmount,
|
|
27
|
+
type BillingCycle,
|
|
28
|
+
type MembershipAmountRefusal,
|
|
29
|
+
} from "../../format/membershipPwyw";
|
|
17
30
|
|
|
18
|
-
|
|
19
|
-
|
|
31
|
+
/**
|
|
32
|
+
* Three steps, matching the client app's three stages.
|
|
33
|
+
*
|
|
34
|
+
* `"tier"` is the tier grid, reached when the page was opened with no
|
|
35
|
+
* `?membershipTierId`. `"select"` keeps its old name deliberately: it is the
|
|
36
|
+
* tier detail + billing options step, and every site already calls
|
|
37
|
+
* `setStep("select")` to go back from payment.
|
|
38
|
+
*/
|
|
39
|
+
export type MembershipCheckoutStep = "tier" | "select" | "payment";
|
|
40
|
+
export type { BillingCycle };
|
|
20
41
|
|
|
21
42
|
export interface UseMembershipCheckoutOptions {
|
|
22
43
|
/** Pre-select a tier (e.g. from a `?membershipTierId=` param). */
|
|
@@ -25,27 +46,37 @@ export interface UseMembershipCheckoutOptions {
|
|
|
25
46
|
returnPath?: string;
|
|
26
47
|
/** Where free activations land. Default `/i/account?tab=membership`. */
|
|
27
48
|
freeReturnPath?: string;
|
|
28
|
-
/**
|
|
29
|
-
*
|
|
30
|
-
|
|
49
|
+
/**
|
|
50
|
+
* Called when the subscription completes IN-APP with nothing left to pay: a
|
|
51
|
+
* free activation, or a tier change the server settled by proration. The host
|
|
52
|
+
* navigates however it wants instead of Forge doing a `window.location`
|
|
53
|
+
* redirect. `requiresConfirmation` is false for both of those, because there
|
|
54
|
+
* is no provider payment for the account page to reconcile.
|
|
55
|
+
*/
|
|
56
|
+
onComplete?: (result: { requiresConfirmation: boolean }) => void;
|
|
31
57
|
}
|
|
32
58
|
|
|
33
59
|
const errMessage = (e: unknown) =>
|
|
34
60
|
(e as { response?: { data?: { message?: string } } })?.response?.data?.message || "Something went wrong.";
|
|
35
61
|
|
|
36
|
-
const tierIsFree = (tier: { payWhatYouWant: boolean; priceMonthly?: number; priceYearly?: number }, cycle: BillingCycle) =>
|
|
37
|
-
!tier.payWhatYouWant && !(cycle === "month" ? tier.priceMonthly : tier.priceYearly);
|
|
38
|
-
|
|
39
62
|
/**
|
|
40
|
-
* Headless membership subscribe: tier selection
|
|
41
|
-
* subscription
|
|
63
|
+
* Headless membership subscribe: tier selection, billing cycle, pay-what-you-want
|
|
64
|
+
* amount, then free activation OR a paid subscription on the tenant's provider.
|
|
65
|
+
* Composes `useGetMembershipTiers`, `useCreateFreeSubscription`,
|
|
42
66
|
* `useCreateSubscription`, and (for the return page) `useConfirmLatestSubscription`.
|
|
43
|
-
* Paid flow exposes `clientSecret` for `ForgePaymentProvider
|
|
67
|
+
* Paid Stripe flow exposes `clientSecret` for `ForgePaymentProvider`; paid
|
|
68
|
+
* Paystack flow exposes `openPaystackCheckout`.
|
|
44
69
|
*
|
|
45
70
|
* Three outcomes, not two: a member who ALREADY subscribes to this artist is
|
|
46
71
|
* changing tier, which the server settles by proration on their existing
|
|
47
|
-
* subscription. That completes server-side with no payment step
|
|
72
|
+
* subscription. That completes server-side with no payment step, it is
|
|
48
73
|
* finished the moment `subscribe()` resolves, exactly like a free activation.
|
|
74
|
+
*
|
|
75
|
+
* ## Pricing lives in `ui/format/membershipPwyw`
|
|
76
|
+
*
|
|
77
|
+
* The floor, the default cycle, which cycles are on offer and the single number
|
|
78
|
+
* that gets sent are pure functions there, so they are testable without a
|
|
79
|
+
* provider tree. Read that file before changing anything about money here.
|
|
49
80
|
*/
|
|
50
81
|
export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
|
|
51
82
|
const { user } = usePublicAuth();
|
|
@@ -54,28 +85,105 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
|
|
|
54
85
|
const createFree = useCreateFreeSubscription();
|
|
55
86
|
const confirmLatest = useConfirmLatestSubscription();
|
|
56
87
|
|
|
57
|
-
const [step, setStep] = useState<MembershipCheckoutStep>("select");
|
|
88
|
+
const [step, setStep] = useState<MembershipCheckoutStep>(opts.initialTierId ? "select" : "tier");
|
|
58
89
|
const [selectedTierId, setSelectedTierId] = useState<string | undefined>(opts.initialTierId);
|
|
59
|
-
const [billingCycle,
|
|
90
|
+
const [billingCycle, setBillingCycleState] = useState<BillingCycle>("month");
|
|
60
91
|
const [customAmount, setCustomAmount] = useState<number>(0);
|
|
92
|
+
const [amountRefusal, setAmountRefusal] = useState<MembershipAmountRefusal | null>(null);
|
|
61
93
|
const [clientSecret, setClientSecret] = useState<string | undefined>();
|
|
62
94
|
/** The started Paystack checkout, kept so the modal can be re-opened. */
|
|
63
95
|
const [paystackSession, setPaystackSession] = useState<PaystackCheckoutSession | null>(null);
|
|
64
96
|
const [error, setError] = useState<string | null>(null);
|
|
65
97
|
|
|
66
|
-
const selectedTier = useMemo(
|
|
67
|
-
|
|
68
|
-
|
|
98
|
+
const selectedTier = useMemo(() => tiers?.find((t) => t.id === selectedTierId), [tiers, selectedTierId]);
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Seed the cycle and the amount box from the tier itself.
|
|
102
|
+
*
|
|
103
|
+
* This runs on the tier ARRIVING as much as on it being clicked: a page opened
|
|
104
|
+
* with `?membershipTierId=` has a selected id before the tier list has
|
|
105
|
+
* loaded, and without this the box would sit at 0 on a tier whose floor is 20.
|
|
106
|
+
*
|
|
107
|
+
* Keyed on the tier ID, not the tier OBJECT: the tier list is a React Query
|
|
108
|
+
* result, so a background refetch hands back an equal-but-new object, and
|
|
109
|
+
* keying on that would wipe the amount a fan had just typed.
|
|
110
|
+
*/
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
if (!selectedTier) return;
|
|
113
|
+
const cycle = defaultCycle(selectedTier);
|
|
114
|
+
setBillingCycleState(cycle);
|
|
115
|
+
setCustomAmount(defaultChosenAmount(selectedTier, cycle));
|
|
116
|
+
setAmountRefusal(null);
|
|
117
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
118
|
+
}, [selectedTier?.id]);
|
|
119
|
+
|
|
120
|
+
/** Switching cycle re-seeds the amount box: the floor belongs to the cycle. */
|
|
121
|
+
const setBillingCycle = useCallback(
|
|
122
|
+
(cycle: BillingCycle) => {
|
|
123
|
+
setBillingCycleState(cycle);
|
|
124
|
+
setAmountRefusal(null);
|
|
125
|
+
if (selectedTier) setCustomAmount(defaultChosenAmount(selectedTier, cycle));
|
|
126
|
+
},
|
|
127
|
+
[selectedTier],
|
|
69
128
|
);
|
|
70
129
|
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
130
|
+
const selectTier = useCallback((tier: MembershipTier) => {
|
|
131
|
+
setSelectedTierId(tier.id);
|
|
132
|
+
setError(null);
|
|
133
|
+
setAmountRefusal(null);
|
|
134
|
+
setStep("select");
|
|
135
|
+
}, []);
|
|
136
|
+
|
|
137
|
+
const backToTierList = useCallback(() => {
|
|
138
|
+
setSelectedTierId(undefined);
|
|
139
|
+
setError(null);
|
|
140
|
+
setAmountRefusal(null);
|
|
141
|
+
setStep("tier");
|
|
142
|
+
}, []);
|
|
143
|
+
|
|
144
|
+
const minimumAmount = selectedTier ? cycleFloor(selectedTier, billingCycle) : 0;
|
|
145
|
+
const maximumAmount = selectedTier ? cycleCeiling(selectedTier, billingCycle) : null;
|
|
146
|
+
const cycles = selectedTier ? offeredCycles(selectedTier) : { month: false, year: false };
|
|
147
|
+
const isFreeCycle = selectedTier ? cycleIsFree(selectedTier, billingCycle) : false;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* What will be charged, and what will be SENT. A fixed tier ignores the
|
|
151
|
+
* amount box, so a figure left over from a pay-what-you-want tier cannot leak
|
|
152
|
+
* into a fixed-price subscription.
|
|
153
|
+
*/
|
|
154
|
+
const amount = useMemo(
|
|
155
|
+
() => (selectedTier ? resolveSubscriptionAmount(selectedTier, billingCycle, customAmount) : 0),
|
|
156
|
+
[selectedTier, billingCycle, customAmount],
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const membership = user?.membership;
|
|
160
|
+
/** The tier they are on now, so its card can say so instead of offering itself. */
|
|
161
|
+
const currentTierId = membership?.membershipTierId;
|
|
162
|
+
/**
|
|
163
|
+
* A member with a live provider subscription picking a PAID tier is changing,
|
|
164
|
+
* not joining. The server settles it by proration on the subscription they
|
|
165
|
+
* already have, so the button says so.
|
|
166
|
+
*/
|
|
167
|
+
const isChange =
|
|
168
|
+
!!membership?.paymentProviderSubscriptionId && membership.status === "active" && !isFreeCycle && !!selectedTier;
|
|
169
|
+
|
|
170
|
+
const finish = (requiresConfirmation: boolean) => {
|
|
171
|
+
if (opts.onComplete) {
|
|
172
|
+
opts.onComplete({ requiresConfirmation });
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (typeof window !== "undefined") {
|
|
176
|
+
const origin = window.location.origin;
|
|
177
|
+
const path = requiresConfirmation
|
|
178
|
+
? (opts.returnPath ?? "/i/account?tab=membership&confirmSubscription=true")
|
|
179
|
+
: (opts.freeReturnPath ?? "/i/account?tab=membership");
|
|
180
|
+
window.location.href = `${origin}${path}`;
|
|
181
|
+
}
|
|
182
|
+
};
|
|
76
183
|
|
|
77
184
|
const subscribe = async () => {
|
|
78
185
|
setError(null);
|
|
186
|
+
setAmountRefusal(null);
|
|
79
187
|
if (!selectedTier) {
|
|
80
188
|
setError("Select a membership tier.");
|
|
81
189
|
return;
|
|
@@ -84,17 +192,22 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
|
|
|
84
192
|
setError("Please sign in to subscribe.");
|
|
85
193
|
return;
|
|
86
194
|
}
|
|
195
|
+
// Checked BEFORE the request, so a pay-what-you-want box below the floor
|
|
196
|
+
// (or still on its initial zero) is a message beside the field rather than
|
|
197
|
+
// an `amount_must_be_positive` 400 with nowhere to land.
|
|
198
|
+
const refusal = refuseMembershipAmount(selectedTier, billingCycle, customAmount);
|
|
199
|
+
if (refusal) {
|
|
200
|
+
setAmountRefusal(refusal);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
87
203
|
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
|
88
204
|
const returnUrl = `${origin}${opts.returnPath ?? "/i/account?tab=membership&confirmSubscription=true"}`;
|
|
89
205
|
const attributionRefId = readAttributionRef() ?? undefined;
|
|
90
206
|
const landing = readLanding() ?? {};
|
|
91
207
|
try {
|
|
92
|
-
if (
|
|
208
|
+
if (isFreeCycle) {
|
|
93
209
|
await createFree.mutateAsync({ membershipTierId: selectedTier.id, attributionRefId, ...landing });
|
|
94
|
-
|
|
95
|
-
else if (typeof window !== "undefined") {
|
|
96
|
-
window.location.href = `${origin}${opts.freeReturnPath ?? "/i/account?tab=membership"}`;
|
|
97
|
-
}
|
|
210
|
+
finish(false);
|
|
98
211
|
return;
|
|
99
212
|
}
|
|
100
213
|
const data = await createPaid.mutateAsync({
|
|
@@ -115,10 +228,7 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
|
|
|
115
228
|
// membership nobody has paid for.
|
|
116
229
|
const requiresPayment = data.requiresPayment ?? !!(data.clientSecret || data.accessCode || data.checkoutUrl);
|
|
117
230
|
if (!requiresPayment) {
|
|
118
|
-
|
|
119
|
-
else if (typeof window !== "undefined") {
|
|
120
|
-
window.location.href = `${origin}${opts.freeReturnPath ?? "/i/account?tab=membership"}`;
|
|
121
|
-
}
|
|
231
|
+
finish(false);
|
|
122
232
|
return;
|
|
123
233
|
}
|
|
124
234
|
|
|
@@ -156,10 +266,26 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
|
|
|
156
266
|
selectedTierId,
|
|
157
267
|
setSelectedTierId,
|
|
158
268
|
selectedTier,
|
|
269
|
+
selectTier,
|
|
270
|
+
backToTierList,
|
|
271
|
+
/** The tier the member is already on, so its card can be shown as taken. */
|
|
272
|
+
currentTierId,
|
|
273
|
+
/** True when pressing subscribe MOVES an existing subscription rather than starting one. */
|
|
274
|
+
isChange,
|
|
159
275
|
billingCycle,
|
|
160
276
|
setBillingCycle,
|
|
277
|
+
/** Which billing cycles this tier actually sells. */
|
|
278
|
+
cycles,
|
|
279
|
+
/** True when this cycle costs nothing, so `/subscriptions/free` is the call. */
|
|
280
|
+
isFreeCycle,
|
|
161
281
|
customAmount,
|
|
162
282
|
setCustomAmount,
|
|
283
|
+
/** The lowest amount this cycle may be bought at, in settlement-currency major units. */
|
|
284
|
+
minimumAmount,
|
|
285
|
+
/** The operator's ceiling for display, or null. Never applied to what is sent. */
|
|
286
|
+
maximumAmount,
|
|
287
|
+
/** Why the entered amount was refused, or null. Cleared on every edit path. */
|
|
288
|
+
amountRefusal,
|
|
163
289
|
amount,
|
|
164
290
|
subscribe,
|
|
165
291
|
clientSecret,
|
|
@@ -178,5 +304,7 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
|
|
|
178
304
|
error,
|
|
179
305
|
/** Call on the return page to reconcile the latest subscription. */
|
|
180
306
|
confirmLatest,
|
|
307
|
+
/** Signals a completed paid payment (the Stripe `onSucceeded` leg). */
|
|
308
|
+
finish,
|
|
181
309
|
};
|
|
182
310
|
}
|
package/src/ui/index.ts
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
// Forge UI — Tier-2 styled blocks (theme-aware, opinionated) + the theme
|
|
2
2
|
// contract. Built on the Tier-1 headless primitives in `forge/ui/headless`.
|
|
3
|
+
|
|
4
|
+
// Compiled Tailwind utilities for every className in Forge's source, generated
|
|
5
|
+
// by `npm run build:css` (see tailwind.input.css for why the consumer site's
|
|
6
|
+
// own Tailwind scan cannot be relied on). Bundlers deliver a side-effect CSS
|
|
7
|
+
// import from a library the same way the rich-text.css imports already ship.
|
|
8
|
+
// Compiled Tailwind utilities for every className in Forge's source, generated
|
|
9
|
+
// by `npm run build:css` (see tailwind.input.css for why the consumer site's
|
|
10
|
+
// own Tailwind scan cannot be relied on). Bundlers deliver a side-effect CSS
|
|
11
|
+
// import from a library the same way the rich-text.css imports already ship.
|
|
12
|
+
//
|
|
13
|
+
// This import MUST stay a side effect, and the reason is not stylistic. A site
|
|
14
|
+
// deployed before this shipped has a `styles.css` we never rewrite: Update
|
|
15
|
+
// Theme repins Forge's version and touches nothing else. So an explicit
|
|
16
|
+
// `@import` in the starter reaches NEW sites only, and every existing site
|
|
17
|
+
// would silently lose its Forge layouts the moment it repinned. Delivering
|
|
18
|
+
// from the package is the only route that reaches a site nobody edits.
|
|
19
|
+
//
|
|
20
|
+
// The cost is that it reaches every consumer, including our own apps, and the
|
|
21
|
+
// file is not shy: `@layer theme` redefines 23 root design tokens and
|
|
22
|
+
// `@layer properties` resets 43 `--tw-*` on the universal selector. That made
|
|
23
|
+
// the admin dashboard sidebar disappear (2026-08-22). Admin therefore aliases
|
|
24
|
+
// this file to an empty stub in its vite config and `@source`s Forge's source
|
|
25
|
+
// instead. A monorepo app that renders Forge components should do the same.
|
|
26
|
+
import "./styled/forge-utilities.css";
|
|
27
|
+
|
|
3
28
|
export {
|
|
4
29
|
ForgeThemeProvider,
|
|
5
30
|
useForgeTheme,
|
|
@@ -153,6 +178,24 @@ export { PodcastList, type PodcastListProps } from "./styled/PodcastList";
|
|
|
153
178
|
export { PodcastShow, type PodcastShowProps } from "./styled/PodcastShow";
|
|
154
179
|
export { PodcastEpisode, type PodcastEpisodeProps } from "./styled/PodcastEpisode";
|
|
155
180
|
export { ReplayList, type ReplayListProps } from "./styled/ReplayList";
|
|
181
|
+
// Live broadcasts. A replay is the recording of one of these; this is the half
|
|
182
|
+
// that is on now. `BroadcastWatch` carries the paywall.
|
|
183
|
+
export { LiveBroadcastList, type LiveBroadcastListProps } from "./styled/LiveBroadcastList";
|
|
184
|
+
export { BroadcastWatch, type BroadcastWatchProps } from "./styled/BroadcastWatch";
|
|
185
|
+
export { EndedBroadcast, type EndedBroadcastProps } from "./styled/broadcast/EndedBroadcast";
|
|
186
|
+
export {
|
|
187
|
+
BroadcastPassValidation,
|
|
188
|
+
type BroadcastPassValidationProps,
|
|
189
|
+
} from "./styled/broadcast/BroadcastPassValidation";
|
|
190
|
+
export {
|
|
191
|
+
BroadcastTicketPurchase,
|
|
192
|
+
type BroadcastTicketPurchaseProps,
|
|
193
|
+
} from "./styled/broadcast/BroadcastTicketPurchase";
|
|
194
|
+
export {
|
|
195
|
+
BroadcastPlayer,
|
|
196
|
+
type BroadcastPlayerProps,
|
|
197
|
+
type BroadcastPlayerRenderProps,
|
|
198
|
+
} from "./styled/broadcast/BroadcastPlayer";
|
|
156
199
|
export { ConfirmSubscription, type ConfirmSubscriptionProps } from "./styled/ConfirmSubscription";
|
|
157
200
|
export { LeadMagnet, type LeadMagnetProps } from "./styled/LeadMagnet";
|
|
158
201
|
export { CohortPage, type CohortPageProps } from "./styled/CohortPage";
|
|
@@ -177,6 +220,24 @@ export {
|
|
|
177
220
|
resolveUnitPrice,
|
|
178
221
|
ticketSubtotals,
|
|
179
222
|
} from "./format/pwyw";
|
|
223
|
+
// Membership pricing: which billing cycles a tier sells, a pay-what-you-want
|
|
224
|
+
// cycle's floor, and the ONE number that may be sent to the subscriptions API.
|
|
225
|
+
// Settlement-currency MAJOR units throughout: convert for the screen, never for
|
|
226
|
+
// the request.
|
|
227
|
+
export {
|
|
228
|
+
cycleFloor,
|
|
229
|
+
cycleCeiling,
|
|
230
|
+
cycleIsOffered,
|
|
231
|
+
cycleIsFree,
|
|
232
|
+
offeredCycles,
|
|
233
|
+
defaultCycle,
|
|
234
|
+
defaultChosenAmount,
|
|
235
|
+
clampMembershipAmount,
|
|
236
|
+
resolveSubscriptionAmount,
|
|
237
|
+
refuseMembershipAmount,
|
|
238
|
+
type PricedTier,
|
|
239
|
+
type MembershipAmountRefusal,
|
|
240
|
+
} from "./format/membershipPwyw";
|
|
180
241
|
// When a tier may be BOUGHT, for a site rendering its own ticket UI. Both halves
|
|
181
242
|
// close a "discoverable only by failing" journey: `ticketSeatsAvailable`
|
|
182
243
|
// subtracts `held` the way the server does, so a fan is never shown seats that
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import { CallStage } from "./CallStage";
|
|
4
|
+
import { BookingCallProvider } from "./bookingSession";
|
|
5
|
+
|
|
6
|
+
export type BookingCallScreenProps = {
|
|
7
|
+
bookingId: string;
|
|
8
|
+
/** Shown as the call's title: the product the session was bought on. */
|
|
9
|
+
title: string;
|
|
10
|
+
/** Leave. The caller unmounts this subtree, which is what ends the call. */
|
|
11
|
+
onLeave: () => void;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* One booking's call: the provider wired to the credential endpoint, and the
|
|
16
|
+
* one call UI inside it.
|
|
17
|
+
*
|
|
18
|
+
* A DEFAULT export on purpose. `AccountDashboard` (in the `./ui` entry every
|
|
19
|
+
* site loads) reaches this through `React.lazy(() => import(...))`, so the
|
|
20
|
+
* media SDK and mediasoup are fetched by the browser only when somebody
|
|
21
|
+
* actually presses Join, rather than by every visitor to every account page.
|
|
22
|
+
* The provider connects on mount and closes on unmount, so the caller's
|
|
23
|
+
* "joined" state is exactly the call's lifetime: leaving goes through state,
|
|
24
|
+
* never through a `close()` that leaves a live provider mounted to reconnect
|
|
25
|
+
* on its next render.
|
|
26
|
+
*/
|
|
27
|
+
export default function BookingCallScreen({ bookingId, title, onLeave }: BookingCallScreenProps): ReactNode {
|
|
28
|
+
return (
|
|
29
|
+
<BookingCallProvider bookingId={bookingId}>
|
|
30
|
+
<CallStage title={title} onLeave={onLeave} />
|
|
31
|
+
</BookingCallProvider>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { useId, useState, type ReactNode } from "react";
|
|
2
|
+
import { HelpCircle } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The `?` that carries an explanation, instead of a paragraph that always shows.
|
|
8
|
+
*
|
|
9
|
+
* House design law: if a piece of text exists to EXPLAIN something, it goes
|
|
10
|
+
* behind a `?`. A call screen is the worst possible place to break that rule,
|
|
11
|
+
* because it is looked at while something is happening and every sentence that
|
|
12
|
+
* is not about right now is a sentence in the way.
|
|
13
|
+
*
|
|
14
|
+
* ## What is NOT help, and stays visible on this surface
|
|
15
|
+
*
|
|
16
|
+
* - the connection state, and what went wrong when it went wrong
|
|
17
|
+
* - a denied camera or microphone permission
|
|
18
|
+
* - the recording indicator, which is a consent signal
|
|
19
|
+
*
|
|
20
|
+
* ## Why Forge grows its own rather than importing `HelpHint`
|
|
21
|
+
*
|
|
22
|
+
* `@tribe-nest/frontend-shared` is not a dependency of this package and cannot
|
|
23
|
+
* become one: Forge publishes raw source and a creator site installs only
|
|
24
|
+
* Forge's declared dependencies, so an import of it would resolve here (npm
|
|
25
|
+
* workspaces symlink everything) and fail on every real site. See
|
|
26
|
+
* `_tests/publishedResolvability.spec.ts`. This is a disclosure built from a
|
|
27
|
+
* button and a span, which needs nothing.
|
|
28
|
+
*/
|
|
29
|
+
export type CallHelpHintProps = {
|
|
30
|
+
/** Accessible name. Say what it explains ("What does sharing send?"), never "help". */
|
|
31
|
+
label: string;
|
|
32
|
+
children: ReactNode;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function CallHelpHint({ label, children }: CallHelpHintProps) {
|
|
36
|
+
const tokens = useThemeTokens();
|
|
37
|
+
const [open, setOpen] = useState(false);
|
|
38
|
+
const panelId = useId();
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<span style={{ position: "relative", display: "inline-flex", verticalAlign: "middle" }}>
|
|
42
|
+
<button
|
|
43
|
+
type="button"
|
|
44
|
+
aria-label={label}
|
|
45
|
+
aria-expanded={open}
|
|
46
|
+
aria-controls={panelId}
|
|
47
|
+
onClick={() => setOpen((was) => !was)}
|
|
48
|
+
style={{
|
|
49
|
+
display: "inline-flex",
|
|
50
|
+
alignItems: "center",
|
|
51
|
+
justifyContent: "center",
|
|
52
|
+
background: "transparent",
|
|
53
|
+
border: "none",
|
|
54
|
+
padding: 0,
|
|
55
|
+
cursor: "pointer",
|
|
56
|
+
color: tokens.muted,
|
|
57
|
+
}}
|
|
58
|
+
>
|
|
59
|
+
<HelpCircle width={14} height={14} aria-hidden="true" />
|
|
60
|
+
</button>
|
|
61
|
+
{open && (
|
|
62
|
+
<span
|
|
63
|
+
id={panelId}
|
|
64
|
+
role="note"
|
|
65
|
+
style={{
|
|
66
|
+
position: "absolute",
|
|
67
|
+
bottom: "calc(100% + 6px)",
|
|
68
|
+
left: 0,
|
|
69
|
+
zIndex: 20,
|
|
70
|
+
width: "16rem",
|
|
71
|
+
padding: "0.5rem 0.625rem",
|
|
72
|
+
borderRadius: tokens.cornerRadius,
|
|
73
|
+
border: `1px solid ${tokens.border}`,
|
|
74
|
+
backgroundColor: tokens.surface,
|
|
75
|
+
color: tokens.text,
|
|
76
|
+
fontSize: "0.75rem",
|
|
77
|
+
lineHeight: 1.5,
|
|
78
|
+
textAlign: "left",
|
|
79
|
+
fontWeight: 400,
|
|
80
|
+
}}
|
|
81
|
+
>
|
|
82
|
+
{children}
|
|
83
|
+
</span>
|
|
84
|
+
)}
|
|
85
|
+
</span>
|
|
86
|
+
);
|
|
87
|
+
}
|