@tribe-nest/forge 3.59.0 → 3.60.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 (94) hide show
  1. package/package.json +1 -1
  2. package/src/_tests/testCommunity.tsx +28 -0
  3. package/src/contexts/CommunityContext.tsx +192 -0
  4. package/src/data/queries/useChat.ts +42 -39
  5. package/src/data/queries/useCommunities.ts +343 -0
  6. package/src/data/queries/useCommunity.ts +93 -89
  7. package/src/data/queries/useMembership.ts +32 -7
  8. package/src/data/queries/useMembershipCalls.ts +116 -7
  9. package/src/data/queries/usePostCollections.ts +28 -16
  10. package/src/data/queries/usePosts.ts +16 -14
  11. package/src/data/queries/useSubscriptions.ts +22 -8
  12. package/src/i18n/de.json +145 -1
  13. package/src/i18n/en.json +145 -1
  14. package/src/index.ts +19 -7
  15. package/src/runtime/pages.spec.ts +31 -0
  16. package/src/runtime/pages.ts +21 -22
  17. package/src/server/_tests/buildWebManifest.spec.ts +1 -1
  18. package/src/server/_tests/forgeServiceWorkerScript.spec.ts +4 -4
  19. package/src/ui/format/_tests/membershipGate.spec.ts +6 -6
  20. package/src/ui/format/membershipGate.ts +19 -15
  21. package/src/ui/headless/_tests/communityGate.spec.tsx +80 -0
  22. package/src/ui/headless/_tests/communityHarness.tsx +205 -0
  23. package/src/ui/headless/_tests/communityProvider.spec.tsx +151 -0
  24. package/src/ui/headless/_tests/useCommunityInvite.spec.tsx +104 -0
  25. package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +382 -0
  26. package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +151 -0
  27. package/src/ui/headless/_tests/useCommunityReads.spec.tsx +286 -0
  28. package/src/ui/headless/_tests/useCommunityUpgrade.spec.tsx +157 -0
  29. package/src/ui/headless/auth/useSignupForm.ts +10 -2
  30. package/src/ui/headless/communities/CommunityGate.tsx +67 -0
  31. package/src/ui/headless/communities/index.ts +55 -0
  32. package/src/ui/headless/communities/paths.ts +36 -0
  33. package/src/ui/headless/communities/useCommunity.ts +140 -0
  34. package/src/ui/headless/communities/useCommunityAbout.ts +71 -0
  35. package/src/ui/headless/communities/useCommunityHome.ts +88 -0
  36. package/src/ui/headless/communities/useCommunityInvite.ts +116 -0
  37. package/src/ui/headless/communities/useCommunityJoin.ts +444 -0
  38. package/src/ui/headless/communities/useCommunityMembership.ts +136 -0
  39. package/src/ui/headless/communities/useCommunityUpgrade.ts +213 -0
  40. package/src/ui/headless/communities/useMyMemberships.ts +59 -0
  41. package/src/ui/headless/community/useCommunityAttachmentUpload.ts +7 -3
  42. package/src/ui/headless/index.ts +8 -4
  43. package/src/ui/headless/memberHome/index.ts +1 -1
  44. package/src/ui/headless/memberHome/useMemberHomeNav.ts +44 -42
  45. package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +8 -0
  46. package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +5 -4
  47. package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +1 -1
  48. package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +1 -1
  49. package/src/ui/headless/membership/useMembershipCheckout.ts +89 -22
  50. package/src/ui/headless/membership/useMembershipGateNotice.ts +25 -7
  51. package/src/ui/headless/posts/upsell.ts +54 -8
  52. package/src/ui/headless/posts/useMemberPostsFeed.ts +6 -5
  53. package/src/ui/headless/posts/usePostCollectionDetail.ts +7 -4
  54. package/src/ui/headless/posts/usePostCollectionsList.ts +6 -5
  55. package/src/ui/headless/posts/usePostItem.ts +6 -5
  56. package/src/ui/index.ts +17 -12
  57. package/src/ui/styled/AccountDashboard.tsx +48 -256
  58. package/src/ui/styled/MembershipCheckout.tsx +37 -17
  59. package/src/ui/styled/MembershipTierCallout.tsx +20 -1
  60. package/src/ui/styled/MembershipTiers.tsx +49 -11
  61. package/src/ui/styled/Paywall.tsx +1 -1
  62. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +1 -1
  63. package/src/ui/styled/_tests/AccountDashboardMemberships.spec.tsx +151 -0
  64. package/src/ui/styled/_tests/AccountDashboardRentals.spec.tsx +1 -1
  65. package/src/ui/styled/_tests/CourseCallsSection.spec.tsx +7 -1
  66. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +54 -11
  67. package/src/ui/styled/_tests/membershipTierCallout.spec.tsx +1 -1
  68. package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +1 -1
  69. package/src/ui/styled/communities/CommunitiesList.tsx +88 -0
  70. package/src/ui/styled/communities/CommunityAbout.tsx +140 -0
  71. package/src/ui/styled/communities/CommunityHome.tsx +175 -0
  72. package/src/ui/styled/communities/CommunityJoinSheet.tsx +444 -0
  73. package/src/ui/styled/communities/CommunityMembershipMenu.tsx +133 -0
  74. package/src/ui/styled/communities/CommunityPending.tsx +49 -0
  75. package/src/ui/styled/communities/CommunityUpgradeSheet.tsx +137 -0
  76. package/src/ui/styled/communities/MyMembershipsList.tsx +89 -0
  77. package/src/ui/styled/communities/_tests/CommunityPages.spec.tsx +102 -0
  78. package/src/ui/styled/communities/index.ts +10 -0
  79. package/src/ui/styled/communities/shared.tsx +149 -0
  80. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +4 -1
  81. package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +4 -1
  82. package/src/ui/styled/forge-utilities.css +19 -0
  83. package/src/ui/styled/members/CallTranscriptViewer.tsx +424 -0
  84. package/src/ui/styled/members/MemberCallsCalendar.tsx +142 -10
  85. package/src/ui/styled/members/MemberHomeLayout.tsx +35 -17
  86. package/src/ui/styled/members/MemberPostCard.tsx +9 -5
  87. package/src/ui/styled/members/PostCollectionDetail.tsx +18 -10
  88. package/src/ui/styled/members/PostCollectionsGrid.tsx +24 -10
  89. package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +167 -8
  90. package/src/ui/styled/members/callTranscriptText.ts +67 -0
  91. package/src/utils/_tests/safeRedirect.spec.ts +2 -2
  92. package/src/ui/headless/membership/MembershipGate.tsx +0 -44
  93. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +0 -149
  94. package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +0 -105
