@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.
Files changed (58) hide show
  1. package/package.json +11 -3
  2. package/src/_tests/publishedResolvability.spec.ts +184 -0
  3. package/src/_tests/specsRunWorkspaceSource.spec.ts +116 -0
  4. package/src/_tests/workspaceAliases.ts +40 -0
  5. package/src/contexts/CartContext.tsx +17 -1
  6. package/src/contexts/PublicAuthContext.tsx +34 -5
  7. package/src/contexts/_tests/CartContext.spec.tsx +36 -0
  8. package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
  9. package/src/data/queries/useBroadcasts.ts +151 -0
  10. package/src/data/queries/useMyBookings.ts +18 -1
  11. package/src/i18n/_tests/translationKeys.spec.ts +15 -0
  12. package/src/i18n/de.json +97 -0
  13. package/src/i18n/en.json +97 -0
  14. package/src/ui/format/_tests/membershipPwyw.spec.ts +185 -0
  15. package/src/ui/format/_tests/pwyw.spec.ts +65 -8
  16. package/src/ui/format/membershipPwyw.ts +164 -0
  17. package/src/ui/format/pwyw.ts +37 -0
  18. package/src/ui/headless/broadcast/_tests/broadcastState.spec.ts +235 -0
  19. package/src/ui/headless/broadcast/broadcastState.ts +158 -0
  20. package/src/ui/headless/broadcast/useBroadcastWatch.ts +174 -21
  21. package/src/ui/headless/event/useEventCheckout.ts +8 -13
  22. package/src/ui/headless/index.ts +14 -0
  23. package/src/ui/headless/membership/useMembershipCheckout.ts +160 -32
  24. package/src/ui/index.ts +61 -0
  25. package/src/ui/media/BookingCallScreen.tsx +33 -0
  26. package/src/ui/media/CallHelpHint.tsx +87 -0
  27. package/src/ui/media/CallStage.tsx +633 -0
  28. package/src/ui/media/_tests/CallStage.spec.tsx +931 -0
  29. package/src/ui/media/_tests/bookingSession.spec.tsx +227 -0
  30. package/src/ui/media/_tests/callState.spec.ts +499 -0
  31. package/src/ui/media/_tests/fakeNode.ts +178 -0
  32. package/src/ui/media/bookingSession.tsx +182 -0
  33. package/src/ui/media/bookingWindow.ts +81 -0
  34. package/src/ui/media/callState.ts +360 -0
  35. package/src/ui/media/index.ts +135 -0
  36. package/src/ui/styled/AccountDashboard.tsx +193 -4
  37. package/src/ui/styled/BroadcastWatch.tsx +107 -0
  38. package/src/ui/styled/ForgotPasswordForm.tsx +5 -0
  39. package/src/ui/styled/LiveBroadcastList.tsx +171 -0
  40. package/src/ui/styled/LoginForm.tsx +10 -0
  41. package/src/ui/styled/MembershipCheckout.tsx +318 -45
  42. package/src/ui/styled/MembershipTiers.tsx +10 -3
  43. package/src/ui/styled/ResetPasswordForm.tsx +5 -0
  44. package/src/ui/styled/SignupForm.tsx +5 -0
  45. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +166 -0
  46. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +134 -0
  47. package/src/ui/styled/_tests/BroadcastPassValidation.spec.tsx +125 -0
  48. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +364 -0
  49. package/src/ui/styled/broadcast/BroadcastPassValidation.tsx +187 -0
  50. package/src/ui/styled/broadcast/BroadcastPlayer.tsx +536 -0
  51. package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +74 -0
  52. package/src/ui/styled/broadcast/EndedBroadcast.tsx +103 -0
  53. package/src/ui/styled/community/CommunityComposer.tsx +182 -3
  54. package/src/ui/styled/community/CommunityFeed.tsx +36 -51
  55. package/src/ui/styled/community/CommunityPostDetail.tsx +16 -2
  56. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +281 -0
  57. package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +175 -0
  58. 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
- export type MembershipCheckoutStep = "select" | "payment";
19
- export type BillingCycle = "month" | "year";
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
- /** Called when a FREE activation completes in-app — the host navigates however
29
- * it wants, instead of Forge doing a `window.location` redirect. */
30
- onComplete?: () => void;
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 free activation OR paid Stripe
41
- * subscription. Composes `useGetMembershipTiers`, `useCreateFreeSubscription`,
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 it is
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, setBillingCycle] = useState<BillingCycle>("month");
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
- () => tiers?.find((t) => t.id === selectedTierId),
68
- [tiers, selectedTierId],
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 amount = useMemo(() => {
72
- if (!selectedTier) return 0;
73
- if (selectedTier.payWhatYouWant) return customAmount;
74
- return (billingCycle === "month" ? selectedTier.priceMonthly : selectedTier.priceYearly) ?? 0;
75
- }, [selectedTier, billingCycle, customAmount]);
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 (tierIsFree(selectedTier, billingCycle)) {
208
+ if (isFreeCycle) {
93
209
  await createFree.mutateAsync({ membershipTierId: selectedTier.id, attributionRefId, ...landing });
94
- if (opts.onComplete) opts.onComplete();
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
- if (opts.onComplete) opts.onComplete();
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
+ }