@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.
- package/package.json +6 -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/PublicAuthContext.tsx +34 -5
- package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
- package/src/data/queries/useBroadcasts.ts +151 -0
- package/src/data/queries/useMyBookings.ts +9 -1
- package/src/i18n/de.json +59 -0
- package/src/i18n/en.json +59 -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 +36 -0
- package/src/ui/media/CallHelpHint.tsx +87 -0
- package/src/ui/media/CallStage.tsx +542 -0
- package/src/ui/media/_tests/CallStage.spec.tsx +685 -0
- package/src/ui/media/_tests/bookingSession.spec.tsx +179 -0
- package/src/ui/media/_tests/callState.spec.ts +452 -0
- package/src/ui/media/_tests/fakeNode.ts +178 -0
- package/src/ui/media/bookingSession.tsx +194 -0
- package/src/ui/media/callState.ts +341 -0
- package/src/ui/media/index.ts +135 -0
- package/src/ui/styled/AccountDashboard.tsx +92 -3
- 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/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
|
@@ -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,
|