@tribe-nest/forge 3.59.0 → 3.61.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 (266) hide show
  1. package/package.json +2 -1
  2. package/src/_tests/testCommunity.tsx +28 -0
  3. package/src/contexts/CartContext.tsx +34 -1
  4. package/src/contexts/CommunityContext.tsx +192 -0
  5. package/src/contexts/PublicAuthContext.tsx +15 -3
  6. package/src/data/queries/_tests/metaSignals.spec.tsx +130 -0
  7. package/src/data/queries/useAccount.ts +33 -2
  8. package/src/data/queries/useAnalytics.ts +6 -6
  9. package/src/data/queries/useCallChat.ts +2 -2
  10. package/src/data/queries/useChat.ts +52 -40
  11. package/src/data/queries/useCheckouts.ts +2 -1
  12. package/src/data/queries/useCoachingAvailability.ts +10 -1
  13. package/src/data/queries/useCommunities.ts +345 -0
  14. package/src/data/queries/useCommunity.ts +450 -102
  15. package/src/data/queries/useCommunityAgent.ts +247 -0
  16. package/src/data/queries/useCommunityCalendar.ts +150 -0
  17. package/src/data/queries/useCommunityClassroom.ts +75 -0
  18. package/src/data/queries/useCommunityMembers.ts +108 -0
  19. package/src/data/queries/useCommunityModeration.ts +327 -0
  20. package/src/data/queries/useCommunityWiki.ts +137 -0
  21. package/src/data/queries/useCourses.ts +2 -0
  22. package/src/data/queries/useEmailSignIn.ts +79 -0
  23. package/src/data/queries/useEvents.ts +6 -1
  24. package/src/data/queries/useForms.ts +5 -1
  25. package/src/data/queries/useLeadMagnet.ts +6 -2
  26. package/src/data/queries/useMembership.ts +32 -7
  27. package/src/data/queries/useMembershipCalls.ts +116 -7
  28. package/src/data/queries/useMyBookings.ts +24 -1
  29. package/src/data/queries/useMyTickets.ts +24 -1
  30. package/src/data/queries/useNotifications.ts +6 -2
  31. package/src/data/queries/useOrders.ts +2 -1
  32. package/src/data/queries/usePWA.ts +111 -35
  33. package/src/data/queries/usePostCollections.ts +28 -16
  34. package/src/data/queries/usePosts.ts +16 -14
  35. package/src/data/queries/useSiteApp.ts +130 -0
  36. package/src/data/queries/useSubscriptions.ts +28 -9
  37. package/src/i18n/de.json +891 -2
  38. package/src/i18n/en.json +891 -2
  39. package/src/index.ts +36 -7
  40. package/src/runtime/pages.spec.ts +36 -0
  41. package/src/runtime/pages.ts +52 -27
  42. package/src/server/_tests/buildWebManifest.spec.ts +1 -1
  43. package/src/server/_tests/forgeServiceWorkerScript.spec.ts +4 -4
  44. package/src/types/models.ts +11 -0
  45. package/src/ui/analytics/ForgeAnalytics.tsx +64 -34
  46. package/src/ui/analytics/PageMetaPixel.tsx +23 -8
  47. package/src/ui/analytics/_tests/ForgeAnalytics.meta.spec.tsx +129 -0
  48. package/src/ui/analytics/_tests/metaCommerceEvents.spec.tsx +159 -0
  49. package/src/ui/analytics/useMetaEvents.ts +78 -0
  50. package/src/ui/format/_tests/membershipGate.spec.ts +6 -6
  51. package/src/ui/format/membershipGate.ts +19 -15
  52. package/src/ui/headless/_tests/communityAgent.spec.tsx +519 -0
  53. package/src/ui/headless/_tests/communityCalendar.spec.tsx +195 -0
  54. package/src/ui/headless/_tests/communityClassroom.spec.tsx +120 -0
  55. package/src/ui/headless/_tests/communityGate.spec.tsx +80 -0
  56. package/src/ui/headless/_tests/communityHarness.tsx +212 -0
  57. package/src/ui/headless/_tests/communityMembers.spec.tsx +316 -0
  58. package/src/ui/headless/_tests/communityPoll.spec.tsx +389 -0
  59. package/src/ui/headless/_tests/communityProvider.spec.tsx +151 -0
  60. package/src/ui/headless/_tests/memberHomeBar.spec.ts +84 -0
  61. package/src/ui/headless/_tests/siteAppBar.spec.ts +83 -0
  62. package/src/ui/headless/_tests/siteAppHarness.ts +19 -0
  63. package/src/ui/headless/_tests/siteAppHome.spec.ts +226 -0
  64. package/src/ui/headless/_tests/siteAppPrompts.spec.ts +185 -0
  65. package/src/ui/headless/_tests/useCommunityInvite.spec.tsx +104 -0
  66. package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +596 -0
  67. package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +179 -0
  68. package/src/ui/headless/_tests/useCommunityModeration.spec.tsx +420 -0
  69. package/src/ui/headless/_tests/useCommunityReads.spec.tsx +295 -0
  70. package/src/ui/headless/_tests/useCommunityUpgrade.spec.tsx +157 -0
  71. package/src/ui/headless/_tests/useCommunityWiki.spec.tsx +224 -0
  72. package/src/ui/headless/_tests/useSiteApp.spec.tsx +52 -0
  73. package/src/ui/headless/_tests/wikiMediaImage.spec.tsx +132 -0
  74. package/src/ui/headless/auth/useEmailCodeSignIn.ts +186 -0
  75. package/src/ui/headless/auth/useSignupForm.ts +30 -3
  76. package/src/ui/headless/chat/useChatAttachmentUpload.ts +9 -6
  77. package/src/ui/headless/chat/useChatRoom.ts +35 -1
  78. package/src/ui/headless/communities/CommunityGate.tsx +67 -0
  79. package/src/ui/headless/communities/index.ts +96 -0
  80. package/src/ui/headless/communities/paths.ts +36 -0
  81. package/src/ui/headless/communities/useCommunity.ts +140 -0
  82. package/src/ui/headless/communities/useCommunityAbout.ts +71 -0
  83. package/src/ui/headless/communities/useCommunityCalendar.ts +156 -0
  84. package/src/ui/headless/communities/useCommunityClassroom.ts +84 -0
  85. package/src/ui/headless/communities/useCommunityHome.ts +91 -0
  86. package/src/ui/headless/communities/useCommunityInvite.ts +116 -0
  87. package/src/ui/headless/communities/useCommunityJoin.ts +494 -0
  88. package/src/ui/headless/communities/useCommunityMembership.ts +182 -0
  89. package/src/ui/headless/communities/useCommunityUpgrade.ts +213 -0
  90. package/src/ui/headless/communities/useCommunityWiki.ts +266 -0
  91. package/src/ui/headless/communities/useMyMemberships.ts +59 -0
  92. package/src/ui/headless/community/communityPoll.ts +297 -0
  93. package/src/ui/headless/community/countries.ts +123 -0
  94. package/src/ui/headless/community/index.ts +81 -1
  95. package/src/ui/headless/community/linkPreview.ts +71 -0
  96. package/src/ui/headless/community/richLexical.ts +27 -2
  97. package/src/ui/headless/community/useCommunityAgent.ts +285 -0
  98. package/src/ui/headless/community/useCommunityAttachmentUpload.ts +7 -3
  99. package/src/ui/headless/community/useCommunityMembers.ts +175 -0
  100. package/src/ui/headless/community/useCommunityModeration.ts +300 -0
  101. package/src/ui/headless/community/useCommunitySpaceFeed.ts +22 -4
  102. package/src/ui/headless/community/useCommunityThread.ts +16 -3
  103. package/src/ui/headless/community/useMentionComposer.ts +17 -9
  104. package/src/ui/headless/community/useRichMentionComposer.ts +21 -5
  105. package/src/ui/headless/downloads/index.ts +8 -0
  106. package/src/ui/headless/downloads/useDownloadPage.ts +134 -0
  107. package/src/ui/headless/index.ts +21 -4
  108. package/src/ui/headless/memberHome/index.ts +11 -1
  109. package/src/ui/headless/memberHome/memberHomeBar.ts +91 -0
  110. package/src/ui/headless/memberHome/useMemberHomeNav.ts +84 -44
  111. package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +8 -0
  112. package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +5 -4
  113. package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +1 -1
  114. package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +1 -1
  115. package/src/ui/headless/membership/useMembershipCheckout.ts +89 -22
  116. package/src/ui/headless/membership/useMembershipGateNotice.ts +25 -7
  117. package/src/ui/headless/portal/index.ts +57 -0
  118. package/src/ui/headless/portal/usePortal.ts +274 -0
  119. package/src/ui/headless/portal/usePortalFiles.ts +68 -0
  120. package/src/ui/headless/portal/usePortalMessages.ts +189 -0
  121. package/src/ui/headless/portal/usePortalPreview.ts +142 -0
  122. package/src/ui/headless/posts/upsell.ts +54 -8
  123. package/src/ui/headless/posts/useMemberPostsFeed.ts +6 -5
  124. package/src/ui/headless/posts/usePostCollectionDetail.ts +7 -4
  125. package/src/ui/headless/posts/usePostCollectionsList.ts +6 -5
  126. package/src/ui/headless/posts/usePostItem.ts +6 -5
  127. package/src/ui/headless/siteApp/appBar.ts +102 -0
  128. package/src/ui/headless/siteApp/appHome.ts +344 -0
  129. package/src/ui/headless/siteApp/appPrompts.ts +229 -0
  130. package/src/ui/headless/siteApp/index.ts +58 -0
  131. package/src/ui/headless/siteApp/useAppHome.ts +210 -0
  132. package/src/ui/headless/siteApp/useAppInstallRequest.ts +16 -0
  133. package/src/ui/headless/work/index.ts +2 -0
  134. package/src/ui/headless/work/useWorkPortal.ts +37 -26
  135. package/src/ui/index.ts +28 -12
  136. package/src/ui/styled/AccountDashboard.tsx +754 -602
  137. package/src/ui/styled/AiAgentWidget.tsx +13 -1
  138. package/src/ui/styled/ChatRoom.tsx +126 -16
  139. package/src/ui/styled/Checkout.tsx +8 -0
  140. package/src/ui/styled/CheckoutConfirmation.tsx +8 -0
  141. package/src/ui/styled/CoachingConfirmation.tsx +8 -0
  142. package/src/ui/styled/CoachingDetail.tsx +2 -0
  143. package/src/ui/styled/CourseConfirmation.tsx +5 -0
  144. package/src/ui/styled/CourseDetail.tsx +2 -0
  145. package/src/ui/styled/DownloadPage.tsx +263 -0
  146. package/src/ui/styled/EventConfirmation.tsx +10 -0
  147. package/src/ui/styled/EventDetail.tsx +10 -0
  148. package/src/ui/styled/FilmLibrary.tsx +22 -9
  149. package/src/ui/styled/MembershipCheckout.tsx +37 -17
  150. package/src/ui/styled/MembershipTierCallout.tsx +20 -1
  151. package/src/ui/styled/MembershipTiers.tsx +49 -11
  152. package/src/ui/styled/MusicLinkPage.tsx +5 -5
  153. package/src/ui/styled/Paywall.tsx +1 -1
  154. package/src/ui/styled/ProductDetail.tsx +12 -0
  155. package/src/ui/styled/PwaRegistration.tsx +5 -1
  156. package/src/ui/styled/SignupForm.tsx +18 -3
  157. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +6 -6
  158. package/src/ui/styled/_tests/AccountDashboardMemberships.spec.tsx +151 -0
  159. package/src/ui/styled/_tests/AccountDashboardRentals.spec.tsx +1 -1
  160. package/src/ui/styled/_tests/AccountNotifications.spec.tsx +135 -0
  161. package/src/ui/styled/_tests/CourseCallsSection.spec.tsx +7 -1
  162. package/src/ui/styled/_tests/DownloadPage.spec.tsx +68 -0
  163. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +54 -11
  164. package/src/ui/styled/_tests/membershipTierCallout.spec.tsx +1 -1
  165. package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +1 -1
  166. package/src/ui/styled/app/AppBar.tsx +298 -0
  167. package/src/ui/styled/app/AppHome.tsx +1139 -0
  168. package/src/ui/styled/app/AppHomeMark.tsx +66 -0
  169. package/src/ui/styled/app/AppPrompts.tsx +464 -0
  170. package/src/ui/styled/app/_tests/AppBar.spec.tsx +96 -0
  171. package/src/ui/styled/app/_tests/AppHome.spec.tsx +209 -0
  172. package/src/ui/styled/app/_tests/AppPrompts.spec.tsx +232 -0
  173. package/src/ui/styled/app/index.ts +7 -0
  174. package/src/ui/styled/chat/ChatThreadPanel.tsx +29 -4
  175. package/src/ui/styled/chat/MessageBubble.tsx +43 -6
  176. package/src/ui/styled/communities/CommunitiesList.tsx +88 -0
  177. package/src/ui/styled/communities/CommunityAbout.tsx +145 -0
  178. package/src/ui/styled/communities/CommunityCalendar.tsx +484 -0
  179. package/src/ui/styled/communities/CommunityClassroom.tsx +302 -0
  180. package/src/ui/styled/communities/CommunityHome.tsx +298 -0
  181. package/src/ui/styled/communities/CommunityJoinSheet.tsx +544 -0
  182. package/src/ui/styled/communities/CommunityMembershipMenu.tsx +249 -0
  183. package/src/ui/styled/communities/CommunityPending.tsx +49 -0
  184. package/src/ui/styled/communities/CommunityPublicWiki.tsx +75 -0
  185. package/src/ui/styled/communities/CommunityUpgradeSheet.tsx +137 -0
  186. package/src/ui/styled/communities/CommunityWiki.tsx +289 -0
  187. package/src/ui/styled/communities/MyMembershipsList.tsx +115 -0
  188. package/src/ui/styled/communities/_tests/CommunityAppHome.spec.tsx +92 -0
  189. package/src/ui/styled/communities/_tests/CommunityClassroomCalendar.spec.tsx +453 -0
  190. package/src/ui/styled/communities/_tests/CommunityMobile.spec.tsx +399 -0
  191. package/src/ui/styled/communities/_tests/CommunityPages.spec.tsx +102 -0
  192. package/src/ui/styled/communities/_tests/CommunityWiki.spec.tsx +223 -0
  193. package/src/ui/styled/communities/index.ts +19 -0
  194. package/src/ui/styled/communities/shared.tsx +177 -0
  195. package/src/ui/styled/community/CommunityAgent.tsx +286 -0
  196. package/src/ui/styled/community/CommunityComposer.tsx +266 -15
  197. package/src/ui/styled/community/CommunityFeed.tsx +50 -1
  198. package/src/ui/styled/community/CommunityLinkPreview.tsx +159 -0
  199. package/src/ui/styled/community/CommunityMemberGlobe.tsx +171 -0
  200. package/src/ui/styled/community/CommunityMemberProfile.tsx +137 -4
  201. package/src/ui/styled/community/CommunityMembers.tsx +387 -0
  202. package/src/ui/styled/community/CommunityModeration.tsx +587 -0
  203. package/src/ui/styled/community/CommunityModerationState.tsx +127 -0
  204. package/src/ui/styled/community/CommunityModeratorMenu.tsx +317 -0
  205. package/src/ui/styled/community/CommunityNotifications.tsx +52 -6
  206. package/src/ui/styled/community/CommunityPollCard.tsx +250 -0
  207. package/src/ui/styled/community/CommunityPostCard.tsx +110 -34
  208. package/src/ui/styled/community/CommunityPostDetail.tsx +157 -71
  209. package/src/ui/styled/community/CommunityRichText.tsx +177 -32
  210. package/src/ui/styled/community/CommunitySpaces.tsx +49 -14
  211. package/src/ui/styled/community/_tests/CommunityAgent.spec.tsx +571 -0
  212. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +4 -1
  213. package/src/ui/styled/community/_tests/CommunityLinkPreview.spec.tsx +167 -0
  214. package/src/ui/styled/community/_tests/CommunityMembers.spec.tsx +137 -0
  215. package/src/ui/styled/community/_tests/CommunityModeration.spec.tsx +291 -0
  216. package/src/ui/styled/community/_tests/CommunityPoll.spec.tsx +386 -0
  217. package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +4 -1
  218. package/src/ui/styled/community/index.ts +37 -2
  219. package/src/ui/styled/forge-utilities.css +253 -0
  220. package/src/ui/styled/members/CallRecordingActions.tsx +182 -0
  221. package/src/ui/styled/members/CallTranscriptViewer.tsx +424 -0
  222. package/src/ui/styled/members/MemberCallsCalendar.tsx +41 -54
  223. package/src/ui/styled/members/MemberHomeLayout.tsx +327 -29
  224. package/src/ui/styled/members/MemberPostCard.tsx +9 -5
  225. package/src/ui/styled/members/MemberPostsFeed.tsx +3 -2
  226. package/src/ui/styled/members/PostCollectionDetail.tsx +18 -10
  227. package/src/ui/styled/members/PostCollectionsGrid.tsx +24 -10
  228. package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +167 -8
  229. package/src/ui/styled/members/callTranscriptText.ts +67 -0
  230. package/src/ui/styled/members/index.ts +8 -0
  231. package/src/ui/styled/portal/PortalAccountSection.tsx +61 -0
  232. package/src/ui/styled/portal/PortalActivity.tsx +167 -0
  233. package/src/ui/styled/portal/PortalBilling.tsx +197 -0
  234. package/src/ui/styled/portal/PortalFiles.tsx +279 -0
  235. package/src/ui/styled/portal/PortalHome.tsx +401 -0
  236. package/src/ui/styled/portal/PortalMessages.tsx +300 -0
  237. package/src/ui/styled/portal/PortalPreviewBanner.tsx +75 -0
  238. package/src/ui/styled/portal/PortalProjects.tsx +407 -0
  239. package/src/ui/styled/portal/PortalShell.tsx +397 -0
  240. package/src/ui/styled/portal/_tests/PortalAppHome.spec.tsx +90 -0
  241. package/src/ui/styled/portal/_tests/PortalFiles.spec.tsx +159 -0
  242. package/src/ui/styled/portal/_tests/PortalHome.spec.tsx +148 -0
  243. package/src/ui/styled/portal/_tests/PortalMessages.spec.tsx +168 -0
  244. package/src/ui/styled/portal/_tests/PortalPreviewBanner.spec.tsx +132 -0
  245. package/src/ui/styled/portal/_tests/portalNav.spec.ts +123 -0
  246. package/src/ui/styled/portal/index.ts +13 -0
  247. package/src/ui/styled/portal/portalNav.ts +260 -0
  248. package/src/ui/styled/portal/shared.tsx +206 -0
  249. package/src/ui/styled/work/WorkInviteAccept.tsx +228 -168
  250. package/src/ui/styled/work/WorkPortalProjects.tsx +68 -120
  251. package/src/ui/styled/work/WorkProjectDocuments.tsx +99 -128
  252. package/src/ui/styled/work/WorkProjectInvoices.tsx +91 -86
  253. package/src/ui/styled/work/WorkProjectReport.tsx +11 -17
  254. package/src/ui/styled/work/WorkReportBody.tsx +223 -248
  255. package/src/ui/styled/work/WorkTaskAttachments.tsx +23 -29
  256. package/src/ui/styled/work/WorkTaskComments.tsx +75 -102
  257. package/src/ui/styled/work/WorkTaskDetail.tsx +48 -59
  258. package/src/ui/styled/work/WorkTokenReport.tsx +8 -16
  259. package/src/ui/styled/work/index.ts +1 -1
  260. package/src/utils/_tests/metaEvents.spec.ts +264 -0
  261. package/src/utils/_tests/safeRedirect.spec.ts +2 -2
  262. package/src/utils/landing.ts +19 -6
  263. package/src/utils/metaEvents.ts +302 -0
  264. package/src/ui/headless/membership/MembershipGate.tsx +0 -44
  265. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +0 -149
  266. package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +0 -105