package/src/i18n/en.json CHANGED
@@ -655,6 +655,118 @@
655
655
  "forge.cohort_page.date_range": "{start} to {end}",
656
656
  "forge.cohort_page.date_tbd": "TBD",
657
657
  "forge.cohort_page.not_found": "Cohort not found.",
658
+ "forge.communities_list.empty": "There are no communities to join yet.",
659
+ "forge.communities_list.heading": "Communities",
660
+ "forge.communities_list.member_count": "{count} members",
661
+ "forge.community_about.apply": "Apply to join",
662
+ "forge.community_about.current_tier": "Your current tier",
663
+ "forge.community_about.go_inside": "Go to the community",
664
+ "forge.community_about.join": "Join",
665
+ "forge.community_about.join_free": "Join free",
666
+ "forge.community_about.join_from": "Join from {amount}/mo",
667
+ "forge.community_about.join_from_yearly": "Join from {amount}/yr",
668
+ "forge.community_about.join_tier": "Join",
669
+ "forge.community_about.member_count": "{count} members",
670
+ "forge.community_about.not_found": "This community could not be found.",
671
+ "forge.community_about.pending": "Your application is being reviewed.",
672
+ "forge.community_about.tiers_title": "Membership tiers",
673
+ "forge.community_about.view_application": "View your application",
674
+ "forge.community_home.confirming": "Confirming your membership...",
675
+ "forge.community_home.dismiss": "Dismiss",
676
+ "forge.community_home.join": "Join",
677
+ "forge.community_home.membership": "Membership",
678
+ "forge.community_home.not_found": "This community could not be found.",
679
+ "forge.community_home.upgrade": "Upgrade",
680
+ "forge.community_home.welcome": "Welcome to {community_name}!",
681
+ "forge.community_join_sheet.accept_terms": "I accept the Terms and Privacy Policy",
682
+ "forge.community_join_sheet.already_member": "You are already a member.",
683
+ "forge.community_join_sheet.amount_below_minimum": "The minimum is {minimum}.",
684
+ "forge.community_join_sheet.amount_label": "Choose your amount (minimum {minimum})",
685
+ "forge.community_join_sheet.amount_not_positive": "Enter an amount above zero.",
686
+ "forge.community_join_sheet.answer_required": "Please answer: {question}",
687
+ "forge.community_join_sheet.apply": "Apply to join",
688
+ "forge.community_join_sheet.aria_close": "Close",
689
+ "forge.community_join_sheet.back": "Back",
690
+ "forge.community_join_sheet.billing_cycle": "Billing cycle",
691
+ "forge.community_join_sheet.card_application": "Add a card for your application",
692
+ "forge.community_join_sheet.card_application_help": "Your card is saved, not charged. You are charged only if your application is approved.",
693
+ "forge.community_join_sheet.card_application_help_label": "When is my card charged?",
694
+ "forge.community_join_sheet.card_payment": "Pay {amount}",
695
+ "forge.community_join_sheet.card_trial": "Add a card to start your free trial",
696
+ "forge.community_join_sheet.change_tier": "Change tier",
697
+ "forge.community_join_sheet.choose_tier": "Choose a tier",
698
+ "forge.community_join_sheet.code": "6-digit code",
699
+ "forge.community_join_sheet.code_sent": "We emailed a 6-digit code to {email}.",
700
+ "forge.community_join_sheet.continue": "Continue",
701
+ "forge.community_join_sheet.email": "Email",
702
+ "forge.community_join_sheet.email_mismatch": "This invite is for {email}. You are signed in as {signed_in_email}.",
703
+ "forge.community_join_sheet.first_name": "First name",
704
+ "forge.community_join_sheet.invite_expired": "This invite has expired. You can still join below.",
705
+ "forge.community_join_sheet.invite_not_found": "This invite link is not valid. You can still join below.",
706
+ "forge.community_join_sheet.invite_revoked": "This invite was withdrawn. You can still join below.",
707
+ "forge.community_join_sheet.invite_used": "This invite has already been used. You can still join below.",
708
+ "forge.community_join_sheet.invite_used_up": "This invite link has no places left. You can still join below.",
709
+ "forge.community_join_sheet.join_for": "Join for {amount}",
710
+ "forge.community_join_sheet.join_free": "Join free",
711
+ "forge.community_join_sheet.joined_title": "You're in!",
712
+ "forge.community_join_sheet.joining": "Joining...",
713
+ "forge.community_join_sheet.last_name": "Last name",
714
+ "forge.community_join_sheet.mode_signin": "Sign in",
715
+ "forge.community_join_sheet.mode_signup": "Create account",
716
+ "forge.community_join_sheet.monthly": "Monthly",
717
+ "forge.community_join_sheet.monthly_price": "Monthly, {amount}",
718
+ "forge.community_join_sheet.password": "Password",
719
+ "forge.community_join_sheet.pay": "Pay {amount}",
720
+ "forge.community_join_sheet.pending_body": "Your application is being reviewed. We'll let you know when it is decided.",
721
+ "forge.community_join_sheet.pending_title": "Application sent",
722
+ "forge.community_join_sheet.resend": "Resend code",
723
+ "forge.community_join_sheet.select_placeholder": "Select...",
724
+ "forge.community_join_sheet.sign_in_as": "Sign in as {email}",
725
+ "forge.community_join_sheet.start_trial": "Start free trial",
726
+ "forge.community_join_sheet.taking_you_inside": "Taking you inside...",
727
+ "forge.community_join_sheet.title": "Join {community_name}",
728
+ "forge.community_join_sheet.title_plain": "Join",
729
+ "forge.community_join_sheet.trial_line": "{days}-day free trial, first charge on {date}",
730
+ "forge.community_join_sheet.verify": "Verify",
731
+ "forge.community_join_sheet.working": "Please wait...",
732
+ "forge.community_join_sheet.yearly": "Yearly",
733
+ "forge.community_join_sheet.yearly_price": "Yearly, {amount}",
734
+ "forge.community_membership_menu.aria_close": "Close",
735
+ "forge.community_membership_menu.cancel": "Cancel membership",
736
+ "forge.community_membership_menu.cancel_confirm": "Cancel membership",
737
+ "forge.community_membership_menu.cancel_confirm_now": "Your membership ends now.",
738
+ "forge.community_membership_menu.cancel_confirm_title": "Cancel your membership?",
739
+ "forge.community_membership_menu.cancel_confirm_until": "You keep access until {date}. It will not renew.",
740
+ "forge.community_membership_menu.cancel_keep": "Keep membership",
741
+ "forge.community_membership_menu.downgrade_to": "Switch to {tier_name} at renewal",
742
+ "forge.community_membership_menu.ending": "Ending",
743
+ "forge.community_membership_menu.ends_on": "Ends on {date}",
744
+ "forge.community_membership_menu.payment_failed": "Payment failed",
745
+ "forge.community_membership_menu.pending_downgrade": "Switching to {tier_name} on {date}.",
746
+ "forge.community_membership_menu.renews_on": "Renews on {date}",
747
+ "forge.community_membership_menu.title": "Your membership",
748
+ "forge.community_membership_menu.trigger": "Membership",
749
+ "forge.community_membership_menu.undo_downgrade": "Keep my current tier",
750
+ "forge.community_membership_menu.update_card": "Update payment method",
751
+ "forge.community_membership_menu.upgrade": "Upgrade",
752
+ "forge.community_pending.body": "Your application is being reviewed. You'll get access as soon as it is approved.",
753
+ "forge.community_pending.body_since": "You applied on {date}. You'll get access as soon as it is approved.",
754
+ "forge.community_pending.title": "Your application is being reviewed",
755
+ "forge.community_upgrade_sheet.aria_close": "Close",
756
+ "forge.community_upgrade_sheet.back": "Back",
757
+ "forge.community_upgrade_sheet.charge_now": "You pay {amount} now.",
758
+ "forge.community_upgrade_sheet.charge_prorated": "You pay {amount} now for the rest of this period.",
759
+ "forge.community_upgrade_sheet.confirm": "Upgrade now",
760
+ "forge.community_upgrade_sheet.continue_to_card": "Continue to card",
761
+ "forge.community_upgrade_sheet.current": "You are on {tier_name}.",
762
+ "forge.community_upgrade_sheet.done": "Upgraded!",
763
+ "forge.community_upgrade_sheet.none": "You are already on the highest tier.",
764
+ "forge.community_upgrade_sheet.pay": "Pay and upgrade",
765
+ "forge.community_upgrade_sheet.price_monthly": "{amount}/mo",
766
+ "forge.community_upgrade_sheet.price_yearly": "{amount}/yr",
767
+ "forge.community_upgrade_sheet.requires_card": "Upgrading asks for your card again and replaces your current subscription.",
768
+ "forge.community_upgrade_sheet.title": "Upgrade",
769
+ "forge.community_upgrade_sheet.unlock_with": "Unlock with {tier_name}",
658
770
  "forge.confirm_subscription.back_to_site": "Back to site",
