@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
@@ -29,3 +29,154 @@ export function useValidateBroadcastPass() {
29
29
  },
30
30
  });
31
31
  }
32
+
33
+ /**
34
+ * ONE broadcast, by id.
35
+ *
36
+ * Not the same thing as picking the id out of `useLiveBroadcasts()`: the list is
37
+ * what is on now, and a broadcast that has ENDED still has to render its own
38
+ * page (the "broadcast ended, keep this tab open" screen). Reading the detail
39
+ * endpoint is what makes that page survive the end of the stream.
40
+ */
41
+ export function useLiveBroadcast(broadcastId?: string) {
42
+ const { client, profileId } = useForge();
43
+
44
+ return useQuery<ILiveBroadcast>({
45
+ queryKey: ["live-broadcast", profileId, broadcastId],
46
+ queryFn: async () => {
47
+ const res = await client.get(`/public/broadcasts/${broadcastId}`, {
48
+ params: { profileId },
49
+ });
50
+ return res.data;
51
+ },
52
+ enabled: !!profileId && !!client && !!broadcastId,
53
+ });
54
+ }
55
+
56
+ /**
57
+ * The same broadcast, re-read on an interval, purely to notice that it ended.
58
+ *
59
+ * A separate query from `useLiveBroadcast` on purpose. The page keeps rendering
60
+ * the broadcast it opened with, and only the END is allowed to come from the
61
+ * poll, so a mid-stream edit to the title or the thumbnail cannot swap the
62
+ * player out from under someone who is watching.
63
+ */
64
+ export function useLiveBroadcastPoll(broadcastId?: string, intervalMs = 5000) {
65
+ const { client, profileId } = useForge();
66
+
67
+ return useQuery<ILiveBroadcast>({
68
+ queryKey: ["live-broadcast-poll", profileId, broadcastId],
69
+ queryFn: async () => {
70
+ const res = await client.get(`/public/broadcasts/${broadcastId}`, {
71
+ params: { profileId },
72
+ });
73
+ return res.data;
74
+ },
75
+ enabled: !!profileId && !!client && !!broadcastId,
76
+ refetchInterval: intervalMs,
77
+ });
78
+ }
79
+
80
+ /** How many people are watching right now. Polled while the broadcast is on. */
81
+ export function useBroadcastAudience(broadcastId?: string, enabled = true, intervalMs = 3000) {
82
+ const { client, profileId } = useForge();
83
+
84
+ return useQuery<{ count: number }>({
85
+ queryKey: ["broadcast-audience", broadcastId],
86
+ queryFn: async () => {
87
+ const res = await client.get(`/public/broadcasts/${broadcastId}/audience`, {
88
+ params: { profileId },
89
+ });
90
+ return res.data;
91
+ },
92
+ enabled: !!profileId && !!client && !!broadcastId && enabled,
93
+ refetchInterval: intervalMs,
94
+ });
95
+ }
96
+
97
+ /** Which chat surface a broadcast comment arrived from. */
98
+ export const BroadcastChannelProvider = {
99
+ Youtube: "youtube",
100
+ Twitch: "twitch",
101
+ Website: "website",
102
+ CustomRTMP: "custom_rtmp",
103
+ } as const;
104
+
105
+ export type BroadcastChannelProviderValue =
106
+ (typeof BroadcastChannelProvider)[keyof typeof BroadcastChannelProvider];
107
+
108
+ /** One message in a broadcast's live chat. */
109
+ export type BroadcastComment = {
110
+ id: string;
111
+ name: string;
112
+ content: string;
113
+ publishedAt: string;
114
+ channelProvider: BroadcastChannelProviderValue;
115
+ };
116
+
117
+ /** The chat backlog a viewer sees when they join. Realtime arrives over the socket. */
118
+ export function useBroadcastComments(broadcastId?: string) {
119
+ const { client, profileId } = useForge();
120
+
121
+ return useQuery<BroadcastComment[]>({
122
+ queryKey: ["broadcast-comments", profileId, broadcastId],
123
+ queryFn: async () => {
124
+ const res = await client.get(`/public/broadcasts/${broadcastId}/comments`, {
125
+ params: { profileId },
126
+ });
127
+ return res.data;
128
+ },
129
+ enabled: !!profileId && !!client && !!broadcastId,
130
+ });
131
+ }
132
+
133
+ /** The offer/answer exchange that subscribes a viewer to a WebRTC broadcast. */
134
+ export type BroadcastSubscribeAnswer = {
135
+ requiresImmediateRenegotiation: boolean;
136
+ sessionId: string;
137
+ tracks: { trackName: string; mid: string; trackSid: string }[];
138
+ sessionDescription: { sdp: string; type: string };
139
+ };
140
+
141
+ /**
142
+ * The imperative half of watching a broadcast: the calls that are made in
143
+ * response to something happening (a pass validated, a heartbeat, a viewer
144
+ * closing the tab), not on a render.
145
+ *
146
+ * They live here rather than inline in a component so both rendering stacks make
147
+ * the SAME call to the same address. A session that is pinged on one stack and
148
+ * not the other reports a different audience count for the same room.
149
+ */
150
+ export function useBroadcastSessionApi() {
151
+ const { client } = useForge();
152
+
153
+ return {
154
+ /** Re-open a session held in storage from an earlier visit. */
155
+ validateSession: async (broadcastId: string, sessionId: string): Promise<IBroadcastPass> => {
156
+ const res = await client.post(`/public/broadcasts/${broadcastId}/validate-session`, { sessionId });
157
+ return res.data;
158
+ },
159
+ /** Heartbeat. Silence is what marks a viewer as gone. */
160
+ sessionPing: async (broadcastId: string, sessionId: string): Promise<void> => {
161
+ await client.post(`/public/broadcasts/${broadcastId}/session-ping`, { sessionId });
162
+ },
163
+ /** Leave deliberately, so the count drops now rather than on a timeout. */
164
+ leave: async (broadcastId: string, sessionId?: string): Promise<void> => {
165
+ await client.post(`/public/broadcasts/${broadcastId}/leave`, { sessionId });
166
+ },
167
+ /** Subscribe to the WebRTC tracks of a realtime broadcast. */
168
+ subscribe: async (payload: {
169
+ sdp?: string;
170
+ type?: string;
171
+ trackIds?: string[];
172
+ sessionId?: string;
173
+ }): Promise<BroadcastSubscribeAnswer> => {
174
+ const res = await client.post(`/public/broadcasts/subscribe`, payload);
175
+ return res.data;
176
+ },
177
+ /** Pin the viewer to one simulcast layer, or back to auto. */
178
+ switchQuality: async (payload: { track: Record<string, unknown>; sessionId?: string }): Promise<void> => {
179
+ await client.post(`/public/broadcasts/switch-quality`, payload);
180
+ },
181
+ };
182
+ }
@@ -148,7 +148,15 @@ export type MyBooking = {
148
148
  */
149
149
  export type BookingLocation =
150
150
  | { type: "in_person"; address: string }
151
- | { type: "online"; url: string };
151
+ | { type: "online"; url: string }
152
+ /**
153
+ * A call on the platform's own media network. No payload, and there never
154
+ * will be one: entry is a ticket minted per attempt and valid for minutes, so
155
+ * a URL here would be a forwardable key to somebody else's session. Draw the
156
+ * Join control from `bookingCallWindow` in `@tribe-nest/forge/media` and let
157
+ * `<BookingCallProvider>` fetch the ticket.
158
+ */
159
+ | { type: "video" };
152
160
 
153
161
  export type CancelBookingResult = {
154
162
  bookingId: string;
package/src/i18n/de.json CHANGED
@@ -4,14 +4,22 @@
4
4
  "forge.account_dashboard.booking_cancel_error": "Wir konnten diese Session nicht stornieren. Bitte versuche es erneut.",
5
5
  "forge.account_dashboard.booking_duration": "{minutes} Minuten",
6
6
  "forge.account_dashboard.booking_join_label": "Teilnehmen:",
7
+ "forge.account_dashboard.booking_video_call": "Videoanruf auf dieser Website",
7
8
  "forge.account_dashboard.booking_where_label": "Wo:",
8
9
  "forge.account_dashboard.cancel": "Abbrechen",
10
+ "forge.account_dashboard.cancel_confirm_confirm": "Mitgliedschaft kündigen",
11
+ "forge.account_dashboard.cancel_confirm_free": "Ihre Vorteile enden sofort.",
12
+ "forge.account_dashboard.cancel_confirm_keep": "Mitgliedschaft behalten",
13
+ "forge.account_dashboard.cancel_confirm_paid": "Sie wird nicht verlängert, und Ihre Vorteile bleiben bis zum Ende des bezahlten Zeitraums bestehen.",
14
+ "forge.account_dashboard.cancel_confirm_paid_until": "Sie wird nicht verlängert, und Ihre Vorteile bleiben bis zum {{date}} bestehen.",
15
+ "forge.account_dashboard.cancel_confirm_title": "Mitgliedschaft kündigen?",
9
16
  "forge.account_dashboard.cancel_deletion": "Löschung abbrechen",
10
17
  "forge.account_dashboard.cancel_session": "Session stornieren",
11
18
  "forge.account_dashboard.cancel_ticket": "Ticket stornieren",
12
19
  "forge.account_dashboard.cancelled": "Storniert",
13
20
  "forge.account_dashboard.cancelling": "Wird storniert…",
14
21
  "forge.account_dashboard.close": "Schließen",
22
+ "forge.account_dashboard.community_spaces": "Community-Bereiche",
15
23
  "forge.account_dashboard.confirm_password": "Neues Passwort bestätigen",
16
24
  "forge.account_dashboard.current_password": "Aktuelles Passwort",
17
25
  "forge.account_dashboard.data_export_description": "Exportiere eine Kopie deiner persönlichen Daten.",
@@ -139,6 +147,28 @@
139
147
  "forge.blog_post.not_found": "Beitrag nicht gefunden.",
140
148
  "forge.blog_post.pause": "Pause",
141
149
  "forge.blog_post.play": "Abspielen",
150
+ "forge.broadcast_pass_validation.input_placeholder": "Gib deine Ticket-ID ein (TN-XXXXXXX)",
151
+ "forge.broadcast_pass_validation.join": "Übertragung beitreten",
152
+ "forge.broadcast_pass_validation.or": "ODER",
153
+ "forge.broadcast_pass_validation.prompt": "Gib deine Ticket-ID ein, um der Übertragung beizutreten (TN-XXXXXXX)",
154
+ "forge.broadcast_pass_validation.validating": "Wird geprüft…",
155
+ "forge.broadcast_player.anonymous": "Anonym",
156
+ "forge.broadcast_player.chat": "Chat",
157
+ "forge.broadcast_player.fullscreen": "Vollbild",
158
+ "forge.broadcast_player.message_label": "Nachricht",
159
+ "forge.broadcast_player.pinned": "Angepinnt",
160
+ "forge.broadcast_player.quality": "Qualität",
161
+ "forge.broadcast_player.quality_auto": "Automatisch",
162
+ "forge.broadcast_player.quality_high": "Hoch",
163
+ "forge.broadcast_player.quality_low": "Niedrig",
164
+ "forge.broadcast_player.quality_medium": "Mittel",
165
+ "forge.broadcast_player.send": "Senden",
166
+ "forge.broadcast_player.started": "Vor {duration} gestartet",
167
+ "forge.broadcast_player.watching_now": "{count} schauen gerade zu",
168
+ "forge.broadcast_ticket_purchase.buy_tickets": "Tickets kaufen",
169
+ "forge.broadcast_ticket_purchase.from_price": "Ab {price}",
170
+ "forge.broadcast_watch.leave": "Übertragung verlassen",
171
+ "forge.broadcast_watch.load_error": "Übertragung konnte nicht geladen werden",
142
172
  "forge.bundle_confirmation.continue_shopping": "Weiter einkaufen",
143
173
  "forge.bundle_confirmation.eyebrow": "Bestellung bestätigt",
144
174
  "forge.bundle_confirmation.failed_body": "Es wurde nichts abgebucht und dein Warenkorb ist noch da. Du kannst es erneut versuchen.",
@@ -519,6 +549,12 @@
519
549
  "forge.email_list_form.submitting": "Wird eingetragen…",
520
550
  "forge.email_list_form.success": "Danke für deine Anmeldung!",
521
551
  "forge.email_list_form.title": "Meinem E-Mail-Verteiler beitreten",
552
+ "forge.ended_broadcast.banner": "Übertragung beendet",
553
+ "forge.ended_broadcast.close_instruction": "Schließe die Übertragungsseite mit dem \"X\" oben links, dann landest du auf der Seite mit den Live-Events des Hosts.",
554
+ "forge.ended_broadcast.notice_title": "Wichtige Information",
555
+ "forge.ended_broadcast.refresh_instruction": "Du kannst diese Seite immer wieder neu laden, bis die Übertragung wieder läuft. Tritt ihr dann bei und du kannst das Event weiter ansehen. Du bekommst außerdem eine E-Mail, sobald die Übertragung wieder läuft.",
556
+ "forge.ended_broadcast.why_body": "Wenn das mitten im Event passiert ist, lag es sehr wahrscheinlich an einer instabilen Internetverbindung beim Host.",
557
+ "forge.ended_broadcast.why_label": "Warum wurde die Übertragung beendet?",
522
558
  "forge.event_confirmation.body_incomplete": "Deine Bestellung ist {status}. Falls dir etwas berechnet wurde, melde dich beim Support.",
523
559
  "forge.event_confirmation.body_paid": "Wir haben deine Tickets an {email} geschickt.",
524
560
  "forge.event_confirmation.explore": "Mehr Events ansehen",
@@ -708,6 +744,12 @@
708
744
  "forge.lead_magnet.invalid_link": "Ungültiger Link",
709
745
  "forge.lead_magnet.preparing": "Wird vorbereitet…",
710
746
  "forge.lead_magnet.thank_you": "Danke für deine Anmeldung!",
747
+ "forge.live_broadcast_list.empty_body": "Zurzeit sind keine Live-Übertragungen verfügbar.",
748
+ "forge.live_broadcast_list.empty_title": "Keine Live-Übertragungen",
749
+ "forge.live_broadcast_list.error_body": "Live-Übertragungen können gerade nicht geladen werden.",
750
+ "forge.live_broadcast_list.error_title": "Fehler beim Laden der Übertragungen",
751
+ "forge.live_broadcast_list.subtitle": "Live-Streams und Events ansehen",
752
+ "forge.live_broadcast_list.title": "Live-Übertragungen",
711
753
  "forge.loading.loading": "Wird geladen",
712
754
  "forge.login_form.back_to_login": "Zurück zur Anmeldung",
713
755
  "forge.login_form.code_sent_to": "Wir haben einen 6-stelligen Code geschickt an",
@@ -725,17 +767,33 @@
725
767
  "forge.login_form.verify_submit": "Bestätigen & anmelden",
726
768
  "forge.login_form.verify_title": "Bestätige deine E-Mail",
727
769
  "forge.login_form.verifying": "Wird geprüft…",
770
+ "forge.membership_checkout.amount_below_minimum": "Bitte {minimum} oder mehr eingeben.",
771
+ "forge.membership_checkout.amount_not_positive": "Bitte einen Betrag größer als null eingeben.",
728
772
  "forge.membership_checkout.back": "Zurück",
729
773
  "forge.membership_checkout.billing_cycle_label": "Abrechnung",
730
774
  "forge.membership_checkout.billing_monthly": "Monatlich",
775
+ "forge.membership_checkout.billing_monthly_price": "Monatlich ({amount})",
776
+ "forge.membership_checkout.billing_monthly_pwyw": "Zahle was du willst ab {amount}/Mon.",
731
777
  "forge.membership_checkout.billing_yearly": "Jährlich",
778
+ "forge.membership_checkout.billing_yearly_price": "Jährlich ({amount})",
779
+ "forge.membership_checkout.billing_yearly_pwyw": "Zahle was du willst ab {amount}/Jahr",
780
+ "forge.membership_checkout.choose_title": "Mitgliedschaft wählen",
781
+ "forge.membership_checkout.confirm_change": "Wechsel bestätigen",
782
+ "forge.membership_checkout.current_plan": "Aktueller Tarif",
783
+ "forge.membership_checkout.empty_title": "Keine Mitgliedschaften verfügbar",
784
+ "forge.membership_checkout.free": "Kostenlos",
785
+ "forge.membership_checkout.more_benefits": "+{count} weitere",
732
786
  "forge.membership_checkout.no_tier_body": "Wähle eine Mitgliedschaft, um fortzufahren.",
787
+ "forge.membership_checkout.on_this_plan": "Du hast diesen Tarif",
733
788
  "forge.membership_checkout.pay": "{amount} zahlen",
734
789
  "forge.membership_checkout.payment_summary": "{tier_name} · {amount} / {cycle}",
735
790
  "forge.membership_checkout.payment_title": "Zahlung",
736
791
  "forge.membership_checkout.per_month": "pro Monat",
737
792
  "forge.membership_checkout.per_year": "pro Jahr",
793
+ "forge.membership_checkout.pwyw_charged_as": "Ihnen werden {{amount}} berechnet.",
738
794
  "forge.membership_checkout.pwyw_label": "Wie viel möchtest du zahlen?",
795
+ "forge.membership_checkout.pwyw_min": "(mindestens {amount})",
796
+ "forge.membership_checkout.select_tier": "Diesen Tarif wählen",
739
797
  "forge.membership_checkout.submit": "Abonnieren",
740
798
  "forge.membership_checkout.submitting": "Wird verarbeitet…",
741
799
  "forge.membership_checkout.title": "{tier_name} beitreten",
@@ -746,6 +804,7 @@
746
804
  "forge.membership_tiers.price_free": "Kostenlos",
747
805
  "forge.membership_tiers.price_monthly": "{amount}/Mon.",
748
806
  "forge.membership_tiers.price_pwyw": "Zahl, was du willst, ab {amount}/Mon.",
807
+ "forge.membership_tiers.price_pwyw_yearly": "Zahle was du willst ab {amount}/Jahr",
749
808
  "forge.membership_tiers.price_yearly": "{amount}/Jahr",
750
809
  "forge.membership_tiers.select": "Wählen",
751
810
  "forge.offer_button.buy_now": "Jetzt kaufen",
package/src/i18n/en.json CHANGED
@@ -4,14 +4,22 @@
4
4
  "forge.account_dashboard.booking_cancel_error": "We could not cancel this session. Please try again.",
5
5
  "forge.account_dashboard.booking_duration": "{minutes} minutes",
6
6
  "forge.account_dashboard.booking_join_label": "Join:",
7
+ "forge.account_dashboard.booking_video_call": "Video call on this site",
7
8
  "forge.account_dashboard.booking_where_label": "Where:",
8
9
  "forge.account_dashboard.cancel": "Cancel",
10
+ "forge.account_dashboard.cancel_confirm_confirm": "Cancel membership",
11
+ "forge.account_dashboard.cancel_confirm_free": "Your benefits will stop right away.",
12
+ "forge.account_dashboard.cancel_confirm_keep": "Keep membership",
13
+ "forge.account_dashboard.cancel_confirm_paid": "It will not renew, and you keep your benefits until the end of the period you have paid for.",
14
+ "forge.account_dashboard.cancel_confirm_paid_until": "It will not renew, and you keep your benefits until {{date}}.",
15
+ "forge.account_dashboard.cancel_confirm_title": "Cancel your membership?",
9
16
  "forge.account_dashboard.cancel_deletion": "Cancel Deletion",
10
17
  "forge.account_dashboard.cancel_session": "Cancel session",
11
18
  "forge.account_dashboard.cancel_ticket": "Cancel ticket",
12
19
  "forge.account_dashboard.cancelled": "Cancelled",
13
20
  "forge.account_dashboard.cancelling": "Cancelling…",
14
21
  "forge.account_dashboard.close": "Close",
22
+ "forge.account_dashboard.community_spaces": "Community spaces",
15
23
  "forge.account_dashboard.confirm_password": "Confirm new password",
16
24
  "forge.account_dashboard.current_password": "Current password",
17
25
  "forge.account_dashboard.data_export_description": "Export a copy of your personal data.",
@@ -139,6 +147,28 @@
139
147
  "forge.blog_post.not_found": "Post not found.",
140
148
  "forge.blog_post.pause": "Pause",
141
149
  "forge.blog_post.play": "Play",
150
+ "forge.broadcast_pass_validation.input_placeholder": "Enter your ticket ID (TN-XXXXXXX)",
151
+ "forge.broadcast_pass_validation.join": "Join Broadcast",
152
+ "forge.broadcast_pass_validation.or": "OR",
153
+ "forge.broadcast_pass_validation.prompt": "Enter your ticket ID to join the broadcast (TN-XXXXXXX)",
154
+ "forge.broadcast_pass_validation.validating": "Validating…",
155
+ "forge.broadcast_player.anonymous": "Anonymous",
156
+ "forge.broadcast_player.chat": "Chat",
157
+ "forge.broadcast_player.fullscreen": "Fullscreen",
158
+ "forge.broadcast_player.message_label": "Message",
159
+ "forge.broadcast_player.pinned": "Pinned",
160
+ "forge.broadcast_player.quality": "Quality",
161
+ "forge.broadcast_player.quality_auto": "Auto",
162
+ "forge.broadcast_player.quality_high": "High",
163
+ "forge.broadcast_player.quality_low": "Low",
164
+ "forge.broadcast_player.quality_medium": "Medium",
165
+ "forge.broadcast_player.send": "Send",
166
+ "forge.broadcast_player.started": "Started {duration} ago",
167
+ "forge.broadcast_player.watching_now": "{count} watching now",
168
+ "forge.broadcast_ticket_purchase.buy_tickets": "Buy tickets",
169
+ "forge.broadcast_ticket_purchase.from_price": "From {price}",
170
+ "forge.broadcast_watch.leave": "Leave broadcast",
171
+ "forge.broadcast_watch.load_error": "Unable to load broadcast",
142
172
  "forge.bundle_confirmation.continue_shopping": "Continue shopping",
143
173
  "forge.bundle_confirmation.eyebrow": "Order confirmed",
144
174
  "forge.bundle_confirmation.failed_body": "Nothing was charged and your cart is still here. You can try again.",
@@ -519,6 +549,12 @@
519
549
  "forge.email_list_form.submitting": "Joining…",
520
550
  "forge.email_list_form.success": "Thanks for subscribing!",
521
551
  "forge.email_list_form.title": "Join my email list",
552
+ "forge.ended_broadcast.banner": "Broadcast Ended",
553
+ "forge.ended_broadcast.close_instruction": "Close the broadcast page with the \"X\" at the top left and you will land on the host's live events page.",
554
+ "forge.ended_broadcast.notice_title": "Important information",
555
+ "forge.ended_broadcast.refresh_instruction": "You can keep refreshing this page until the broadcast is live again. Join it and you should be able to watch the event again. You will also get an email when the broadcast is live again.",
556
+ "forge.ended_broadcast.why_body": "If this happened in the middle of the event, it was most likely caused by an unstable internet connection at the host's end.",
557
+ "forge.ended_broadcast.why_label": "Why did the broadcast end?",
522
558
  "forge.event_confirmation.body_incomplete": "Your order is {status}. If you were charged, contact support.",
523
559
  "forge.event_confirmation.body_paid": "We’ve sent your tickets to {email}.",
524
560
  "forge.event_confirmation.explore": "Browse more events",
@@ -708,6 +744,12 @@
708
744
  "forge.lead_magnet.invalid_link": "Invalid link",
709
745
  "forge.lead_magnet.preparing": "Preparing…",
710
746
  "forge.lead_magnet.thank_you": "Thank you for subscribing!",
747
+ "forge.live_broadcast_list.empty_body": "There are currently no live broadcasts available.",
748
+ "forge.live_broadcast_list.empty_title": "No Live Broadcasts",
749
+ "forge.live_broadcast_list.error_body": "Unable to load live broadcasts at this time.",
750
+ "forge.live_broadcast_list.error_title": "Error Loading Broadcasts",
751
+ "forge.live_broadcast_list.subtitle": "Watch live streams and events",
752
+ "forge.live_broadcast_list.title": "Live Broadcasts",
711
753
  "forge.loading.loading": "Loading",
712
754
  "forge.login_form.back_to_login": "Back to login",
713
755
  "forge.login_form.code_sent_to": "We sent a 6-digit code to",
@@ -725,17 +767,33 @@
725
767
  "forge.login_form.verify_submit": "Verify & Sign In",
726
768
  "forge.login_form.verify_title": "Verify your email",
727
769
  "forge.login_form.verifying": "Verifying…",
770
+ "forge.membership_checkout.amount_below_minimum": "Enter {minimum} or more.",
771
+ "forge.membership_checkout.amount_not_positive": "Enter an amount greater than zero.",
728
772
  "forge.membership_checkout.back": "Back",
729
773
  "forge.membership_checkout.billing_cycle_label": "Billing cycle",
730
774
  "forge.membership_checkout.billing_monthly": "Monthly",
775
+ "forge.membership_checkout.billing_monthly_price": "Monthly ({amount})",
776
+ "forge.membership_checkout.billing_monthly_pwyw": "Pay what you want from {amount}/mo",
731
777
  "forge.membership_checkout.billing_yearly": "Yearly",
778
+ "forge.membership_checkout.billing_yearly_price": "Yearly ({amount})",
779
+ "forge.membership_checkout.billing_yearly_pwyw": "Pay what you want from {amount}/yr",
780
+ "forge.membership_checkout.choose_title": "Choose your membership",
781
+ "forge.membership_checkout.confirm_change": "Confirm change",
782
+ "forge.membership_checkout.current_plan": "Current plan",
783
+ "forge.membership_checkout.empty_title": "No memberships available",
784
+ "forge.membership_checkout.free": "Free",
785
+ "forge.membership_checkout.more_benefits": "+{count} more",
732
786
  "forge.membership_checkout.no_tier_body": "Select a membership tier to continue.",
787
+ "forge.membership_checkout.on_this_plan": "You are on this plan",
733
788
  "forge.membership_checkout.pay": "Pay {amount}",
734
789
  "forge.membership_checkout.payment_summary": "{tier_name} · {amount} / {cycle}",
735
790
  "forge.membership_checkout.payment_title": "Payment",
736
791
  "forge.membership_checkout.per_month": "per month",
737
792
  "forge.membership_checkout.per_year": "per year",
793
+ "forge.membership_checkout.pwyw_charged_as": "You will be charged {{amount}}.",
738
794
  "forge.membership_checkout.pwyw_label": "How much would you like to pay?",
795
+ "forge.membership_checkout.pwyw_min": "(minimum {amount})",
796
+ "forge.membership_checkout.select_tier": "Select this tier",
739
797
  "forge.membership_checkout.submit": "Subscribe",
740
798
  "forge.membership_checkout.submitting": "Processing…",
741
799
  "forge.membership_checkout.title": "Join {tier_name}",
@@ -746,6 +804,7 @@
746
804
  "forge.membership_tiers.price_free": "Free",
747
805
  "forge.membership_tiers.price_monthly": "{amount}/mo",
748
806
  "forge.membership_tiers.price_pwyw": "Pay what you want from {amount}/mo",
807
+ "forge.membership_tiers.price_pwyw_yearly": "Pay what you want from {amount}/yr",
749
808
  "forge.membership_tiers.price_yearly": "{amount}/yr",
750
809
  "forge.membership_tiers.select": "Select",
751
810
  "forge.offer_button.buy_now": "Buy now",
@@ -0,0 +1,185 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import type { MembershipTier } from "../../../types/models";
3
+ import {
4
+ clampMembershipAmount,
5
+ cycleCeiling,
6
+ cycleFloor,
7
+ cycleIsFree,
8
+ cycleIsOffered,
9
+ defaultChosenAmount,
10
+ defaultCycle,
11
+ offeredCycles,
12
+ refuseMembershipAmount,
13
+ resolveSubscriptionAmount,
14
+ } from "../membershipPwyw";
15
+
16
+ /**
17
+ * The membership money rules, away from React.
18
+ *
19
+ * Everything here decides either what a fan is CHARGED or which endpoint is
20
+ * called, and both have shipped wrong: a pay-what-you-want box that sent its
21
+ * initial zero to an endpoint that refuses anything not positive, and a
22
+ * yearly-only tier read as free because the default cycle was the literal
23
+ * "month".
24
+ */
25
+
26
+ const tier = (overrides: Partial<MembershipTier> = {}): MembershipTier => ({
27
+ id: "tier-1",
28
+ name: "Inner Circle",
29
+ description: "The good stuff",
30
+ payWhatYouWant: false,
31
+ benefits: [],
32
+ ...overrides,
33
+ });
34
+
35
+ describe("cycleFloor", () => {
36
+ it("reads a pay-what-you-want tier's YEARLY minimum on the yearly cycle", () => {
37
+ // Monthly 5, yearly 50. Reading the monthly floor on a yearly subscription
38
+ // would let someone buy a whole year for the price of a month.
39
+ const t = tier({ payWhatYouWant: true, payWhatYouWantMinimum: 5, payWhatYouWantYearlyMinimum: 50 });
40
+
41
+ expect(cycleFloor(t, "month")).toBe(5);
42
+ expect(cycleFloor(t, "year")).toBe(50);
43
+ });
44
+
45
+ it("reads a fixed tier's own cycle price, which is the only amount the server takes", () => {
46
+ const t = tier({ priceMonthly: 10, priceYearly: 100 });
47
+
48
+ expect(cycleFloor(t, "month")).toBe(10);
49
+ expect(cycleFloor(t, "year")).toBe(100);
50
+ });
51
+
52
+ it("treats a missing, null or negative price as no price at all", () => {
53
+ expect(cycleFloor(tier({ priceMonthly: undefined }), "month")).toBe(0);
54
+ expect(cycleFloor(tier({ priceYearly: -4 }), "year")).toBe(0);
55
+ });
56
+ });
57
+
58
+ describe("offeredCycles / defaultCycle", () => {
59
+ it("REGRESSION: opens a YEARLY-ONLY tier on the yearly cycle", () => {
60
+ // The defect: the cycle defaulted to the literal "month", the monthly price
61
+ // was absent, and the tier was therefore read as FREE and activated through
62
+ // the free endpoint. The fan got the tier and the artist was never charged.
63
+ const t = tier({ priceYearly: 100 });
64
+
65
+ expect(offeredCycles(t)).toEqual({ month: false, year: true });
66
+ expect(defaultCycle(t)).toBe("year");
67
+ expect(cycleIsFree(t, defaultCycle(t))).toBe(false);
68
+ });
69
+
70
+ it("opens a pay-what-you-want tier with only a yearly minimum on the yearly cycle", () => {
71
+ const t = tier({ payWhatYouWant: true, payWhatYouWantYearlyMinimum: 60 });
72
+
73
+ expect(offeredCycles(t)).toEqual({ month: false, year: true });
74
+ expect(defaultCycle(t)).toBe("year");
75
+ });
76
+
77
+ it("prefers monthly when both are sold", () => {
78
+ expect(defaultCycle(tier({ priceMonthly: 10, priceYearly: 100 }))).toBe("month");
79
+ });
80
+
81
+ it("calls a tier with no price on either cycle free, and opens it on monthly", () => {
82
+ const t = tier();
83
+
84
+ expect(cycleIsOffered(t, "month")).toBe(false);
85
+ expect(cycleIsOffered(t, "year")).toBe(false);
86
+ expect(defaultCycle(t)).toBe("month");
87
+ expect(cycleIsFree(t, "month")).toBe(true);
88
+ });
89
+
90
+ it("never calls a pay-what-you-want tier free, whatever its minimums", () => {
91
+ // A PWYW tier is bought through the PAID endpoint even at its floor, so
92
+ // reading it as free would activate a membership with no charge behind it.
93
+ expect(cycleIsFree(tier({ payWhatYouWant: true, payWhatYouWantMinimum: 5 }), "month")).toBe(false);
94
+ });
95
+ });
96
+
97
+ describe("defaultChosenAmount", () => {
98
+ it("REGRESSION: opens the amount box at the floor, not at zero", () => {
99
+ // The defect: the box started at 0 and nothing ever seeded it, so the very
100
+ // first press of Subscribe sent `amount: 0` and the server answered
101
+ // `amount_must_be_positive`. Pay-what-you-want could not be bought at all.
102
+ const t = tier({ payWhatYouWant: true, payWhatYouWantMinimum: 25, payWhatYouWantYearlyMinimum: 250 });
103
+
104
+ expect(defaultChosenAmount(t, "month")).toBe(25);
105
+ expect(defaultChosenAmount(t, "year")).toBe(250);
106
+ });
107
+
108
+ it("leaves a fixed tier's box at zero, because it is never read", () => {
109
+ expect(defaultChosenAmount(tier({ priceMonthly: 10 }), "month")).toBe(0);
110
+ });
111
+ });
112
+
113
+ describe("clampMembershipAmount", () => {
114
+ const t = tier({ payWhatYouWant: true, payWhatYouWantMinimum: 20, payWhatYouWantMaximum: 100 });
115
+
116
+ it("lifts anything under the floor up to it", () => {
117
+ expect(clampMembershipAmount(t, "month", 5)).toBe(20);
118
+ expect(clampMembershipAmount(t, "month", 0)).toBe(20);
119
+ expect(clampMembershipAmount(t, "month", -3)).toBe(20);
120
+ });
121
+
122
+ it("treats an unparseable box (a cleared input) as the floor rather than NaN", () => {
123
+ expect(clampMembershipAmount(t, "month", Number(""))).toBe(20);
124
+ expect(clampMembershipAmount(t, "month", undefined)).toBe(20);
125
+ });
126
+
127
+ it("keeps a generous amount exactly as typed, ceiling and all", () => {
128
+ // The ceiling is display-only: neither the client app nor the server
129
+ // enforces one on a membership, and lowering the figure here would charge
130
+ // less than the total the fan just read.
131
+ expect(clampMembershipAmount(t, "month", 45)).toBe(45);
132
+ expect(clampMembershipAmount(t, "month", 500)).toBe(500);
133
+ expect(cycleCeiling(t, "month")).toBe(100);
134
+ });
135
+ });
136
+
137
+ describe("resolveSubscriptionAmount", () => {
138
+ it("ignores the amount box on a fixed tier", () => {
139
+ // A figure left over from a pay-what-you-want tier must not follow the fan
140
+ // onto a fixed one: the server rejects anything that is not exactly the
141
+ // cycle price, so this would be a checkout that always 400s.
142
+ const t = tier({ priceMonthly: 10, priceYearly: 100 });
143
+
144
+ expect(resolveSubscriptionAmount(t, "month", 999)).toBe(10);
145
+ expect(resolveSubscriptionAmount(t, "year", 999)).toBe(100);
146
+ });
147
+
148
+ it("sends the fan's own figure on a pay-what-you-want tier", () => {
149
+ const t = tier({ payWhatYouWant: true, payWhatYouWantMinimum: 5 });
150
+
151
+ expect(resolveSubscriptionAmount(t, "month", 12.5)).toBe(12.5);
152
+ });
153
+ });
154
+
155
+ describe("refuseMembershipAmount", () => {
156
+ it("refuses a pay-what-you-want figure under the floor, and names the floor", () => {
157
+ const t = tier({ payWhatYouWant: true, payWhatYouWantMinimum: 20 });
158
+
159
+ expect(refuseMembershipAmount(t, "month", 5)).toEqual({ reason: "below_minimum", minimum: 20 });
160
+ });
161
+
162
+ it("refuses a zero before the request rather than after the 400", () => {
163
+ // A pay-what-you-want tier whose floor is 0 passes the minimum check on 0
164
+ // and then fails `amount_must_be_positive` server-side, with the failure
165
+ // landing nowhere the fan can see.
166
+ const t = tier({ payWhatYouWant: true, payWhatYouWantMinimum: 0, payWhatYouWantYearlyMinimum: 60 });
167
+
168
+ expect(refuseMembershipAmount(t, "month", 0)).toEqual({ reason: "not_positive" });
169
+ });
170
+
171
+ it("passes a figure at the floor, and anything above it", () => {
172
+ const t = tier({ payWhatYouWant: true, payWhatYouWantMinimum: 20 });
173
+
174
+ expect(refuseMembershipAmount(t, "month", 20)).toBeNull();
175
+ expect(refuseMembershipAmount(t, "month", 21)).toBeNull();
176
+ });
177
+
178
+ it("passes a free cycle, which never sends an amount at all", () => {
179
+ expect(refuseMembershipAmount(tier(), "month", 0)).toBeNull();
180
+ });
181
+
182
+ it("passes a fixed paid cycle regardless of the box", () => {
183
+ expect(refuseMembershipAmount(tier({ priceMonthly: 10 }), "month", 0)).toBeNull();
184
+ });
185
+ });