@@ -4,10 +4,10 @@ import type { AudioTrack } from "../../../contexts/AudioPlayerContext";
4
4
  import { usePublicAuth } from "../../../contexts/PublicAuthContext";
5
5
  import { useGetMembershipTiers } from "../../../data/queries/useMembership";
6
6
  import { usePostEngagement } from "../engagement/usePostEngagement";
7
- import { buildPostUpsell, type PostUpsell, type UpsellPathOptions } from "./upsell";
7
+ import { buildPostUpsell, useUpsellContext, type PostUpsell, type UpsellPathOptions } from "./upsell";
8
8
 
9
9
  export interface UsePostItemOptions extends UpsellPathOptions {
10
- /** Other audio posts in the feed — becomes the continue-playing queue. */
10
+ /** Other audio posts in the feed: becomes the continue-playing queue. */
11
11
  otherAudioPosts?: IPublicPost[];
12
12
  /** Shown as the audio track artist (the craft members area uses the profile name). */
13
13
  artistName?: string;
@@ -28,7 +28,7 @@ const toTrack = (post: IPublicPost, media: IMedia, artistName?: string): AudioTr
28
28
  });
29
29
 
30
30
  /**
31
- * Headless single-post behavior — the Forge port of the craft `PostItem` minus
31
+ * Headless single-post behavior: the Forge port of the craft `PostItem` minus
32
32
  * rendering: engagement (like/save/comment via `usePostEngagement`), media
33
33
  * selection (image/video/audio/document), the audio track + queue for the
34
34
  * Forge audio player, and the locked-post upsell state. The server already
@@ -36,6 +36,7 @@ const toTrack = (post: IPublicPost, media: IMedia, artistName?: string): AudioTr
36
36
  */