659
771
  "forge.confirm_subscription.expired_body": "This confirmation link is no longer valid. Try subscribing again.",
660
772
  "forge.confirm_subscription.expired_title": "Link expired",
@@ -1167,6 +1279,19 @@
1167
1279
  "forge.login_form.verify_submit": "Verify & Sign In",
1168
1280
  "forge.login_form.verify_title": "Verify your email",
1169
1281
  "forge.login_form.verifying": "Verifying…",
1282
+ "forge.member_home_layout.nav_label": "Community",
1283
+ "forge.member_home_layout.tab_calendar": "Calendar",
1284
+ "forge.member_home_layout.tab_chat": "Chat",
1285
+ "forge.member_home_layout.tab_collections": "Collections",
1286
+ "forge.member_home_layout.tab_leaderboard": "Leaderboard",
1287
+ "forge.member_home_layout.tab_notifications": "Notifications",
1288
+ "forge.member_home_layout.tab_posts": "Posts",
1289
+ "forge.member_home_layout.tab_spaces": "Spaces",
1290
+ "forge.member_post_card.locked_body": "Join to access this content",
1291
+ "forge.member_post_card.locked_body_tier": "Unlock with {tier_name} to access this content",
1292
+ "forge.member_post_card.locked_title": "Content locked",
1293
+ "forge.member_post_card.unlock": "Unlock",
1294
+ "forge.member_post_card.unlock_with": "Unlock with {tier_name}",
1170
1295
  "forge.membership_checkout.accept_invite": "Accept invite",
