@tribe-nest/forge 3.29.0 → 3.31.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 (51) hide show
  1. package/package.json +6 -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/PublicAuthContext.tsx +34 -5
  6. package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
  7. package/src/data/queries/useBroadcasts.ts +151 -0
  8. package/src/data/queries/useMyBookings.ts +9 -1
  9. package/src/i18n/de.json +59 -0
  10. package/src/i18n/en.json +59 -0
  11. package/src/ui/format/_tests/membershipPwyw.spec.ts +185 -0
  12. package/src/ui/format/_tests/pwyw.spec.ts +65 -8
  13. package/src/ui/format/membershipPwyw.ts +164 -0
  14. package/src/ui/format/pwyw.ts +37 -0
  15. package/src/ui/headless/broadcast/_tests/broadcastState.spec.ts +235 -0
  16. package/src/ui/headless/broadcast/broadcastState.ts +158 -0
  17. package/src/ui/headless/broadcast/useBroadcastWatch.ts +174 -21
  18. package/src/ui/headless/event/useEventCheckout.ts +8 -13
  19. package/src/ui/headless/index.ts +14 -0
  20. package/src/ui/headless/membership/useMembershipCheckout.ts +160 -32
  21. package/src/ui/index.ts +36 -0
  22. package/src/ui/media/CallHelpHint.tsx +87 -0
  23. package/src/ui/media/CallStage.tsx +542 -0
  24. package/src/ui/media/_tests/CallStage.spec.tsx +685 -0
  25. package/src/ui/media/_tests/bookingSession.spec.tsx +179 -0
  26. package/src/ui/media/_tests/callState.spec.ts +452 -0
  27. package/src/ui/media/_tests/fakeNode.ts +178 -0
  28. package/src/ui/media/bookingSession.tsx +194 -0
  29. package/src/ui/media/callState.ts +341 -0
  30. package/src/ui/media/index.ts +135 -0
  31. package/src/ui/styled/AccountDashboard.tsx +92 -3
  32. package/src/ui/styled/BroadcastWatch.tsx +107 -0
  33. package/src/ui/styled/ForgotPasswordForm.tsx +5 -0
  34. package/src/ui/styled/LiveBroadcastList.tsx +171 -0
  35. package/src/ui/styled/LoginForm.tsx +10 -0
  36. package/src/ui/styled/MembershipCheckout.tsx +318 -45
  37. package/src/ui/styled/MembershipTiers.tsx +10 -3
  38. package/src/ui/styled/ResetPasswordForm.tsx +5 -0
  39. package/src/ui/styled/SignupForm.tsx +5 -0
  40. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +134 -0
  41. package/src/ui/styled/_tests/BroadcastPassValidation.spec.tsx +125 -0
  42. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +364 -0
  43. package/src/ui/styled/broadcast/BroadcastPassValidation.tsx +187 -0
  44. package/src/ui/styled/broadcast/BroadcastPlayer.tsx +536 -0
  45. package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +74 -0
  46. package/src/ui/styled/broadcast/EndedBroadcast.tsx +103 -0
  47. package/src/ui/styled/community/CommunityComposer.tsx +182 -3
  48. package/src/ui/styled/community/CommunityFeed.tsx +36 -51
  49. package/src/ui/styled/community/CommunityPostDetail.tsx +16 -2
  50. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +281 -0
  51. package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +175 -0