37
37
  export function usePostItem(post: IPublicPost, opts: UsePostItemOptions = {}) {
38
38
  const { isAuthenticated } = usePublicAuth();
39
+ const upsellContext = useUpsellContext(opts);
39
40
  const { data: tiers } = useGetMembershipTiers();
40
41
 
41
42
  const engagement = usePostEngagement(post.id, "post", {
@@ -64,13 +65,13 @@ export function usePostItem(post: IPublicPost, opts: UsePostItemOptions = {}) {
64
65
  [opts.otherAudioPosts, opts.artistName],
65
66
  );
66
67
 
67
- const upsell: PostUpsell = buildPostUpsell(post, tiers, isAuthenticated, opts);
68
+ const upsell: PostUpsell = buildPostUpsell(post, tiers, isAuthenticated, upsellContext.paths, upsellContext.unlockWith);
68
69
 
69
70
  return {
70
71
  post,
71
72
  kind,
72
73
  isLocked,
73
- /** First media item (null when locked — the server ships none). */
74
+ /** First media item (null when locked: the server ships none). */
74
75
  media,
75
76
  audioTrack,
76
77
  audioQueue,
@@ -0,0 +1,102 @@
1
+ import { internalPath } from "../../../runtime/pages";
2
+ import {
3
+ SITE_APP_BAR_KEYS,
4
+ SITE_APP_BAR_MAX_ITEMS,
5
+ SITE_APP_DEFAULT_BAR,
6
+ type SiteAppBarKey,
7
+ } from "../../../data/queries/useSiteApp";
8
+
9
+ /**
10
+ * The site app's bottom bar: Home, then the creator's chosen items (at most
11
+ * three), then More, which opens a sheet with every other destination. Kept
12
+ * apart from how it is drawn so the bar, the sheet and a spec can never
13
+ * disagree about what goes where (the same split as `buildMemberHomeBar`).
14
+ */
15
+
16
+ export type AppBarKey = "home" | SiteAppBarKey;
17
+
18
+ export interface AppBarItem {
19
+ key: AppBarKey;
20
+ href: string;
21
+ active: boolean;
22
+ }
23
+
24
+ export interface AppBarModel {
25
+ /** Home first, then the configured items. More is not an item: it is always drawn last. */
26
+ bar: AppBarItem[];
27
+ /** Every destination not in the bar, in the canonical order. */
28
+ more: AppBarItem[];
29
+ /** The page being shown lives in the sheet, so More is the highlighted button. */
30
+ moreActive: boolean;
31
+ }
32
+
33
+ /** Where each destination goes. Inbox is the member's notifications. */
34
+ export const APP_BAR_HREFS: Readonly<Record<SiteAppBarKey, string>> = {
35
+ communities: internalPath("communities"),
36
+ portal: internalPath("account"),
37
+ inbox: internalPath("account/notifications"),
38
+ events: internalPath("events"),
39
+ courses: internalPath("courses"),
40
+ shop: internalPath("store"),
41
+ };
42
+
43
+ /**
44
+ * The configured keys made safe: unknown keys and repeats dropped, at most
45
+ * three. A missing or empty list is the contract's default.
46
+ */
47
+ export function normaliseBarItems(items: readonly string[] | null | undefined): SiteAppBarKey[] {
48
+ if (!items || items.length === 0) return [...SITE_APP_DEFAULT_BAR];
49
+ const known = new Set<string>(SITE_APP_BAR_KEYS);
50
+ const out: SiteAppBarKey[] = [];
51
+ for (const key of items) {
52
+ if (!known.has(key) || out.includes(key as SiteAppBarKey)) continue;
53
+ out.push(key as SiteAppBarKey);
54
+ if (out.length === SITE_APP_BAR_MAX_ITEMS) break;
55
+ }
56
+ return out;
57
+ }
58
+
59
+ /** Strip a query and hash, and a trailing slash, so `/i/events/` and `/i/events?x=1` compare equal. */
60
+ function clean(path: string): string {
61
+ const bare = path.split(/[?#]/)[0] ?? "";
62
+ return bare.length > 1 ? bare.replace(/\/+$/, "") : bare;
63
+ }
64
+
65
+ /**
66
+ * Is `currentPath` this destination? Home matches only itself; the others match
67
+ * their own page and anything below it, except that the portal does not claim
68
+ * the inbox, which lives under it.
69
+ */
70
+ export function isAppBarItemActive(key: AppBarKey, href: string, currentPath: string | null | undefined): boolean {
71
+ if (!currentPath) return false;
72
+ const here = clean(currentPath);
73
+ const target = clean(href);
74
+ if (key === "home") return here === target;
75
+ if (key === "portal" && isAppBarItemActive("inbox", APP_BAR_HREFS.inbox, here)) return false;
76
+ return here === target || here.startsWith(`${target}/`);
77
+ }
78
+
79
+ /**
80
+ * Decide what sits in the bar and what goes behind More. Pure.
81
+ *
82
+ * Every destination appears exactly once, in the bar or in the sheet.
83
+ */
84
+ export function buildAppBar({
85
+ barItems,
86
+ homeHref,
87
+ currentPath,
88
+ }: {
89
+ barItems: readonly string[] | null | undefined;
90
+ homeHref: string;
91
+ currentPath?: string | null;
92
+ }): AppBarModel {
93
+ const chosen = normaliseBarItems(barItems);
94
+ const item = (key: AppBarKey, href: string): AppBarItem => ({
95
+ key,
96
+ href,
97
+ active: isAppBarItemActive(key, href, currentPath),
98
+ });
99
+ const bar = [item("home", homeHref), ...chosen.map((key) => item(key, APP_BAR_HREFS[key]))];
100
+ const more = SITE_APP_BAR_KEYS.filter((key) => !chosen.includes(key)).map((key) => item(key, APP_BAR_HREFS[key]));
101
+ return { bar, more, moreActive: more.some((entry) => entry.active) };
102
+ }
@@ -0,0 +1,344 @@
1
+ import { internalPath } from "../../../runtime/pages";
2
+ import { bookingCallWindow } from "../../media/bookingWindow";
3
+ import { SITE_APP_SECTION_KEYS, type SiteAppHomeSection, type SiteAppSectionKey } from "../../../data/queries/useSiteApp";
4
+ import type { PortalAttentionItem, PortalSectionKey, PortalSummary } from "../portal/usePortal";
5
+ import type { MyBooking } from "../../../data/queries/useMyBookings";
6
+ import type { MyTicket } from "../../../data/queries/useMyTickets";
7
+ import type { MyCourse } from "../../../data/queries/useMyCourses";
8
+ import type { MyMembershipCall } from "../../../data/queries/useMembershipCalls";
9
+ import type { CourseAccessData, IEvent, IPublicProduct, PublicCourse } from "../../../types/models";
10
+
11
+ /**
12
+ * The site app's home (`/i/app`): the sections the creator configured, in their
13
+ * order, each drawn only when it has something to show. The portal is the one
14
+ * exception: it is always drawn, because it is where everything a member owns
15
+ * lives. Everything here is pure, so the order, the visibility and the
16
+ * empty-skip are decided in one place a spec can read.
17
+ */
18
+
19
+ // ---- Portal tiles -------------------------------------------------------------------
20
+
21
+ export type AppPortalTileKey = "projects" | "bookings" | "invoices" | "documents" | "courses" | "orders" | "tickets" | "saved" | "settings";
22
+
23
+ export interface AppPortalTile {
24
+ key: AppPortalTileKey;
25
+ href: string;
26
+ /** How many things in this section need the person (the tile's badge). 0 draws none. */
27
+ attention: number;
28
+ }
29
+
30
+ /** The tiles, in the order the grid draws them, each with the summary count that decides it. */
31
+ export const APP_PORTAL_TILES: ReadonlyArray<{ key: Exclude<AppPortalTileKey, "settings">; countKey: PortalSectionKey; href: string }> = [
32
+ { key: "projects", countKey: "projects", href: internalPath("account/projects") },
33
+ { key: "bookings", countKey: "bookings", href: internalPath("account/bookings") },
34
+ { key: "invoices", countKey: "invoices", href: internalPath("account/invoices") },
35
+ { key: "documents", countKey: "documents", href: internalPath("account/documents") },
36
+ { key: "courses", countKey: "courses", href: internalPath("account/courses") },
37
+ { key: "orders", countKey: "orders", href: internalPath("account/orders") },
38
+ { key: "tickets", countKey: "tickets", href: internalPath("account/tickets") },
39
+ { key: "saved", countKey: "saved", href: internalPath("account/saved") },
40
+ ];
41
+
42
+ const ATTENTION_TILE: Record<PortalAttentionItem["kind"], AppPortalTileKey | null> = {
43
+ invoice_due: "invoices",
44
+ invoice_overdue: "invoices",
45
+ document_to_sign: "documents",
46
+ approval_requested: "projects",
47
+ membership_payment_failed: null,
48
+ message_unread: null,
49
+ };
50
+
51
+ /**
52
+ * The portal grid: a tile for each section the person has anything in, then
53
+ * Settings so a new fan's grid is never empty and a full one is exactly eight.
54
+ */
55
+ export function buildPortalTiles(summary: PortalSummary | null | undefined): AppPortalTile[] {
56
+ const attention: Partial<Record<AppPortalTileKey, number>> = {};
57
+ for (const item of summary?.attention ?? []) {
58
+ const tile = ATTENTION_TILE[item.kind];
59
+ if (tile) attention[tile] = (attention[tile] ?? 0) + 1;
60
+ }
61
+ const tiles: AppPortalTile[] = APP_PORTAL_TILES.filter((tile) => (summary?.sections?.[tile.countKey] ?? 0) > 0).map(
62
+ (tile) => ({ key: tile.key, href: tile.href, attention: attention[tile.key] ?? 0 }),
63
+ );
64
+ if (tiles.length < APP_PORTAL_TILES.length) {
65
+ tiles.push({ key: "settings", href: internalPath("account/settings"), attention: 0 });
66
+ }
67
+ return tiles;
68
+ }
69
+
70
+ // ---- Needs you ----------------------------------------------------------------------
71
+
72
+ export interface AppAttentionAction {
73
+ item: PortalAttentionItem;
74
+ /** Which verb the button says. */
75
+ action: "pay" | "review" | "reply" | "update";
76
+ href: string;
77
+ }
78
+
79
+ /** Where each portal attention item is acted on: the exact thing, one tap away. */
80
+ export function attentionAction(item: PortalAttentionItem): AppAttentionAction {
81
+ switch (item.kind) {
82
+ case "invoice_due":
83
+ case "invoice_overdue":
84
+ return { item, action: "pay", href: internalPath("invoice/$invoiceId", { invoiceId: item.id }) };
85
+ case "approval_requested":
86
+ return {
87
+ item,
88
+ action: "review",
89
+ href: `${internalPath("account/projects/$projectId", { projectId: item.projectId })}#approvals`,
90
+ };
91
+ case "document_to_sign":
92
+ return { item, action: "review", href: internalPath("documents/$token", { token: item.token }) };
93
+ case "message_unread":
94
+ return { item, action: "reply", href: internalPath("account/messages") };
95
+ case "membership_payment_failed":
96
+ return { item, action: "update", href: internalPath("communities/$slug/home", { slug: item.communitySlug }) };
97
+ }
98
+ }
99
+
100
+ // ---- Coming up ----------------------------------------------------------------------
101
+
102
+ export interface AppNextCall {
103
+ id: string;
104
+ title: string;
105
+ startsAt: string;
106
+ /** The call is open right now. */
107
+ joinable: boolean;
108
+ /** The room when joinable, else where the session is listed. */
109
+ href: string;
110
+ /** Who it is with: the audience label of a member call, or null for a booked session. */
111
+ context: string | null;
112
+ }
113
+
114
+ export interface AppNextTicket {
115
+ id: string;
116
+ title: string;
117
+ startsAt: string;
118
+ /** Passes on the order, at least 1. */
119
+ count: number;
120
+ href: string;
121
+ }
122
+
123
+ /**
124
+ * The next call: a member call or a booked session, whichever comes first and
125
+ * has not ended. An open one is joinable.
126
+ */
127
+ export function pickNextCall(
128
+ calls: readonly MyMembershipCall[] | null | undefined,
129
+ bookings: readonly MyBooking[] | null | undefined,
130
+ now: Date,
131
+ ): AppNextCall | null {
132
+ const candidates: AppNextCall[] = [];
133
+ for (const call of calls ?? []) {
134
+ if (Date.parse(call.endsAt) < now.getTime()) continue;
135
+ const joinable = call.isOpen && !!call.callPublicId;
136
+ candidates.push({
137
+ id: `call:${call.id}`,
138
+ title: call.title,
139
+ startsAt: call.startsAt,
140
+ joinable,
141
+ // The call page itself says when the room opens, so it is the link either way.
142
+ href: call.callPublicId ? internalPath("video-calls/$callId", { callId: call.callPublicId }) : internalPath("account"),
143
+ context: call.audiences?.[0]?.label ?? null,
144
+ });
145
+ }
146
+ for (const booking of bookings ?? []) {
147
+ if (booking.status !== "confirmed") continue;
148
+ if (Date.parse(booking.sessionEndTime) < now.getTime()) continue;
149
+ const joinable = bookingCallWindow(booking, now).isOpen && !!booking.videoCallId;
150
+ candidates.push({
151
+ id: `booking:${booking.id}`,
152
+ title: booking.coachingProductTitle,
153
+ startsAt: booking.sessionStartTime,
154
+ joinable,
155
+ href: joinable ? internalPath("video-calls/$callId", { callId: booking.videoCallId! }) : internalPath("account/bookings"),
156
+ context: null,
157
+ });
158
+ }
159
+ candidates.sort((a, b) => Date.parse(a.startsAt) - Date.parse(b.startsAt));
160
+ return candidates[0] ?? null;
161
+ }
162
+
163
+ /** The next event the person holds a ticket for. Cancelled and refunded orders do not count. */
164
+ export function pickNextTicket(tickets: readonly MyTicket[] | null | undefined, now: Date): AppNextTicket | null {
165
+ const upcoming = (tickets ?? [])
166
+ .filter((ticket) => !ticket.selfCancelledAt && ticket.status !== "cancelled" && ticket.status !== "refunded")
167
+ .filter((ticket) => Date.parse(ticket.eventEndDateTime ?? ticket.eventDateTime) >= now.getTime())
168
+ .sort((a, b) => Date.parse(a.eventDateTime) - Date.parse(b.eventDateTime));
169
+ const next = upcoming[0];
170
+ if (!next) return null;
171
+ const count = (next.items ?? []).reduce((sum, item) => sum + (Number(item.quantity) || 0), 0);
172
+ return {
173
+ id: next.id,
174
+ title: next.eventTitle,
175
+ startsAt: next.eventDateTime,
176
+ count: Math.max(1, count),
177
+ href: internalPath("account/tickets"),
178
+ };
179
+ }
180
+
181
+ // ---- Continue learning -------------------------------------------------------------
182
+
183
+ /** The course to carry on with: one started and not finished, else the newest not finished. */
184
+ export function pickLearningCourse(courses: readonly MyCourse[] | null | undefined): MyCourse | null {
185
+ const open = (courses ?? []).filter((course) => !course.completedAt);
186
+ return open.find((course) => !!course.currentLessonId) ?? open[0] ?? null;
187
+ }
188
+
189
+ export interface AppCourseProgress {
190
+ done: number;
191
+ total: number;
192
+ /** 0 to 1. */
193
+ ratio: number;
194
+ }
195
+
196
+ /** Lessons done out of lessons released, from the classroom read. Null when there are no lessons. */
197
+ export function courseProgress(access: CourseAccessData | null | undefined): AppCourseProgress | null {
198
+ if (!access) return null;
199
+ const lessons = (access.course?.modules ?? []).flatMap((module) => module.lessons ?? []);
200
+ if (lessons.length === 0) return null;
201
+ const completed = new Set((access.progress ?? []).filter((row) => row.isCompleted).map((row) => row.lessonId));
202
+ const done = lessons.filter((lesson) => completed.has(lesson.id) || lesson.progress?.isCompleted).length;
203
+ return { done, total: lessons.length, ratio: done / lessons.length };
204
+ }
205
+
206
+ export interface AppLearningCourse {
207
+ accessId: string;
208
+ title: string;
209
+ href: string;
210
+ progress: AppCourseProgress | null;
211
+ }
212
+
213
+ // ---- Communities ---------------------------------------------------------------------
214
+
215
+ export interface AppCommunityCard {
216
+ id: string;
217
+ name: string;
218
+ slug: string;
219
+ logoUrl: string | null;
220
+ coverUrl: string | null;
221
+ tierName: string | null;
222
+ memberCount: number | null;
223
+ href: string;
224
+ /** Explore only: how a visitor gets in, which names the button. */
225
+ joinMode?: "open" | "application" | "invite";
226
+ }
227
+
228
+ // ---- The sections ---------------------------------------------------------------------
229
+
230
+ /** Everything the home can show, already fetched and shaped. Missing means none. */
231
+ export interface AppHomeData {
232
+ attention?: readonly PortalAttentionItem[];
233
+ portalTiles?: readonly AppPortalTile[];
234
+ communities?: readonly AppCommunityCard[];
235
+ nextCall?: AppNextCall | null;
236
+ nextTicket?: AppNextTicket | null;
237
+ learning?: AppLearningCourse | null;
238
+ explore?: readonly AppCommunityCard[];
239
+ events?: readonly IEvent[];
240
+ courses?: readonly PublicCourse[];
241
+ products?: readonly IPublicProduct[];
242
+ }
243
+
244
+ export type AppHomeSectionView =
245
+ | { kind: "needs_you"; key: "needs_you"; actions: AppAttentionAction[] }
246
+ | { kind: "portal"; key: "portal"; tiles: AppPortalTile[] }
247
+ | { kind: "communities"; key: "communities"; communities: AppCommunityCard[] }
248
+ | { kind: "coming_up"; key: "coming_up"; call: AppNextCall | null; ticket: AppNextTicket | null }
249
+ | { kind: "learning"; key: "learning"; course: AppLearningCourse }
250
+ | { kind: "explore_communities"; key: "explore_communities"; communities: AppCommunityCard[] }
251
+ | { kind: "events"; key: "events"; events: IEvent[] }
252
+ | { kind: "courses"; key: "courses"; courses: PublicCourse[] }
253
+ | { kind: "shop"; key: "shop"; products: IPublicProduct[] }
254
+ | { kind: "link"; key: string; label: string; href: string };
255
+
256
+ /** How many items a list section shows before "See all". */
257
+ export const APP_HOME_LIST_LIMIT = { explore: 3, events: 4, courses: 3, products: 4 } as const;
258
+
259
+ const KNOWN = new Set<string>(SITE_APP_SECTION_KEYS);
260
+
261
+ /** A link section's address: a site path, or an absolute http(s) URL. Anything else is not drawn. */
262
+ export function isSafeLinkHref(href: string | null | undefined): boolean {
263
+ const value = href?.trim() ?? "";
264
+ if (value.startsWith("/")) return !value.startsWith("//") && !value.startsWith("/\\");
265
+ return /^https?:\/\/[^\s]+$/i.test(value);
266
+ }
267
+
268
+ /**
269
+ * The sections to draw, in the configured order. Pure.
270
+ *
271
+ * Hidden entries, unknown keys, repeats and broken links are skipped. A
272
+ * section with nothing to show is skipped too, except the portal.
273
+ */
274
+ export function buildAppHomeSections(
275
+ config: readonly SiteAppHomeSection[] | null | undefined,
276
+ data: AppHomeData,
277
+ ): AppHomeSectionView[] {
278
+ const out: AppHomeSectionView[] = [];
279
+ const seen = new Set<string>();
280
+ for (const entry of config ?? []) {
281
+ if (!entry || entry.visible === false) continue;
282
+ if (entry.type === "link") {
283
+ const label = entry.label?.trim();
284
+ if (!label || !isSafeLinkHref(entry.href) || seen.has(`link:${entry.id}`)) continue;
285
+ seen.add(`link:${entry.id}`);
286
+ out.push({ kind: "link", key: entry.id, label, href: entry.href.trim() });
287
+ continue;
288
+ }
289
+ if (entry.type !== "builtin" || !KNOWN.has(entry.key) || seen.has(entry.key)) continue;
290
+ seen.add(entry.key);
291
+ const view = builtinView(entry.key, data);
292
+ if (view) out.push(view);
293
+ }
294
+ return out;
295
+ }
296
+
297
+ function builtinView(key: SiteAppSectionKey, data: AppHomeData): AppHomeSectionView | null {
298
+ switch (key) {
299
+ case "needs_you": {
300
+ const actions = (data.attention ?? []).map(attentionAction);
301
+ return actions.length ? { kind: key, key, actions } : null;
302
+ }
303
+ case "portal":
304
+ // Always drawn: it is where everything the member owns lives.
305
+ return { kind: key, key, tiles: [...(data.portalTiles ?? buildPortalTiles(null))] };
306
+ case "communities":
307
+ return data.communities?.length ? { kind: key, key, communities: [...data.communities] } : null;
308
+ case "coming_up":
309
+ return data.nextCall || data.nextTicket
310
+ ? { kind: key, key, call: data.nextCall ?? null, ticket: data.nextTicket ?? null }
311
+ : null;
312
+ case "learning":
313
+ return data.learning ? { kind: key, key, course: data.learning } : null;
314
+ case "explore_communities":
315
+ return data.explore?.length
316
+ ? { kind: key, key, communities: data.explore.slice(0, APP_HOME_LIST_LIMIT.explore) }
317
+ : null;
318
+ case "events":
319
+ return data.events?.length ? { kind: key, key, events: data.events.slice(0, APP_HOME_LIST_LIMIT.events) } : null;
320
+ case "courses":
321
+ return data.courses?.length
322
+ ? { kind: key, key, courses: data.courses.slice(0, APP_HOME_LIST_LIMIT.courses) }
323
+ : null;
324
+ case "shop":
325
+ return data.products?.length
326
+ ? { kind: key, key, products: data.products.slice(0, APP_HOME_LIST_LIMIT.products) }
327
+ : null;
328
+ }
329
+ }
330
+
331
+ /** Events still to come, soonest first. Cancelled, completed and archived ones are left out. */
332
+ export function upcomingEvents(events: readonly IEvent[] | null | undefined, now: Date): IEvent[] {
333
+ return (events ?? [])
334
+ .filter((event) => event.status !== "cancelled" && event.status !== "completed" && event.status !== "draft")
335
+ .filter((event) => !event.cancelledAt && !event.archivedAt)
336
+ .filter((event) => Date.parse(event.endDateTime ?? event.dateTime) >= now.getTime())
337
+ .sort((a, b) => Date.parse(a.dateTime) - Date.parse(b.dateTime));
338
+ }
339
+
340
+ /** The time-of-day part of the greeting. */
341
+ export function greetingPart(now: Date): "morning" | "afternoon" | "evening" {
342
+ const hour = now.getHours();
343
+ return hour < 12 ? "morning" : hour < 18 ? "afternoon" : "evening";
344
+ }