1171
1296
  "forge.membership_checkout.amount_below_minimum": "Enter {minimum} or more.",
1172
1297
  "forge.membership_checkout.amount_not_positive": "Enter an amount greater than zero.",
@@ -1203,7 +1328,7 @@
1203
1328
  "forge.membership_checkout.empty_title": "No memberships available",
1204
1329
  "forge.membership_checkout.field_required": "This field is required",
1205
1330
  "forge.membership_checkout.free": "Free",
1206
- "forge.membership_checkout.go_to_account": "Go to your account",
1331
+ "forge.membership_checkout.go_to_community": "Go to the community",
1207
1332
  "forge.membership_checkout.invite_dead_body": "It may have expired, been used already, or been replaced by a newer invite. Ask us for a new one.",
1208
1333
  "forge.membership_checkout.invite_dead_title": "This invite is no longer valid",
1209
1334
  "forge.membership_checkout.invite_email_mismatch": "You're signed in as {signed_in_email}. Sign in with {email} to accept this invite.",
@@ -1235,6 +1360,7 @@
1235
1360
  "forge.membership_tier_callout.invite_for_email": "This invite is for {email}. Sign in or create an account with that address.",
1236
1361
  "forge.membership_tier_callout.join_title": "Join {tier_name}",
1237
1362
  "forge.membership_tier_callout.more_benefits": "+{count} more benefits",
1363
+ "forge.membership_tier_callout.view_community": "Part of {community_name}",
1238
1364
  "forge.membership_tiers.accept_invite": "Accept invite",
1239
1365
  "forge.membership_tiers.application_approved": "Approved, finishing up",
1240
1366
  "forge.membership_tiers.application_declined": "Not approved",
@@ -1255,6 +1381,14 @@
1255
1381
  "forge.membership_tiers.withdraw": "Withdraw application",
1256
1382
  "forge.membership_tiers.withdraw_error": "We couldn't withdraw your application. Please try again.",
1257
1383
  "forge.membership_tiers.withdrawing": "Withdrawing…",
1384
+ "forge.my_memberships_list.cycle_month": "month",
1385
+ "forge.my_memberships_list.cycle_year": "year",
1386
+ "forge.my_memberships_list.empty": "You are not a member of any community yet.",
1387
+ "forge.my_memberships_list.ends_on": "Ends on {date}",
1388
+ "forge.my_memberships_list.open": "Open community",
1389
+ "forge.my_memberships_list.price": "{amount} per {cycle}",
1390
+ "forge.my_memberships_list.renews_on": "Renews on {date}",
1391
+ "forge.my_memberships_list.trial_until": "Free trial until {date}",
1258
1392
  "forge.offer_button.buy_now": "Buy now",
1259
1393
  "forge.offer_button.confirm_email": "Confirm email",
1260
1394
  "forge.offer_button.continue_to_payment": "Continue to payment",
@@ -1310,6 +1444,16 @@
1310
1444
  "forge.podcast_show.empty_title": "No episodes yet.",
1311
1445
  "forge.podcast_show.not_found": "Podcast not found.",
1312
1446
  "forge.podcast_show.now_playing": "Now playing",
1447
+ "forge.post_collection_detail.locked_body": "Join to access this content",
1448
+ "forge.post_collection_detail.locked_body_tier": "Unlock with {tier_name} to access this content",
1449
+ "forge.post_collection_detail.locked_title": "Content locked",
1450
+ "forge.post_collection_detail.unlock": "Unlock",
1451
+ "forge.post_collection_detail.unlock_with": "Unlock with {tier_name}",
1452
+ "forge.post_collections_grid.locked_body": "Join to access this content",
1453
+ "forge.post_collections_grid.locked_body_tier": "Unlock with {tier_name} to access this content",
1454
+ "forge.post_collections_grid.locked_title": "Content locked",
1455
+ "forge.post_collections_grid.unlock": "Unlock",
1456
+ "forge.post_collections_grid.unlock_with": "Unlock with {tier_name}",
1313
1457
  "forge.posts_feed.empty_body": "No posts yet.",
1314
1458
  "forge.presale_code.checking": "Checking…",
1315
1459
  "forge.presale_code.input_placeholder": "Enter code",
package/src/index.ts CHANGED
@@ -1,8 +1,8 @@
1
- // Forge — the headless React SDK for TribeNest creator sites.
1
+ // Forge: the headless React SDK for TribeNest creator sites.
2
2
  //
3
3
  // Phase 1 (the spine): client factory, root provider, core types.