@@ -0,0 +1,107 @@
1
+ import type { ReactNode } from "react";
2
+ import { X } from "lucide-react";
3
+ import { useForgeT } from "../../i18n";
4
+ import { useForgeTheme } from "../theme/ForgeThemeProvider";
5
+ import { useBroadcastWatch } from "../headless/broadcast/useBroadcastWatch";
6
+ import { Loading } from "./Loading";
7
+ import { BroadcastPassValidation } from "./broadcast/BroadcastPassValidation";
8
+ import { BroadcastTicketPurchase } from "./broadcast/BroadcastTicketPurchase";
9
+ import { BroadcastPlayer, type BroadcastPlayerRenderProps } from "./broadcast/BroadcastPlayer";
10
+ import { EndedBroadcast } from "./broadcast/EndedBroadcast";
11
+
12
+ export interface BroadcastWatchProps {
13
+ broadcastId: string;
14
+ /** Host-owned navigation. Falls back to a full page load. */
15
+ navigate?: (href: string) => void;
16
+ /** Where leaving lands. Default `/i/live`. */
17
+ livePath?: string;
18
+ /** How to play the HLS stream. See `BroadcastPlayer` for why this is a prop. */
19
+ renderPlayer?: (props: BroadcastPlayerRenderProps) => ReactNode;
20
+ /** Format a numeric amount for display (the "from ..." line on the paywall). */
21
+ formatAmount?: (amount: number) => string;
22
+ /** Stripe return URL for a PAID ticket purchase made from the paywall. */
23
+ finalisePath?: (slug: string, orderId: string) => string;
24
+ /** Host-owned navigation on FREE ticket completion. */
25
+ onComplete?: (info: { slug: string; orderId: string }) => void;
26
+ }
27
+
28
+ /**
29
+ * The watch page for one live broadcast.
30
+ *
31
+ * What is on screen is decided entirely by `useBroadcastWatch().screen`, which
32
+ * is computed by a pure function. The order those states are checked in is the
33
+ * whole safety property of this page: a broadcast that has ENDED shows the ended
34
+ * screen, a broadcast that is GATED shows the ticket box, and only a viewer past
35
+ * both gets a component that knows the playback URL.
36
+ */
37
+ export function BroadcastWatch({
38
+ broadcastId,
39
+ navigate,
40
+ livePath = "/i/live",
41
+ renderPlayer,
42
+ formatAmount,
43
+ finalisePath,
44
+ onComplete,
45
+ }: BroadcastWatchProps) {
46
+ const t = useForgeT();
47
+ const theme = useForgeTheme();
48
+ const watch = useBroadcastWatch(broadcastId);
49
+ const { broadcast, screen } = watch;
50
+
51
+ const leave = () => {
52
+ watch.leave();
53
+ if (navigate) navigate(livePath);
54
+ else if (typeof window !== "undefined") window.location.assign(livePath);
55
+ };
56
+
57
+ return (
58
+ <div
59
+ style={{
60
+ display: "flex",
61
+ flexDirection: "column",
62
+ flex: 1,
63
+ color: theme.colors.text,
64
+ fontFamily: theme.fontFamily,
65
+ }}
66
+ >
67
+ <div style={{ padding: "0 16px 8px" }}>
68
+ <button
69
+ type="button"
70
+ onClick={leave}
71
+ data-testid="broadcast-leave"
72
+ aria-label={t("forge.broadcast_watch.leave")}
73
+ style={{ background: "transparent", border: "none", padding: 0, cursor: "pointer" }}
74
+ >
75
+ <X size={28} color={theme.colors.primary} />
76
+ </button>
77
+ </div>
78
+
79
+ {screen.showLoading && <Loading fullPage />}
80
+
81
+ {screen.view === "error" && <div data-testid="broadcast-load-error">{t("forge.broadcast_watch.load_error")}</div>}
82
+
83
+ {screen.view === "ended" && broadcast && <EndedBroadcast broadcast={broadcast} />}
84
+
85
+ {screen.view === "player" && broadcast && watch.pass && (
86
+ <BroadcastPlayer broadcast={broadcast} broadcastPass={watch.pass} renderPlayer={renderPlayer} />
87
+ )}
88
+
89
+ {screen.view === "pass" && broadcast && (
90
+ <BroadcastPassValidation
91
+ broadcast={broadcast}
92
+ onValidate={(ticketCode) => watch.validate(ticketCode).catch(() => undefined)}
93
+ isValidating={watch.isValidating}
94
+ error={watch.error}
95
+ buyTickets={
96
+ <BroadcastTicketPurchase
97
+ broadcast={broadcast}
98
+ formatAmount={formatAmount}
99
+ finalisePath={finalisePath}
100
+ onComplete={onComplete}
101
+ />
102
+ }
103
+ />
104
+ )}
105
+ </div>
106
+ );
107
+ }
@@ -21,6 +21,11 @@ export function ForgotPasswordForm({ loginHref }: ForgotPasswordFormProps) {
21
21
  const [isSuccess, setIsSuccess] = useState(false);
22
22
 
23
23
  const card: React.CSSProperties = {
24
+ // See `LoginForm`. The host layout is a COLUMN flex container, and an auto
25
+ // cross-axis margin disables `align-items: stretch`, so `maxWidth` alone
26
+ // leaves the card shrink-to-fit. The cap is unchanged: this fills the
27
+ // container UP TO 420 and no further.
28
+ width: "100%",
24
29
  maxWidth: 420,
25
30
  margin: "40px auto 0",
26
31
  padding: 24,
@@ -0,0 +1,171 @@
1
+ import { Calendar, Play } from "lucide-react";
2
+ import { useLiveBroadcasts } from "../../data/queries/useBroadcasts";
3
+ import type { ILiveBroadcast } from "../../types/models";
4
+ import { formatDateTime } from "../../utils/formatDateTime";
5
+ import { useForgeT } from "../../i18n";
6
+ import { useForgeTheme } from "../theme/ForgeThemeProvider";
7
+ import { broadcastListStatus } from "../headless/broadcast/broadcastState";
8
+ import { Loading } from "./Loading";
9
+
10
+ export interface LiveBroadcastListProps {
11
+ /** Host-owned navigation. Falls back to a full page load. */
12
+ navigate?: (href: string) => void;
13
+ /** Where a card leads. Default `/i/live/:broadcastId`. */
14
+ broadcastPath?: (broadcastId: string) => string;
15
+ }
16
+
17
+ /**
18
+ * What is on now: the tenant's live, upcoming and just-ended broadcasts.
19
+ *
20
+ * The list itself is public. The paywall is on the WATCH page, where a gated
21
+ * broadcast asks for a ticket before a single frame is mounted, so a card here
22
+ * gives away nothing but a title and a start time.
23
+ */
24
+ export function LiveBroadcastList({ navigate, broadcastPath }: LiveBroadcastListProps) {
25
+ const t = useForgeT();
26
+ const theme = useForgeTheme();
27
+ const { data: broadcasts, isLoading, error } = useLiveBroadcasts();
28
+
29
+ const href = (id: string) => (broadcastPath ? broadcastPath(id) : `/i/live/${id}`);
30
+ const go = (id: string) => {
31
+ const path = href(id);
32
+ if (navigate) navigate(path);
33
+ else if (typeof window !== "undefined") window.location.assign(path);
34
+ };
35
+
36
+ const centered = (title: string, body: string) => (
37
+ <div
38
+ style={{
39
+ display: "flex",
40
+ alignItems: "center",
41
+ justifyContent: "center",
42
+ minHeight: 400,
43
+ color: theme.colors.text,
44
+ fontFamily: theme.fontFamily,
45
+ }}
46
+ >
47
+ <div style={{ textAlign: "center" }}>
48
+ <h3 style={{ fontSize: 18, fontWeight: 600, marginBottom: 8 }}>{title}</h3>
49
+ <p style={{ opacity: 0.8 }}>{body}</p>
50
+ </div>
51
+ </div>
52
+ );
53
+
54
+ if (isLoading) return <Loading fullPage />;
55
+
56
+ if (error) {
57
+ return centered(t("forge.live_broadcast_list.error_title"), t("forge.live_broadcast_list.error_body"));
58
+ }
59
+
60
+ if (!broadcasts || broadcasts.length === 0) {
61
+ return centered(t("forge.live_broadcast_list.empty_title"), t("forge.live_broadcast_list.empty_body"));
62
+ }
63
+
64
+ return (
65
+ <div
66
+ style={{
67
+ width: "100%",
68
+ padding: "24px 16px",
69
+ backgroundColor: theme.colors.background,
70
+ color: theme.colors.text,
71
+ fontFamily: theme.fontFamily,
72
+ }}
73
+ >
74
+ <div style={{ maxWidth: 1280, margin: "0 auto" }}>
75
+ <div style={{ marginBottom: 32 }}>
76
+ <h1 style={{ fontSize: 32, fontWeight: 700, marginBottom: 8, fontFamily: theme.headingFontFamily }}>
77
+ {t("forge.live_broadcast_list.title")}
78
+ </h1>
79
+ <p style={{ opacity: 0.8 }}>{t("forge.live_broadcast_list.subtitle")}</p>
80
+ </div>
81
+
82
+ <div
83
+ style={{
84
+ display: "grid",
85
+ gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
86
+ gap: 24,
87
+ }}
88
+ >
89
+ {broadcasts.map((broadcast: ILiveBroadcast) => {
90
+ const status = broadcastListStatus(broadcast);
91
+ const thumb = broadcast.thumbnailUrl || broadcast.generatedThumbnailUrl;
92
+
93
+ return (
94
+ <button
95
+ key={broadcast.id}
96
+ type="button"
97
+ onClick={() => go(broadcast.id)}
98
+ data-testid="live-broadcast-card"
99
+ style={{
100
+ textAlign: "left",
101
+ padding: 0,
102
+ cursor: "pointer",
103
+ overflow: "hidden",
104
+ backgroundColor: theme.colors.background,
105
+ color: theme.colors.text,
106
+ borderRadius: theme.cornerRadius,
107
+ boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)",
108
+ border: `1px solid ${theme.colors.primary}30`,
109
+ }}
110
+ >
111
+ <div style={{ position: "relative" }}>
112
+ <div
113
+ style={{
114
+ aspectRatio: "16/9",
115
+ display: "flex",
116
+ alignItems: "center",
117
+ justifyContent: "center",
118
+ position: "relative",
119
+ background: `linear-gradient(135deg, ${theme.colors.primary}20, ${theme.colors.primary}10)`,
120
+ }}
121
+ >
122
+ {thumb && (
123
+ <img
124
+ src={thumb}
125
+ alt={broadcast.title}
126
+ style={{
127
+ width: "100%",
128
+ height: "100%",
129
+ objectFit: "cover",
130
+ position: "absolute",
131
+ top: 0,
132
+ left: 0,
133
+ }}
134
+ />
135
+ )}
136
+ <Play size={40} style={{ color: theme.colors.primary, position: "relative", zIndex: 1 }} />
137
+ </div>
138
+ <div
139
+ style={{
140
+ position: "absolute",
141
+ top: 8,
142
+ left: 8,
143
+ padding: "4px 8px",
144
+ borderRadius: 999,
145
+ fontSize: 12,
146
+ fontWeight: 600,
147
+ background: status.background,
148
+ color: status.color,
149
+ }}
150
+ >
151
+ {status.text}
152
+ </div>
153
+ </div>
154
+
155
+ <div style={{ padding: 16 }}>
156
+ <h3 style={{ fontWeight: 600, marginBottom: 8 }}>{broadcast.title}</h3>
157
+ {broadcast.startDate && (
158
+ <div style={{ display: "flex", alignItems: "center", gap: 4, fontSize: 13, opacity: 0.8 }}>
159
+ <Calendar size={14} />
160
+ <span>{formatDateTime(broadcast.startDate)}</span>
161
+ </div>
162
+ )}
163
+ </div>
164
+ </button>
165
+ );
166
+ })}
167
+ </div>
168
+ </div>
169
+ </div>
170
+ );
171
+ }
@@ -18,6 +18,16 @@ export function LoginForm({ onSuccess, signupHref, forgotHref }: LoginFormProps)
18
18
  const flow = useLoginFlow({ onSuccess });
19
19
 
20
20
  const card: React.CSSProperties = {
21
+ // `width: 100%` is what stops this collapsing to its content.
22
+ //
23
+ // The host layout is a COLUMN flex container, and an auto cross-axis margin
24
+ // disables `align-items: stretch`. So `maxWidth` alone left the card
25
+ // shrink-to-fit and the auto margins centred whatever that came to. Sign-in
26
+ // has the least content of the auth forms (two fields, short labels), so it
27
+ // collapsed the furthest and looked broken next to signup.
28
+ //
29
+ // The cap is unchanged: this fills the container UP TO 420 and no further.
30
+ width: "100%",
21
31
  maxWidth: 420,
22
32
  margin: "40px auto 0",
23
33
  padding: 24,