4
4
  // Subsequent phases add providers (auth/cart/audio), data hooks, flow hooks,
5
- // headless blocks, and SEO — see docs/code-websites-initiative.md.
5
+ // headless blocks, and SEO: see docs/code-websites-initiative.md.
6
6
 
7
7
  export { createForgeClient, getForgeWebSocketUrl } from "./client/createForgeClient";
8
8
  export type { ForgeClientOptions } from "./client/createForgeClient";
@@ -43,12 +43,12 @@ export {
43
43
  // All domain types + enums/consts (models) and WebPage.
44
44
  export * from "./types";
45
45
 
46
- // Providers (Phase 2 — moving from frontend-shared into Forge)
46
+ // Providers (Phase 2: moving from frontend-shared into Forge)
47
47
  export { AudioPlayerProvider, useAudioPlayer } from "./contexts/AudioPlayerContext";
48
48
  export type { AudioTrack } from "./contexts/AudioPlayerContext";
49
49
  export { CartProvider, useCart } from "./contexts/CartContext";
50
50
  // Variant selection, shared with the Craft themes in frontend-shared the same
51
- // way the cart and audio player are — so a code site and a Craft site cannot
51
+ // way the cart and audio player are: so a code site and a Craft site cannot
52
52
  // disagree about which version a buyer picked.
53
53
  export { useVariantSelection } from "./ui/headless/useVariantSelection";
54
54
  // Which version a cart line is. Exported from the package root because the
@@ -66,7 +66,7 @@ export {
66
66
  export type { AuthActions } from "./contexts/PublicAuthContext";
67
67
  export { PUBLIC_REFRESH_TOKEN_KEY, APP_REFRESH_TOKEN_KEY } from "./client/tokenStorage";
68
68
  export { safeRedirectPath } from "./utils/safeRedirect";
69
- // Ticket-confirmation copy decision — shared so the Forge block and the client
69
+ // Ticket-confirmation copy decision: shared so the Forge block and the client
70
70
  // app's finalise page can never tell a refunded buyer two different stories.
71
71
  export {
72
72
  getTicketOrderOutcome,
@@ -89,7 +89,7 @@ export {
89
89
  } from "./contexts/AppAuthContext";
90
90
  export { useAppAdminGuard, type AppAdminGuardResult } from "./contexts/useAppAdminGuard";
91
91
 
92
- // Data hooks (React Query) — Phase 3
92
+ // Data hooks (React Query): Phase 3
93
93
  export * from "./data/queries/useProducts";
94
94
  export * from "./data/queries/useMembership";
95
95
  export * from "./data/queries/useCourses";
@@ -151,13 +151,25 @@ export * from "./data/queries/useCallChat";
151
151
  export * from "./data/queries/useCallBreakouts";
152
152
  export * from "./data/queries/useAnalytics";
153
153
  export * from "./data/queries/useCommunity";
154
+ // Communities: the list, one community, the viewer's state in it, invites,
155
+ // upgrades and the fan's memberships across communities.
156
+ export * from "./data/queries/useCommunities";
157
+ // The provider every member-content hook reads its community from.
158
+ export {
159
+ CommunityProvider,
160
+ useCommunityContext,
161
+ useOptionalCommunityContext,
162
+ MissingCommunityProviderError,
163
+ type CommunityContextValue,
164
+ type CommunityProviderProps,
165
+ } from "./contexts/CommunityContext";
154
166
  export * from "./data/queries/useReviews";
155
167
  export * from "./data/queries/useDocuments";
156
168
  export * from "./data/queries/useNotifications";
157
169
  export * from "./data/queries/usePWA";
158
170
  export * from "./data/queries/useCertificates";
159
171
 
160
- // Content layer (Phase E) — typed/editable/localizable site content
172
+ // Content layer (Phase E): typed/editable/localizable site content
161
173
  export * from "./content";
162
174
 
163
175
  // Utils
@@ -38,4 +38,35 @@ describe("internal page links", () => {
38
38
  it("carries every page exactly once", () => {
39
39
  expect(new Set(INTERNAL_PAGES).size).toBe(INTERNAL_PAGES.length);
40
40
  });
41
+
42
+ it("carries exactly the community pages, and none of the removed single-community ones", () => {
43
+ const community = INTERNAL_PAGES.filter((page) => page.startsWith("communities"));
44
+ expect(community).toEqual([
45
+ "communities",
46
+ "communities/$slug",
47
+ "communities/$slug/home",
48
+ "communities/$slug/home/calendar",
49
+ "communities/$slug/home/chat",
50
+ "communities/$slug/home/collections",
51
+ "communities/$slug/home/collections/$collectionId",
52
+ "communities/$slug/home/leaderboard",
53
+ "communities/$slug/home/notifications",
54
+ "communities/$slug/home/posts",
55
+ "communities/$slug/home/profile/$accountId",
56
+ "communities/$slug/home/spaces",
57
+ "communities/$slug/home/spaces/$spaceId",
58
+ "communities/$slug/home/spaces/posts/$postId",
59
+ ]);
60
+ const removed = INTERNAL_PAGES.filter((page) => /^(membership|members|community|chat)(\/|$)/.test(page));
61
+ expect(removed).toEqual([]);
62
+ });
63
+
64
+ it("builds community paths with the slug and recognises them", () => {
65
+ expect(internalPath("communities/$slug/home/spaces/posts/$postId", { slug: "fan-club", postId: "p1" })).toBe(
66
+ "/i/communities/fan-club/home/spaces/posts/p1",
67
+ );
68
+ expect(isInternalPage("communities/fan-club/home")).toBe(true);
69
+ expect(isInternalPage("members/spaces")).toBe(false);
70
+ expect(isInternalPage("membership/checkout")).toBe(false);
71
+ });
41
72
  });
@@ -7,9 +7,13 @@
7
7
  * for TanStack to know about. That is the reason this helper exists, and it is
8
8
  * why the build guide forbids writing an `/i/` path by hand.
9
9
  *
10
- * The page list is the CONTRACT between a site and the hosted pages, so it only
11
- * ever grows. Removing a page breaks links on sites already published, so a
12
- * retired page keeps its entry and redirects.
10
+ * The page list is the CONTRACT between a site and the hosted pages, so it
11
+ * normally only grows: removing a page breaks links on sites already published.
12
+ * The one deliberate exception is Communities (2026-09-27). A profile can now
13
+ * run several communities, so the single-community member pages (`membership`,
14
+ * `members/*`, `community/*`, `chat`) were removed with no redirects, because
15
+ * nobody used them yet and there is no one community they could redirect to.
16
+ * Every member page now lives under `communities/$slug`.
13
17
  *
14
18
  * <Link {...internalLink("store/$slug", { slug: product.slug })}>Buy</Link>
15
19
  * navigate(internalLink("checkout"))
@@ -28,7 +32,6 @@ export const INTERNAL_PAGES = [
28
32
  "blog",
29
33
  "blog-category/$slug",
30
34
  "blog/$slug",
31
- "chat",
32
35
  "checkout",
33
36
  "checkout/finalise",
34
37
  "checkout/resume",
@@ -37,11 +40,20 @@ export const INTERNAL_PAGES = [
37
40
  "coaching/$slug/finalise",
38
41
  "cohorts/$cohortId",
39
42
  "cohorts/$cohortId/finalise",
40
- "community",
41
- "community/leaderboard",
42
- "community/notifications",
43
- "community/posts/$postId",
44
- "community/spaces/$spaceId",
43
+ "communities",
44
+ "communities/$slug",
45
+ "communities/$slug/home",
46
+ "communities/$slug/home/calendar",
47
+ "communities/$slug/home/chat",
48
+ "communities/$slug/home/collections",
49
+ "communities/$slug/home/collections/$collectionId",
50
+ "communities/$slug/home/leaderboard",
51
+ "communities/$slug/home/notifications",
52
+ "communities/$slug/home/posts",
53
+ "communities/$slug/home/profile/$accountId",
54
+ "communities/$slug/home/spaces",
55
+ "communities/$slug/home/spaces/$spaceId",
56
+ "communities/$slug/home/spaces/posts/$postId",
45
57
  "confirm-subscription",
46
58
  "course-access/$accessId",
47
59
  "courses",
@@ -65,19 +77,6 @@ export const INTERNAL_PAGES = [
65
77
  "live",
66
78
  "live/$broadcastId",
67
79
  "login",
68
- "members",
69
- "members/calendar",
70
- "members/chat",
71
- "members/collections",
72
- "members/collections/$slug",
73
- "members/spaces",
74
- "members/spaces/$slug",
75
- "members/spaces/leaderboard",
76
- "members/spaces/notifications",
77
- "members/spaces/profile/$accountId",
78
- "members/spaces/thread/$postId",
79
- "membership",
80
- "membership/checkout",
81
80
  "payment-link/$paymentLinkId",
82
81
  "payment-link/$paymentLinkId/finalise",
83
82
  "podcasts",
@@ -270,7 +270,7 @@ describe("buildWebManifest — name/short_name fallback chain", () => {
270
270
  describe("buildWebManifest — overrides (shortcuts) merge", () => {
271
271
  const overrides: BuildWebManifestOptions["overrides"] = {
272
272
  shortcuts: [
273
- { name: "Members", short_name: "Members", url: "/i/members" },
273
+ { name: "Members", short_name: "Members", url: "/i/communities" },
274
274
  { name: "Account", short_name: "Account", url: "/i/account" },
275
275
  ],
276
276
  };
@@ -168,7 +168,7 @@ describe("forgeServiceWorkerScript — push payload contract (frozen, shared wit
168
168
  title: "New message",
169
169
  body: "hey!",
170
170
  type: "chat_message",
171
- url: "/i/chat/42",
171
+ url: "/i/communities/fan-club/home/chat",
172
172
  profileId: "prof-1",
173
173
  channelId: "chan-9",
174
174
  icon: "https://cdn.tribenest.co/p/nova/icon-192.png",
@@ -181,7 +181,7 @@ describe("forgeServiceWorkerScript — push payload contract (frozen, shared wit
181
181
  body: "hey!",
182
182
  icon: "https://cdn.tribenest.co/p/nova/icon-192.png",
183
183
  tag: "chat-chan-9",
184
- data: { url: "/i/chat/42", type: "chat_message" },
184
+ data: { url: "/i/communities/fan-club/home/chat", type: "chat_message" },
185
185
  });
186
186
  });
187
187
 
@@ -229,9 +229,9 @@ describe("forgeServiceWorkerScript — notificationclick", () => {
229
229
  it("focuses an existing client matching the deep-link URL", async () => {
230
230
  const { handlers, matchAll, openWindow } = loadSw();
231
231
  const focus = vi.fn(() => ({}));
232
- matchAll.mockResolvedValueOnce([{ url: "https://nova.tribenest.co/i/chat/42", focus }]);
232
+ matchAll.mockResolvedValueOnce([{ url: "https://nova.tribenest.co/i/communities/fan-club/home/chat", focus }]);
233
233
  const close = vi.fn();
234
- const event = fakeEvent({ notification: { close, data: { url: "/i/chat/42" } } });
234
+ const event = fakeEvent({ notification: { close, data: { url: "/i/communities/fan-club/home/chat" } } });
235
235
  handlers.notificationclick!(event);
236
236
  await event.waited;
237
237
  expect(close).toHaveBeenCalledOnce();
@@ -72,7 +72,7 @@ describe("parseMembershipGateError", () => {
72
72
  });
73
73
  });
74
74
 
75
- it("matches on CODE, not on status — a read refusal is 401/403, a checkout refusal 400", () => {
75
+ it("matches on CODE, not on status: a read refusal is 401/403, a checkout refusal 400", () => {
76
76
  // Nothing here reads a status at all; the same payload parses whichever
77
77
  // status carried it. A client switching on 401 would render this as a
78
78
  // session timeout.
@@ -113,7 +113,7 @@ describe("joinTierNames", () => {
113
113
  expect(joinTierNames(["Gold", "Silver", "Bronze"])).toBe("Gold, Silver or Bronze");
114
114
  });
115
115
 
116
- it("drops blanks — an unresolved tier must not become an empty slot", () => {
116
+ it("drops blanks: an unresolved tier must not become an empty slot", () => {
117
117
  expect(joinTierNames(["", " ", "Gold"])).toBe("Gold");
118
118
  expect(joinTierNames([])).toBe("");
119
119
  });
@@ -160,18 +160,18 @@ describe("buildMembershipGateNotice", () => {
160
160
  expect(notice!.body).toBe("This ticket is for Gold members. Already one? Sign in and it unlocks.");
161
161
  });
162
162
 
163
- it("offers the TIER to someone already signed in — never sign-in again", () => {
163
+ it("offers the TIER to someone already signed in: never sign-in again", () => {
164
164
  const notice = buildMembershipGateNotice({
165
165
  gate: gate({ reason: "membership_required", requiredTierIds: ["gold", "plat"] }),
166
166
  tierNames: ["Gold", "Platinum"],
167
167
  itemLabel: "This course",
168
- membershipPath: "/i/membership",
168
+ membershipPath: "/i/communities/fan-club?tier=gold",
169
169
  });
170
170
 
171
171
  expect(notice).toMatchObject({
172
172
  reason: "membership_required",
173
173
  actionLabel: "View Gold or Platinum",
174
- actionHref: "/i/membership",
174
+ actionHref: "/i/communities/fan-club?tier=gold",
175
175
  });
176
176
  expect(notice!.body).toBe("This course is for Gold or Platinum members. Your current plan doesn't include it.");
177
177
  // Telling a signed-in member to sign in is a loop with no exit.
@@ -214,7 +214,7 @@ describe("buildMembershipGateNotice", () => {
214
214
  });
215
215
 
216
216
  it("uses default paths when the host names none", () => {
217
- expect(buildMembershipGateNotice({ gate: gate() })!.actionHref).toBe("/membership");
217
+ expect(buildMembershipGateNotice({ gate: gate() })!.actionHref).toBe("/i/communities");
218
218
  expect(buildMembershipGateNotice({ gate: gate({ reason: "sign_in_required" }) })!.actionHref).toBe("/login");
219
219
  });
220
220
  });
@@ -8,22 +8,22 @@ import type { MembershipGateReason, PublicMembershipGate } from "../../types/mod
8
8
  *
9
9
  * The gate shipped ENFORCED and UNANNOUNCED. A logged-out buyer saw an ordinary
10
10
  * ticket, chose a seat, typed their name and their email twice, and was refused
11
- * on the last screen with a `MEMBERSHIP_GATE` 400 — the restriction was
11
+ * on the last screen with a `MEMBERSHIP_GATE` 400: the restriction was
12
12
  * discoverable only by failing it. And the "members save X, sign in" funnel the
13
13
  * feature was justified by existed on no surface at all, because nothing drew a
14
14
  * way OUT of the refusal.
15
15
  *
16
16
  * ## Why it is pure, and why it lives in `format/`
17
17
  *
18
- * There are two rendering stacks — `apps/client` and the Forge SDK that code
19
- * websites build against — and a members-only badge that says one thing on an
18
+ * There are two rendering stacks (`apps/client` and the Forge SDK that code
19
+ * websites build against), and a members-only badge that says one thing on an
20
20
  * artist's PWA and another on their website is the same bug twice. So the words
21
21
  * and the resolving ACTION are decided once, here, with no React and no client;
22
22
  * each stack owns only how it looks. `apps/client` imports this from
23
23
  * `@tribe-nest/forge/ui`, exactly as it already does for the PWYW arithmetic.
24
24
  *
25
25
  * Pure and total also means the whole mapping is testable without a DOM or a
26
- * session — and getting the two reasons backwards is the failure mode that
26
+ * session, and getting the two reasons backwards is the failure mode that
27
27
  * matters most: telling someone who is already signed in to "sign in" is a loop
28
28
  * with no exit, and telling a logged-out visitor to "upgrade" asks them to buy a
29
29
  * membership they may already hold.
@@ -94,7 +94,7 @@ export function parseMembershipGateError(error: unknown): MembershipGateRefusal
94
94
  /**
95
95
  * Is this thing refused to the caller looking at it?
96
96
  *
97
- * `null` means "nothing gates it" — which is also what every read returns while
97
+ * `null` means "nothing gates it", which is also what every read returns while
98
98
  * the `membership_entitlement_gates` switch is off. Treating an absent gate as
99
99
  * "gated and permitted" would badge every item on every site the day the field
100
100
  * shipped, so absent must read as false here and nowhere else.
@@ -110,7 +110,7 @@ export type MembershipGateNotice = {
110
110
  badge: string;
111
111
  title: string;
112
112
  body: string;
113
- /** Primary CTA label — the way OUT of the refusal, which is the whole point. */
113
+ /** Primary CTA label: the way OUT of the refusal, which is the whole point. */
114
114
  actionLabel: string;
115
115
  /**
116
116
  * Where the CTA goes. `null` only when the caller passed no path to send them
@@ -120,12 +120,16 @@ export type MembershipGateNotice = {
120
120
  actionHref: string | null;
121
121
  };
122
122
 
123
- /** Default sign-in path. Overridden per site — code websites mount theirs under `/i`. */
123
+ /** Default sign-in path. Overridden per site: code websites mount theirs under `/i`. */
124
124
  export const MEMBERSHIP_GATE_LOGIN_PATH = "/login";
125
- /** Default tier-listing path, i.e. where "become a member" actually leads. */
126
- export const MEMBERSHIP_GATE_MEMBERSHIP_PATH = "/membership";
125
+ /**
126
+ * Default "become a member" path when the tier's community is not known: the
127
+ * communities list. `useMembershipGateNotice` sends the buyer to the About page
128
+ * of the community the required tier belongs to, with that tier preselected.
129
+ */
130
+ export const MEMBERSHIP_GATE_MEMBERSHIP_PATH = "/i/communities";
127
131
 
128
- /** "Gold", "Gold or Platinum", "Gold, Silver or Bronze" — ANY one unlocks, hence "or". */
132
+ /** "Gold", "Gold or Platinum", "Gold, Silver or Bronze". ANY one unlocks, hence "or". */
129
133
  export function joinTierNames(names: string[]): string {
130
134
  const clean = names.map((name) => name.trim()).filter(Boolean);
131
135
  if (clean.length === 0) return "";
@@ -133,7 +137,7 @@ export function joinTierNames(names: string[]): string {
133
137
  return `${clean.slice(0, -1).join(", ")} or ${clean[clean.length - 1]}`;
134
138
  }
135
139
 
136
- /** `path?redirect=<here>` — a sign-in that lands back on the thing they wanted. */
140
+ /** `path?redirect=<here>`: a sign-in that lands back on the thing they wanted. */
137
141
  export function membershipSignInHref(loginPath: string, currentPath: string): string {
138
142
  const target = safeRedirectPath(currentPath, "");
139
143
  return target ? `${loginPath}?redirect=${encodeURIComponent(target)}` : loginPath;
@@ -142,7 +146,7 @@ export function membershipSignInHref(loginPath: string, currentPath: string): st
142
146
  export interface BuildMembershipGateNoticeInput {
143
147
  /** The announced gate from a listing/detail read. */
144
148
  gate?: PublicMembershipGate | null;
145
- /** A `MEMBERSHIP_GATE` refusal from a failed purchase. Wins over `gate` — it is the newer fact. */
149
+ /** A `MEMBERSHIP_GATE` refusal from a failed purchase. Wins over `gate`: it is the newer fact. */
146
150
  refusal?: MembershipGateRefusal | null;
147
151
  /**
148
152
  * Names for `requiredTierIds`, already resolved by the caller (both stacks
@@ -150,7 +154,7 @@ export interface BuildMembershipGateNoticeInput {
150
154
  * 8f3c-…" is not a sentence anyone can act on.
151
155
  */
152
156
  tierNames?: string[];
153
- /** What the thing IS, in the buyer's words — "This ticket", "This course". */
157
+ /** What the thing IS, in the buyer's words: "This ticket", "This course". */
154
158
  itemLabel?: string;
155
159
  loginPath?: string;
156
160
  membershipPath?: string;
@@ -185,7 +189,7 @@ export function buildMembershipGateNotice(
185
189
  // The API's own sentence is deliberately NOT used for this half. It is
186
190
  // written for the refusal ("You must be a member to buy this"), and what
187
191
  // an anonymous visitor needs first is the possibility that they already
188
- // are one — which is the difference between a dead end and a funnel.
192
+ // are one, which is the difference between a dead end and a funnel.
189
193
  body: tiers
190
194
  ? `${item} is for ${tiers} members. Already one? Sign in and it unlocks.`
191
195
  : `${item} is for members. Already one? Sign in and it unlocks.`,
@@ -201,7 +205,7 @@ export function buildMembershipGateNotice(
201
205
  body: tiers
202
206
  ? `${item} is for ${tiers} members. Your current plan doesn't include it.`
203
207
  : `${item} is for members. Your current plan doesn't include it.`,
204
- // NOT "sign in" — they already are, and offering it again is a loop with no
208
+ // NOT "sign in": they already are, and offering it again is a loop with no
205
209
  // exit. The only move that changes anything is joining the tier.
206
210
  actionLabel: tiers ? `View ${tiers}` : "View membership",
207
211
  actionHref: membershipPath,