@tribe-nest/forge 3.60.0 → 3.63.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 (220) hide show
  1. package/package.json +5 -2
  2. package/src/contexts/CartContext.tsx +34 -1
  3. package/src/contexts/PublicAuthContext.tsx +15 -3
  4. package/src/data/queries/_tests/metaSignals.spec.tsx +130 -0
  5. package/src/data/queries/useAccount.ts +33 -2
  6. package/src/data/queries/useAnalytics.ts +6 -6
  7. package/src/data/queries/useCallChat.ts +2 -2
  8. package/src/data/queries/useChat.ts +10 -1
  9. package/src/data/queries/useCheckouts.ts +2 -1
  10. package/src/data/queries/useCoachingAvailability.ts +10 -1
  11. package/src/data/queries/useCommunities.ts +2 -0
  12. package/src/data/queries/useCommunity.ts +362 -18
  13. package/src/data/queries/useCommunityAgent.ts +247 -0
  14. package/src/data/queries/useCommunityCalendar.ts +150 -0
  15. package/src/data/queries/useCommunityClassroom.ts +75 -0
  16. package/src/data/queries/useCommunityMembers.ts +108 -0
  17. package/src/data/queries/useCommunityModeration.ts +327 -0
  18. package/src/data/queries/useCommunityWiki.ts +137 -0
  19. package/src/data/queries/useCourses.ts +2 -0
  20. package/src/data/queries/useEmailSignIn.ts +79 -0
  21. package/src/data/queries/useEvents.ts +6 -1
  22. package/src/data/queries/useForms.ts +5 -1
  23. package/src/data/queries/useLeadMagnet.ts +6 -2
  24. package/src/data/queries/useMyBookings.ts +24 -1
  25. package/src/data/queries/useMyTickets.ts +24 -1
  26. package/src/data/queries/useNotifications.ts +6 -2
  27. package/src/data/queries/useOrders.ts +2 -1
  28. package/src/data/queries/usePWA.ts +111 -35
  29. package/src/data/queries/useSiteApp.ts +130 -0
  30. package/src/data/queries/useSubscriptions.ts +6 -1
  31. package/src/i18n/de.json +746 -1
  32. package/src/i18n/en.json +746 -1
  33. package/src/index.ts +20 -0
  34. package/src/runtime/pages.spec.ts +5 -0
  35. package/src/runtime/pages.ts +32 -6
  36. package/src/seo/_tests/seo.spec.ts +326 -0
  37. package/src/seo/applySeoToHead.ts +98 -0
  38. package/src/seo/index.ts +9 -0
  39. package/src/seo/resolve.ts +211 -0
  40. package/src/seo/seoHead.ts +61 -0
  41. package/src/seo/siteSeoHead.ts +97 -0
  42. package/src/seo/types.ts +99 -0
  43. package/src/server/_tests/siteBootstrap.spec.ts +65 -0
  44. package/src/server/index.ts +33 -4
  45. package/src/types/models.ts +11 -0
  46. package/src/ui/analytics/ForgeAnalytics.tsx +134 -34
  47. package/src/ui/analytics/PageMetaPixel.tsx +23 -8
  48. package/src/ui/analytics/_tests/ForgeAnalytics.meta.spec.tsx +129 -0
  49. package/src/ui/analytics/_tests/ForgeAnalytics.webVitals.spec.tsx +129 -0
  50. package/src/ui/analytics/_tests/metaCommerceEvents.spec.tsx +159 -0
  51. package/src/ui/analytics/useMetaEvents.ts +78 -0
  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/communityHarness.tsx +10 -3
  56. package/src/ui/headless/_tests/communityMembers.spec.tsx +316 -0
  57. package/src/ui/headless/_tests/communityPoll.spec.tsx +389 -0
  58. package/src/ui/headless/_tests/memberHomeBar.spec.ts +84 -0
  59. package/src/ui/headless/_tests/siteAppBar.spec.ts +83 -0
  60. package/src/ui/headless/_tests/siteAppHarness.ts +19 -0
  61. package/src/ui/headless/_tests/siteAppHome.spec.ts +226 -0
  62. package/src/ui/headless/_tests/siteAppPrompts.spec.ts +185 -0
  63. package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +228 -14
  64. package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +29 -1
  65. package/src/ui/headless/_tests/useCommunityModeration.spec.tsx +420 -0
  66. package/src/ui/headless/_tests/useCommunityReads.spec.tsx +11 -2
  67. package/src/ui/headless/_tests/useCommunityWiki.spec.tsx +224 -0
  68. package/src/ui/headless/_tests/useSiteApp.spec.tsx +52 -0
  69. package/src/ui/headless/_tests/wikiMediaImage.spec.tsx +132 -0
  70. package/src/ui/headless/auth/useEmailCodeSignIn.ts +186 -0
  71. package/src/ui/headless/auth/useSignupForm.ts +20 -1
  72. package/src/ui/headless/chat/useChatAttachmentUpload.ts +9 -6
  73. package/src/ui/headless/chat/useChatRoom.ts +35 -1
  74. package/src/ui/headless/communities/index.ts +41 -0
  75. package/src/ui/headless/communities/useCommunityCalendar.ts +156 -0
  76. package/src/ui/headless/communities/useCommunityClassroom.ts +84 -0
  77. package/src/ui/headless/communities/useCommunityHome.ts +3 -0
  78. package/src/ui/headless/communities/useCommunityJoin.ts +73 -23
  79. package/src/ui/headless/communities/useCommunityMembership.ts +48 -2
  80. package/src/ui/headless/communities/useCommunityWiki.ts +266 -0
  81. package/src/ui/headless/community/communityPoll.ts +297 -0
  82. package/src/ui/headless/community/countries.ts +123 -0
  83. package/src/ui/headless/community/index.ts +81 -1
  84. package/src/ui/headless/community/linkPreview.ts +71 -0
  85. package/src/ui/headless/community/richLexical.ts +27 -2
  86. package/src/ui/headless/community/useCommunityAgent.ts +285 -0
  87. package/src/ui/headless/community/useCommunityMembers.ts +175 -0
  88. package/src/ui/headless/community/useCommunityModeration.ts +300 -0
  89. package/src/ui/headless/community/useCommunitySpaceFeed.ts +22 -4
  90. package/src/ui/headless/community/useCommunityThread.ts +16 -3
  91. package/src/ui/headless/community/useMentionComposer.ts +17 -9
  92. package/src/ui/headless/community/useRichMentionComposer.ts +21 -5
  93. package/src/ui/headless/downloads/index.ts +8 -0
  94. package/src/ui/headless/downloads/useDownloadPage.ts +134 -0
  95. package/src/ui/headless/index.ts +13 -0
  96. package/src/ui/headless/memberHome/index.ts +10 -0
  97. package/src/ui/headless/memberHome/memberHomeBar.ts +91 -0
  98. package/src/ui/headless/memberHome/useMemberHomeNav.ts +56 -18
  99. package/src/ui/headless/portal/index.ts +57 -0
  100. package/src/ui/headless/portal/usePortal.ts +274 -0
  101. package/src/ui/headless/portal/usePortalFiles.ts +68 -0
  102. package/src/ui/headless/portal/usePortalMessages.ts +189 -0
  103. package/src/ui/headless/portal/usePortalPreview.ts +142 -0
  104. package/src/ui/headless/siteApp/appBar.ts +102 -0
  105. package/src/ui/headless/siteApp/appHome.ts +344 -0
  106. package/src/ui/headless/siteApp/appPrompts.ts +229 -0
  107. package/src/ui/headless/siteApp/index.ts +58 -0
  108. package/src/ui/headless/siteApp/useAppHome.ts +210 -0
  109. package/src/ui/headless/siteApp/useAppInstallRequest.ts +16 -0
  110. package/src/ui/headless/work/index.ts +2 -0
  111. package/src/ui/headless/work/useWorkPortal.ts +37 -26
  112. package/src/ui/index.ts +11 -0
  113. package/src/ui/styled/AccountDashboard.tsx +712 -352
  114. package/src/ui/styled/AiAgentWidget.tsx +13 -1
  115. package/src/ui/styled/ChatRoom.tsx +126 -16
  116. package/src/ui/styled/Checkout.tsx +8 -0
  117. package/src/ui/styled/CheckoutConfirmation.tsx +8 -0
  118. package/src/ui/styled/CoachingConfirmation.tsx +8 -0
  119. package/src/ui/styled/CoachingDetail.tsx +2 -0
  120. package/src/ui/styled/CourseConfirmation.tsx +5 -0
  121. package/src/ui/styled/CourseDetail.tsx +2 -0
  122. package/src/ui/styled/DownloadPage.tsx +263 -0
  123. package/src/ui/styled/EventConfirmation.tsx +10 -0
  124. package/src/ui/styled/EventDetail.tsx +10 -0
  125. package/src/ui/styled/FilmLibrary.tsx +22 -9
  126. package/src/ui/styled/MusicLinkPage.tsx +5 -5
  127. package/src/ui/styled/ProductDetail.tsx +12 -0
  128. package/src/ui/styled/PwaRegistration.tsx +5 -1
  129. package/src/ui/styled/SignupForm.tsx +18 -3
  130. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +5 -5
  131. package/src/ui/styled/_tests/AccountNotifications.spec.tsx +135 -0
  132. package/src/ui/styled/_tests/DownloadPage.spec.tsx +68 -0
  133. package/src/ui/styled/app/AppBar.tsx +298 -0
  134. package/src/ui/styled/app/AppHome.tsx +1139 -0
  135. package/src/ui/styled/app/AppHomeMark.tsx +66 -0
  136. package/src/ui/styled/app/AppPrompts.tsx +464 -0
  137. package/src/ui/styled/app/_tests/AppBar.spec.tsx +96 -0
  138. package/src/ui/styled/app/_tests/AppHome.spec.tsx +209 -0
  139. package/src/ui/styled/app/_tests/AppPrompts.spec.tsx +232 -0
  140. package/src/ui/styled/app/index.ts +7 -0
  141. package/src/ui/styled/chat/ChatThreadPanel.tsx +29 -4
  142. package/src/ui/styled/chat/MessageBubble.tsx +43 -6
  143. package/src/ui/styled/communities/CommunityAbout.tsx +6 -1
  144. package/src/ui/styled/communities/CommunityCalendar.tsx +484 -0
  145. package/src/ui/styled/communities/CommunityClassroom.tsx +302 -0
  146. package/src/ui/styled/communities/CommunityHome.tsx +134 -11
  147. package/src/ui/styled/communities/CommunityJoinSheet.tsx +119 -19
  148. package/src/ui/styled/communities/CommunityMembershipMenu.tsx +168 -52
  149. package/src/ui/styled/communities/CommunityPublicWiki.tsx +75 -0
  150. package/src/ui/styled/communities/CommunityWiki.tsx +289 -0
  151. package/src/ui/styled/communities/MyMembershipsList.tsx +31 -5
  152. package/src/ui/styled/communities/_tests/CommunityAppHome.spec.tsx +92 -0
  153. package/src/ui/styled/communities/_tests/CommunityClassroomCalendar.spec.tsx +453 -0
  154. package/src/ui/styled/communities/_tests/CommunityMobile.spec.tsx +399 -0
  155. package/src/ui/styled/communities/_tests/CommunityWiki.spec.tsx +223 -0
  156. package/src/ui/styled/communities/index.ts +9 -0
  157. package/src/ui/styled/communities/shared.tsx +39 -11
  158. package/src/ui/styled/community/CommunityAgent.tsx +286 -0
  159. package/src/ui/styled/community/CommunityComposer.tsx +266 -15
  160. package/src/ui/styled/community/CommunityFeed.tsx +50 -1
  161. package/src/ui/styled/community/CommunityLinkPreview.tsx +159 -0
  162. package/src/ui/styled/community/CommunityMemberGlobe.tsx +171 -0
  163. package/src/ui/styled/community/CommunityMemberProfile.tsx +137 -4
  164. package/src/ui/styled/community/CommunityMembers.tsx +387 -0
  165. package/src/ui/styled/community/CommunityModeration.tsx +587 -0
  166. package/src/ui/styled/community/CommunityModerationState.tsx +127 -0
  167. package/src/ui/styled/community/CommunityModeratorMenu.tsx +317 -0
  168. package/src/ui/styled/community/CommunityNotifications.tsx +52 -6
  169. package/src/ui/styled/community/CommunityPollCard.tsx +250 -0
  170. package/src/ui/styled/community/CommunityPostCard.tsx +110 -34
  171. package/src/ui/styled/community/CommunityPostDetail.tsx +157 -71
  172. package/src/ui/styled/community/CommunityRichText.tsx +177 -32
  173. package/src/ui/styled/community/CommunitySpaces.tsx +49 -14
  174. package/src/ui/styled/community/_tests/CommunityAgent.spec.tsx +571 -0
  175. package/src/ui/styled/community/_tests/CommunityLinkPreview.spec.tsx +167 -0
  176. package/src/ui/styled/community/_tests/CommunityMembers.spec.tsx +137 -0
  177. package/src/ui/styled/community/_tests/CommunityModeration.spec.tsx +291 -0
  178. package/src/ui/styled/community/_tests/CommunityPoll.spec.tsx +386 -0
  179. package/src/ui/styled/community/index.ts +37 -2
  180. package/src/ui/styled/forge-utilities.css +234 -0
  181. package/src/ui/styled/members/CallRecordingActions.tsx +182 -0
  182. package/src/ui/styled/members/MemberCallsCalendar.tsx +13 -158
  183. package/src/ui/styled/members/MemberHomeLayout.tsx +295 -15
  184. package/src/ui/styled/members/MemberPostsFeed.tsx +3 -2
  185. package/src/ui/styled/members/index.ts +8 -0
  186. package/src/ui/styled/portal/PortalAccountSection.tsx +61 -0
  187. package/src/ui/styled/portal/PortalActivity.tsx +167 -0
  188. package/src/ui/styled/portal/PortalBilling.tsx +197 -0
  189. package/src/ui/styled/portal/PortalFiles.tsx +279 -0
  190. package/src/ui/styled/portal/PortalHome.tsx +401 -0
  191. package/src/ui/styled/portal/PortalMessages.tsx +300 -0
  192. package/src/ui/styled/portal/PortalPreviewBanner.tsx +75 -0
  193. package/src/ui/styled/portal/PortalProjects.tsx +407 -0
  194. package/src/ui/styled/portal/PortalShell.tsx +397 -0
  195. package/src/ui/styled/portal/_tests/PortalAppHome.spec.tsx +90 -0
  196. package/src/ui/styled/portal/_tests/PortalFiles.spec.tsx +159 -0
  197. package/src/ui/styled/portal/_tests/PortalHome.spec.tsx +148 -0
  198. package/src/ui/styled/portal/_tests/PortalMessages.spec.tsx +168 -0
  199. package/src/ui/styled/portal/_tests/PortalPreviewBanner.spec.tsx +132 -0
  200. package/src/ui/styled/portal/_tests/portalNav.spec.ts +123 -0
  201. package/src/ui/styled/portal/index.ts +13 -0
  202. package/src/ui/styled/portal/portalNav.ts +260 -0
  203. package/src/ui/styled/portal/shared.tsx +206 -0
  204. package/src/ui/styled/work/WorkInviteAccept.tsx +228 -168
  205. package/src/ui/styled/work/WorkPortalProjects.tsx +68 -120
  206. package/src/ui/styled/work/WorkProjectDocuments.tsx +99 -128
  207. package/src/ui/styled/work/WorkProjectInvoices.tsx +91 -86
  208. package/src/ui/styled/work/WorkProjectReport.tsx +11 -17
  209. package/src/ui/styled/work/WorkReportBody.tsx +223 -248
  210. package/src/ui/styled/work/WorkTaskAttachments.tsx +23 -29
  211. package/src/ui/styled/work/WorkTaskComments.tsx +75 -102
  212. package/src/ui/styled/work/WorkTaskDetail.tsx +48 -59
  213. package/src/ui/styled/work/WorkTokenReport.tsx +8 -16
  214. package/src/ui/styled/work/index.ts +1 -1
  215. package/src/utils/_tests/landing.spec.ts +39 -0
  216. package/src/utils/_tests/metaEvents.spec.ts +264 -0
  217. package/src/utils/_tests/structuredDataSeo.spec.ts +247 -0
  218. package/src/utils/landing.ts +36 -10
  219. package/src/utils/metaEvents.ts +302 -0
  220. package/src/utils/structuredData.ts +339 -1
@@ -0,0 +1,156 @@
1
+ import { useMemo, useState } from "react";
2
+ import { communityRequestStatus } from "../../../data/queries/useCommunityClassroom";
3
+ import {
4
+ useCommunityCalendarItems,
5
+ useCommunityCallRsvp,
6
+ type CommunityCalendarCall,
7
+ type CommunityCalendarItem,
8
+ type CommunityRsvpResponse,
9
+ } from "../../../data/queries/useCommunityCalendar";
10
+
11
+ // ── Pure helpers (unit-tested; no React) ─────────────────────────────────────
12
+
13
+ /** One day of the agenda, in the viewer's own time zone. */
14
+ export interface CommunityCalendarDay {
15
+ /** `YYYY-MM-DD`, local. */
16
+ key: string;
17
+ /** Local midnight of the day. */
18
+ date: Date;
19
+ items: CommunityCalendarItem[];
20
+ }
21
+
22
+ const pad = (n: number) => String(n).padStart(2, "0");
23
+
24
+ /** The local day an instant falls on, as `YYYY-MM-DD`. */
25
+ export function calendarDayKey(value: Date | string): string {
26
+ const date = typeof value === "string" ? new Date(value) : value;
27
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
28
+ }
29
+
30
+ /** Local midnight on the first of the month `offset` months from `now`. */
31
+ export function calendarMonthStart(now: Date, offset = 0): Date {
32
+ return new Date(now.getFullYear(), now.getMonth() + offset, 1);
33
+ }
34
+
35
+ /** The window one month reads: its first local midnight up to the next month's. */
36
+ export function calendarMonthWindow(monthStart: Date): { from: Date; to: Date } {
37
+ return {
38
+ from: new Date(monthStart.getFullYear(), monthStart.getMonth(), 1),
39
+ to: new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 1),
40
+ };
41
+ }
42
+
43
+ /** When an item is over: its end, or its start when it has no end. */
44
+ export function calendarItemIsOver(item: Pick<CommunityCalendarItem, "startsAt" | "endsAt">, now: Date): boolean {
45
+ return new Date(item.endsAt ?? item.startsAt).getTime() < now.getTime();
46
+ }
47
+
48
+ /**
49
+ * The agenda, grouped by the local day each item starts on and split in two:
50
+ * `upcoming` (anything not yet over, soonest first) and `past` (most recent
51
+ * day first), so what a member can still act on is always at the top. Items
52
+ * keep their time order inside a day.
53
+ */
54
+ export function groupCalendarItems(
55
+ items: CommunityCalendarItem[] | undefined,
56
+ now: Date,
57
+ ): { upcoming: CommunityCalendarDay[]; past: CommunityCalendarDay[] } {
58
+ const sorted = [...(Array.isArray(items) ? items : [])].sort((a, b) => a.startsAt.localeCompare(b.startsAt));
59
+ const group = (list: CommunityCalendarItem[]) => {
60
+ const days = new Map<string, CommunityCalendarDay>();
61
+ for (const item of list) {
62
+ const key = calendarDayKey(item.startsAt);
63
+ let day = days.get(key);
64
+ if (!day) {
65
+ const start = new Date(item.startsAt);
66
+ day = { key, date: new Date(start.getFullYear(), start.getMonth(), start.getDate()), items: [] };
67
+ days.set(key, day);
68
+ }
69
+ day.items.push(item);
70
+ }
71
+ return [...days.values()];
72
+ };
73
+ const upcoming = group(sorted.filter((item) => !calendarItemIsOver(item, now)));
74
+ const past = group(sorted.filter((item) => calendarItemIsOver(item, now))).reverse();
75
+ return { upcoming, past };
76
+ }
77
+
78
+ /** What a failed going or not going means for the member. */
79
+ export type CommunityRsvpErrorCode = "closed" | "not_found" | "failed";
80
+
81
+ export function rsvpErrorCode(error: unknown): CommunityRsvpErrorCode {
82
+ const status = communityRequestStatus(error);
83
+ if (status === 409) return "closed";
84
+ if (status === 404) return "not_found";
85
+ return "failed";
86
+ }
87
+
88
+ /** Pressing the answer a member already gave clears it; any other answer replaces it. */
89
+ export function nextRsvp(
90
+ current: CommunityRsvpResponse | null,
91
+ pressed: CommunityRsvpResponse,
92
+ ): CommunityRsvpResponse | null {
93
+ return current === pressed ? null : pressed;
94
+ }
95
+
96
+ /** Join is offered only while the server says the room is open and there is a room to join. */
97
+ export function callJoinable(call: Pick<CommunityCalendarCall, "isOpen" | "callPublicId">): boolean {
98
+ return call.isOpen && !!call.callPublicId;
99
+ }
100
+
101
+ // ── Hook ─────────────────────────────────────────────────────────────────────
102
+
103
+ export interface UseCommunityCalendarOptions {
104
+ /** The clock, for tests. Defaults to the time of the first render. */
105
+ now?: Date;
106
+ }
107
+
108
+ /**
109
+ * Headless community calendar: one month at a time (this month first, arrows
110
+ * either way), grouped by day with what is still ahead on top, and going or
111
+ * not going on a call. Answering the answer already given clears it. A refused
112
+ * answer (the call is over or was cancelled, 409) is kept per call for the
113
+ * row to show.
114
+ */
115
+ export function useCommunityCalendar(opts: UseCommunityCalendarOptions = {}) {
116
+ const [initialNow] = useState(() => opts.now ?? new Date());
117
+ const now = opts.now ?? initialNow;
118
+ const [offset, setOffset] = useState(0);
119
+ const monthStart = useMemo(() => calendarMonthStart(now, offset), [now, offset]);
120
+ const range = useMemo(() => calendarMonthWindow(monthStart), [monthStart]);
121
+ const query = useCommunityCalendarItems(range);
122
+ const groups = useMemo(() => groupCalendarItems(query.data, now), [query.data, now]);
123
+ const rsvpMutation = useCommunityCallRsvp();
124
+ const [rsvpError, setRsvpError] = useState<{ callId: string; code: CommunityRsvpErrorCode } | null>(null);
125
+ const status = communityRequestStatus(query.error);
126
+
127
+ const answer = (call: CommunityCalendarCall, pressed: CommunityRsvpResponse) => {
128
+ setRsvpError(null);
129
+ rsvpMutation.mutate(
130
+ { callId: call.id, response: nextRsvp(call.rsvp.mine, pressed) },
131
+ { onError: (error) => setRsvpError({ callId: call.id, code: rsvpErrorCode(error) }) },
132
+ );
133
+ };
134
+
135
+ return {
136
+ monthStart,
137
+ isCurrentMonth: offset === 0,
138
+ goToPreviousMonth: () => setOffset((o) => o - 1),
139
+ goToNextMonth: () => setOffset((o) => o + 1),
140
+ goToToday: () => setOffset(0),
141
+ items: Array.isArray(query.data) ? query.data : [],
142
+ upcoming: groups.upcoming,
143
+ past: groups.past,
144
+ now,
145
+ isLoading: query.isLoading,
146
+ /** Signed out, or not a member of this community. */
147
+ forbidden: status === 401 || status === 403,
148
+ isError: query.isError && status !== 401 && status !== 403,
149
+ /** Answer going or not going; pressing the current answer clears it. */
150
+ answer,
151
+ /** The call whose answer is being saved. */
152
+ pendingCallId: rsvpMutation.isPending ? (rsvpMutation.variables?.callId ?? null) : null,
153
+ /** Why a call's last answer was refused. */
154
+ rsvpErrorFor: (callId: string) => (rsvpError?.callId === callId ? rsvpError.code : null),
155
+ };
156
+ }
@@ -0,0 +1,84 @@
1
+ import { useCommunityUpgradeOpener, useOptionalCommunityContext } from "../../../contexts/CommunityContext";
2
+ import {
3
+ communityRequestStatus,
4
+ useCommunityClassroomCourses,
5
+ type CommunityClassroomCourse,
6
+ type CommunityClassroomProgress,
7
+ } from "../../../data/queries/useCommunityClassroom";
8
+ import { internalPath } from "../../../runtime/pages";
9
+ import { communityAboutPath } from "./paths";
10
+ import { wikiUnlockParts } from "./useCommunityWiki";
11
+
12
+ // ── Pure helpers (unit-tested; no React) ─────────────────────────────────────
13
+
14
+ /** Where a member stands with one course card. */
15
+ export type ClassroomCourseState = "locked" | "not_started" | "in_progress" | "completed";
16
+
17
+ /** How far through a course the member is, 0 to 100. */
18
+ export function classroomProgressPercent(progress: CommunityClassroomProgress | null | undefined): number {
19
+ if (!progress) return 0;
20
+ if (progress.completedAt) return 100;
21
+ const total = Number(progress.total) || 0;
22
+ if (total <= 0) return 0;
23
+ return Math.max(0, Math.min(100, Math.round(((Number(progress.completed) || 0) / total) * 100)));
24
+ }
25
+
26
+ export function classroomCourseState(course: Pick<CommunityClassroomCourse, "open" | "progress">): ClassroomCourseState {
27
+ if (!course.open) return "locked";
28
+ const progress = course.progress;
29
+ if (!progress) return "not_started";
30
+ if (progress.completedAt || (progress.total > 0 && progress.completed >= progress.total)) return "completed";
31
+ return progress.completed > 0 ? "in_progress" : "not_started";
32
+ }
33
+
34
+ /**
35
+ * What opens a locked course: the level the member has not reached (null when
36
+ * the level is not what is missing) and the names of the tiers that include
37
+ * it (empty when none are named). The same reading as a locked wiki page.
38
+ */
39
+ export function classroomUnlockParts(course: Pick<CommunityClassroomCourse, "unlockLevel" | "unlockTiers">) {
40
+ return wikiUnlockParts(course);
41
+ }
42
+
43
+ /**
44
+ * Where an open card leads: the course player, addressed by the member's
45
+ * access id. Null without one. The course's own page is the sales page, a dead
46
+ * end for someone who already holds the course, so it is never the fallback.
47
+ */
48
+ export function classroomCourseHref(course: Pick<CommunityClassroomCourse, "open" | "accessId">): string | null {
49
+ return course.open && course.accessId ? internalPath("course-access/$accessId", { accessId: course.accessId }) : null;
50
+ }
51
+
52
+ // ── Hook ─────────────────────────────────────────────────────────────────────
53
+
54
+ /**
55
+ * Headless classroom for the surrounding community: the course cards, open
56
+ * ones first as the server orders them, whether the viewer may see the tab at
57
+ * all (`forbidden` for a non-member), and how to unlock a locked card. `unlock`
58
+ * opens the community's upgrade sheet on the first tier that includes the
59
+ * course when a community home provides one; otherwise `unlockHref` is the
60
+ * About page with that tier chosen. A card only a level holds back has nothing
61
+ * to buy, so it offers no upgrade.
62
+ */
63
+ export function useCommunityClassroom(opts: { enabled?: boolean } = {}) {
64
+ const community = useOptionalCommunityContext();
65
+ const opener = useCommunityUpgradeOpener();
66
+ const query = useCommunityClassroomCourses(opts);
67
+ const status = communityRequestStatus(query.error);
68
+ const courses = Array.isArray(query.data) ? query.data : [];
69
+
70
+ return {
71
+ courses,
72
+ isLoading: query.isLoading,
73
+ /** Signed out, or not a member of this community. */
74
+ forbidden: status === 401 || status === 403,
75
+ notFound: status === 404,
76
+ isError: query.isError && status !== 401 && status !== 403 && status !== 404,
77
+ refetch: query.refetch,
78
+ /** A locked card offers an upgrade only when a tier includes it. */
79
+ canUpgrade: (course: CommunityClassroomCourse) => !course.open && course.unlockTiers.length > 0,
80
+ unlock: opener ? (course: CommunityClassroomCourse) => opener.openUpgrade(course.unlockTiers[0]?.id ?? null) : null,
81
+ unlockHref: (course: CommunityClassroomCourse) =>
82
+ community?.slug ? communityAboutPath(community.slug, { tier: course.unlockTiers[0]?.id }) : null,
83
+ };
84
+ }
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useMemo, useRef, useState } from "react";
2
2
  import { MissingCommunityProviderError, useOptionalCommunityContext } from "../../../contexts/CommunityContext";
3
3
  import { useConfirmLatestSubscription } from "../../../data/queries/useSubscriptions";
4
+ import { requestAppInstall } from "../siteApp/appPrompts";
4
5
  import { useInvalidateCommunities } from "../../../data/queries/useCommunities";
5
6
  import { useCommunity } from "./useCommunity";
6
7
  import { communityAboutPath } from "./paths";
@@ -46,6 +47,8 @@ export function useCommunityHome(opts: UseCommunityHomeOptions = {}) {
46
47
  setConfirming(true);
47
48
  confirmLatest
48
49
  .mutateAsync({ communityId })
50
+ // Back from paying to join: the same moment as a free join to offer the app.
51
+ .then(() => requestAppInstall("joined_community"))
49
52
  .catch(() => undefined)
50
53
  .finally(() => {
51
54
  setConfirming(false);
@@ -5,17 +5,23 @@ import { useInvalidateCommunities, type PublicTier } from "../../../data/queries
5
5
  import { useMembershipCheckout } from "../membership/useMembershipCheckout";
6
6
  import { useSignupForm } from "../auth/useSignupForm";
7
7
  import { useLoginFlow } from "../auth/useLoginFlow";
8
+ import { useEmailCodeSignIn } from "../auth/useEmailCodeSignIn";
8
9
  import { offeredCycles } from "../../format/membershipPwyw";
9
10
  import { useCommunity } from "./useCommunity";
10
11
  import { useCommunityInvite } from "./useCommunityInvite";
11
12
  import { communityHomePath } from "./paths";
13
+ import { requestAppInstall } from "../siteApp/appPrompts";
12
14
 
13
15
  /**
14
16
  * Where the join sheet is. Steps that do not apply are skipped, never shown:
15
17
  *
16
- * email who the fan is: sign up (name, email, password) or sign in.
17
- * Skipped when signed in.
18
+ * email who the fan is: their email address, nothing else. Skipped
19
+ * when signed in. (`switchToPassword()` swaps in the email and
20
+ * password sign-in for someone who prefers it.)
18
21
  * code the 6-digit code emailed to them. Skipped when signed in.
22
+ * Right for an address with an account here: signed in.
23
+ * profile right, but nobody has that address here yet: first name, last
24
+ * name and the terms. Only for someone new.
19
25
  * tier which tier, and on a tier with a choice, the billing cycle or
20
26
  * the pay-what-you-want amount. Skipped when there is one tier
21
27
  * and nothing to choose.
@@ -26,12 +32,14 @@ import { communityHomePath } from "./paths";
26
32
  * joined in. The sheet lands the fan on the community home.
27
33
  * pending an application was lodged: the fan lands inside, pending.
28
34
  */
29
- export type CommunityJoinStep = "email" | "code" | "tier" | "questions" | "payment" | "submitting" | "joined" | "pending";
35
+ export type CommunityJoinStep = "email" | "code" | "profile" | "tier" | "questions" | "payment" | "submitting" | "joined" | "pending";
30
36
 
31
37
  /** The facts the step is derived from. Exported so the order can be tested without a provider tree. */
32
38
  export interface CommunityJoinStepInput {
33
39
  isAuthenticated: boolean;
34
40
  awaitingCode: boolean;
41
+ /** The code was right and the address is new here: the name step. */
42
+ needsProfile?: boolean;
35
43
  /** A per-email invite for another address, while signed in: the sheet offers "Sign in as". */
36
44
  emailMismatch: boolean;
37
45
  joined: boolean;
@@ -51,7 +59,7 @@ export interface CommunityJoinStepInput {
51
59
  export function communityJoinStep(input: CommunityJoinStepInput): CommunityJoinStep {
52
60
  if (input.joined || (input.alreadyMember && input.isAuthenticated)) return "joined";
53
61
  if (input.pending) return "pending";
54
- if (!input.isAuthenticated) return input.awaitingCode ? "code" : "email";
62
+ if (!input.isAuthenticated) return input.needsProfile ? "profile" : input.awaitingCode ? "code" : "email";
55
63
  if (input.emailMismatch) return "email";
56
64
  if (input.isProcessing) return "submitting";
57
65
  if (input.hasPaymentStep) return "payment";
@@ -93,14 +101,18 @@ export interface UseCommunityJoinOptions {
93
101
  /**
94
102
  * Join a community from its About page, in place: Skool-simple.
95
103
  *
96
- * email -> code -> tier -> questions -> payment -> inside
104
+ * email -> code -> (profile) -> tier -> questions -> payment -> inside
97
105
  *
98
- * with every step that does not apply skipped. A free tier with nothing to
106
+ * with every step that does not apply skipped. Identity is email first: the
107
+ * code decides whether the fan already has an account (signed in) or is new
108
+ * (asked their name), so nobody picks between "Create account" and "Sign in". A free tier with nothing to
99
109
  * choose is joined the moment the code is accepted. Every success lands INSIDE
100
110
  * the community (`/i/communities/<slug>/home`), never on the account page.
101
111
  *
102
- * Composes, rather than re-implements: the email-code sign-up (`useSignupForm`)
103
- * and sign-in (`useLoginFlow`), the membership checkout (`useMembershipCheckout`:
112
+ * Composes, rather than re-implements: the email-first sign-in
113
+ * (`useEmailCodeSignIn`), the password sign-in (`useLoginFlow`) and the
114
+ * password sign-up (`useSignupForm`, kept for a custom sheet that sets
115
+ * `mode` to `signup`), the membership checkout (`useMembershipCheckout`:
104
116
  * pricing, pay-what-you-want, questions, trials, free and paid subscribe,
105
117
  * applications, Stripe and Paystack), the community read (`useCommunity`) and
106
118
  * the invite read (`useCommunityInvite`).
@@ -153,6 +165,8 @@ export function useCommunityJoin(opts: UseCommunityJoinOptions = {}) {
153
165
  const land = useCallback(
154
166
  (result: { pending: boolean; requiresConfirmation: boolean }) => {
155
167
  void invalidate();
168
+ // In, with nothing left to pay: the moment the site app offers itself.
169
+ if (!result.pending && !result.requiresConfirmation) requestAppInstall("joined_community");
156
170
  const path = result.pending ? home() : result.requiresConfirmation ? paidReturnPath : home({ welcome: 1 });
157
171
  if (opts.onJoined) return opts.onJoined({ ...result, path });
158
172
  if (opts.navigate) return opts.navigate(path);
@@ -188,7 +202,13 @@ export function useCommunityJoin(opts: UseCommunityJoinOptions = {}) {
188
202
  }, [presetTierId, tiers]);
189
203
 
190
204
  // ---- Identity (email + code) -------------------------------------------------------
191
- const [mode, setMode] = useState<"signup" | "signin">("signup");
205
+ /**
206
+ * `email` (the default): email first, the code decides. `signin`: the email
207
+ * and password sign-in, for someone who prefers their password. `signup`: the
208
+ * password sign-up, which the styled sheet no longer offers.
209
+ */
210
+ const [mode, setMode] = useState<"email" | "signup" | "signin">("email");
211
+ const emailFlow = useEmailCodeSignIn({ requireAcceptance: opts.requireAcceptance });
192
212
  const signup = useSignupForm({
193
213
  membershipTierId: checkout.selectedTierId,
194
214
  requireAcceptance: opts.requireAcceptance,
@@ -199,9 +219,12 @@ export function useCommunityJoin(opts: UseCommunityJoinOptions = {}) {
199
219
  if (!lockedEmail) return;
200
220
  if (signup.email !== lockedEmail) signup.setEmail(lockedEmail);
201
221
  if (login.email !== lockedEmail) login.setEmail(lockedEmail);
222
+ if (emailFlow.email !== lockedEmail) emailFlow.setEmail(lockedEmail);
202
223
  // eslint-disable-next-line react-hooks/exhaustive-deps
203
- }, [lockedEmail, signup.email, login.email]);
204
- const awaitingCode = mode === "signup" ? signup.awaitingCode : login.step === "code";
224
+ }, [lockedEmail, signup.email, login.email, emailFlow.email]);
225
+ const awaitingCode =
226
+ mode === "email" ? emailFlow.step === "code" : mode === "signup" ? signup.awaitingCode : login.step === "code";
227
+ const needsProfile = mode === "email" && emailFlow.step === "profile";
205
228
 
206
229
  // ---- Tier and question confirmation ------------------------------------------------
207
230
  const [tierConfirmed, setTierConfirmed] = useState(false);
@@ -230,6 +253,7 @@ export function useCommunityJoin(opts: UseCommunityJoinOptions = {}) {
230
253
  const step = communityJoinStep({
231
254
  isAuthenticated,
232
255
  awaitingCode,
256
+ needsProfile,
233
257
  emailMismatch: isAuthenticated && invite.emailMismatch,
234
258
  joined,
235
259
  pending,
@@ -287,20 +311,26 @@ export function useCommunityJoin(opts: UseCommunityJoinOptions = {}) {
287
311
  // eslint-disable-next-line react-hooks/exhaustive-deps
288
312
  }, [step, alreadyMember, joined, slug]);
289
313
 
290
- const identityError = mode === "signup" ? signup.error : login.error;
291
- const identitySubmitting = mode === "signup" ? signup.isSubmitting : login.isSubmitting;
314
+ const identityError = mode === "email" ? emailFlow.error : mode === "signup" ? signup.error : login.error;
315
+ const identitySubmitting =
316
+ mode === "email" ? emailFlow.isSubmitting : mode === "signup" ? signup.isSubmitting : login.isSubmitting;
292
317
 
293
318
  /** Advance from the current step. */
294
319
  const next = async () => {
295
320
  switch (step) {
296
321
  case "email":
297
- if (mode === "signup") await signup.submit();
322
+ if (mode === "email") await emailFlow.submitEmail();
323
+ else if (mode === "signup") await signup.submit();
298
324
  else await login.submitCredentials();
299
325
  return;
300
326
  case "code":
301
- if (mode === "signup") await signup.submitCode();
327
+ if (mode === "email") await emailFlow.submitCode();
328
+ else if (mode === "signup") await signup.submitCode();
302
329
  else await login.submitCode();
303
330
  return;
331
+ case "profile":
332
+ await emailFlow.submitProfile();
333
+ return;
304
334
  case "tier":
305
335
  if (!selectedTier) return;
306
336
  setTierConfirmed(true);
@@ -323,7 +353,9 @@ export function useCommunityJoin(opts: UseCommunityJoinOptions = {}) {
323
353
  const back = () => {
324
354
  switch (step) {
325
355
  case "code":
326
- if (mode === "signup") signup.editDetails();
356
+ case "profile":
357
+ if (mode === "email") emailFlow.changeEmail();
358
+ else if (mode === "signup") signup.editDetails();
327
359
  else login.back();
328
360
  return;
329
361
  case "questions":
@@ -351,9 +383,22 @@ export function useCommunityJoin(opts: UseCommunityJoinOptions = {}) {
351
383
  alreadyMember,
352
384
 
353
385
  // ---- email + code ----
354
- /** `signup` (new fan) or `signin` (has an account). */
386
+ /** `email` (email first, the default), `signin` (email and password) or `signup` (password sign-up). */
355
387
  mode,
356
388
  setMode,
389
+ /** The email-first fields and steps (email, code, then a name for someone new). */
390
+ emailFlow,
391
+ /** "Use your password instead": the email and password sign-in, carrying the typed address over. */
392
+ switchToPassword: () => {
393
+ if (!lockedEmail && emailFlow.email && !login.email) login.setEmail(emailFlow.email);
394
+ setMode("signin");
395
+ },
396
+ /** Back from the password sign-in to email first. */
397
+ switchToEmailCode: () => {
398
+ if (!lockedEmail && login.email && !emailFlow.email) emailFlow.setEmail(login.email);
399
+ login.back();
400
+ setMode("email");
401
+ },
357
402
  /** The sign-up fields (name, email, password, terms). */
358
403
  signup,
359
404
  /** The sign-in fields (email, password, then code). */
@@ -362,9 +407,13 @@ export function useCommunityJoin(opts: UseCommunityJoinOptions = {}) {
362
407
  emailLocked: !!lockedEmail,
363
408
  lockedEmail,
364
409
  /** The code the fan types on the `code` step, whichever mode. */
365
- code: mode === "signup" ? signup.code : login.code,
366
- setCode: (value: string) => (mode === "signup" ? signup.setCode(value) : login.setCode(value)),
367
- resendCode: () => (mode === "signup" ? signup.resendCode() : login.resend()),
410
+ code: mode === "email" ? emailFlow.code : mode === "signup" ? signup.code : login.code,
411
+ setCode: (value: string) =>
412
+ mode === "email" ? emailFlow.setCode(value) : mode === "signup" ? signup.setCode(value) : login.setCode(value),
413
+ resendCode: () =>
414
+ mode === "email" ? emailFlow.resendCode() : mode === "signup" ? signup.resendCode() : login.resend(),
415
+ /** The address the code went to, whichever mode. */
416
+ codeEmail: mode === "email" ? emailFlow.email : mode === "signup" ? signup.email : login.email,
368
417
 
369
418
  // ---- invites ----
370
419
  invite,
@@ -372,10 +421,11 @@ export function useCommunityJoin(opts: UseCommunityJoinOptions = {}) {
372
421
  inviteProblem: invite.problem,
373
422
  /** Signed in as another address than a per-email invite's. */
374
423
  emailMismatch: isAuthenticated && invite.emailMismatch ? { invitedEmail: invite.invitedEmail ?? "", signedInEmail: user?.email ?? "" } : null,
375
- /** "Sign in as {email}": signs the current account out and opens sign-in with the invited address locked. */
424
+ /** "Sign in as {email}": signs the current account out and starts email first with the invited address locked. */
376
425
  signInAsInvited: async () => {
377
426
  await auth?.logout();
378
- setMode("signin");
427
+ emailFlow.changeEmail();
428
+ setMode("email");
379
429
  },
380
430
 
381
431
  // ---- tier ----
@@ -433,7 +483,7 @@ export function useCommunityJoin(opts: UseCommunityJoinOptions = {}) {
433
483
  applicationOutcome: checkout.applicationOutcome,
434
484
  /** Whether a join was attempted in this sheet. */
435
485
  attempted,
436
- error: step === "email" || step === "code" ? identityError : checkout.error,
486
+ error: step === "email" || step === "code" || step === "profile" ? identityError : checkout.error,
437
487
  isSubmitting: identitySubmitting || checkout.isProcessing,
438
488
 
439
489
  next,
@@ -3,11 +3,12 @@ import { MissingCommunityProviderError, useOptionalCommunityContext } from "../.
3
3
  import {
4
4
  useCancelCommunityMembership,
5
5
  useDowngradeCommunityMembership,
6
+ useMyCommunityMemberships,
6
7
  useUndoCommunityDowngrade,
7
8
  type PublicTier,
8
9
  } from "../../../data/queries/useCommunities";
9
10
  import { useOpenBillingPortal } from "../../../data/queries/useSubscriptions";
10
- import { cycleFloor, defaultCycle } from "../../format/membershipPwyw";
11
+ import { cycleFloor, cycleIsFree, defaultCycle, type BillingCycle } from "../../format/membershipPwyw";
11
12
  import { useCommunity } from "./useCommunity";
12
13
 
13
14
  const errMessage = (e: unknown) =>
@@ -37,6 +38,39 @@ export function communityDowngradeTiers(tiers: PublicTier[], currentTierId: stri
37
38
  );
38
39
  }
39
40
 
41
+ /** What the member pays, as far as the client can tell. */
42
+ export interface CommunityMembershipBilling {
43
+ /** True when the membership is paid for (a subscription with a price). */
44
+ isPaid: boolean;
45
+ /** Per period, in `currency`. Null on a free membership, or while unknown. */
46
+ amount: number | null;
47
+ currency: string | null;
48
+ cycle: BillingCycle | null;
49
+ }
50
+
51
+ /**
52
+ * Is this membership paid for, and what does it cost? Pure.
53
+ *
54
+ * The answer comes from the member's own row in `/public/memberships/mine`
55
+ * (`row`), which carries the subscription's real amount and cycle: a
56
+ * pay-what-you-want member pays what they chose, and a member on a tier sold
57
+ * both ways pays the cycle they picked. Until that row arrives, the tier's own
58
+ * price stands in, so a free tier never flashes "Update payment method".
59
+ */
60
+ export function communityMembershipBilling(
61
+ row: { amount: number | null; currency: string | null; billingCycle: string | null } | null | undefined,
62
+ tier: PublicTier | null | undefined,
63
+ ): CommunityMembershipBilling {
64
+ if (row) {
65
+ const amount = typeof row.amount === "number" && row.amount > 0 ? row.amount : null;
66
+ const cycle = row.billingCycle === "year" ? "year" : row.billingCycle === "month" ? "month" : null;
67
+ return { isPaid: amount !== null, amount, currency: amount !== null ? row.currency : null, cycle: amount !== null ? cycle : null };
68
+ }
69
+ if (!tier) return { isPaid: false, amount: null, currency: null, cycle: null };
70
+ const cycle = defaultCycle(tier);
71
+ return { isPaid: !cycleIsFree(tier, cycle), amount: null, currency: null, cycle: null };
72
+ }
73
+
40
74
  /**
41
75
  * The Membership menu inside a community: current tier, renewal date, a pending
42
76
  * downgrade, and the actions. Cancel keeps the paid period; a downgrade takes
@@ -55,6 +89,7 @@ export function useCommunityMembership() {
55
89
  const downgrade = useDowngradeCommunityMembership(ref);
56
90
  const undo = useUndoCommunityDowngrade(ref);
57
91
  const billingPortal = useOpenBillingPortal();
92
+ const mine = useMyCommunityMemberships({ enabled: !!community.membership });
58
93
 
59
94
  const [cancelRequested, setCancelRequested] = useState(false);
60
95
  const [error, setError] = useState<string | null>(null);
@@ -73,11 +108,17 @@ export function useCommunityMembership() {
73
108
  })
74
109
  : null;
75
110
 
111
+ const mineRow = membership ? mine.data?.find((row) => row.id === membership.id) : undefined;
112
+ const billing = communityMembershipBilling(
113
+ mineRow ? { amount: mineRow.subscriptionAmount ?? null, currency: mineRow.subscriptionCurrency ?? null, billingCycle: mineRow.billingCycle ?? null } : null,
114
+ community.currentTier,
115
+ );
116
+
76
117
  const run = async (action: () => Promise<unknown>) => {
77
118
  setError(null);
78
119
  try {
79
120
  await action();
80
- await community.refetch();
121
+ await Promise.all([community.refetch(), membership ? mine.refetch() : Promise.resolve()]);
81
122
  return true;
82
123
  } catch (e) {
83
124
  setError(errMessage(e));
@@ -97,6 +138,11 @@ export function useCommunityMembership() {
97
138
  pendingDowngradeTier,
98
139
  downgradeTiers,
99
140
  isPastDue: community.viewer?.state === "past_due",
141
+ /** Paid or free, and the price per period when the server has said. A free membership has no card to update. */
142
+ billing,
143
+ isPaid: billing.isPaid,
144
+ /** A higher tier exists, so the sheet offers Upgrade. */
145
+ hasUpgrade: community.hasUpgrade,
100
146
  error,
101
147
  isWorking: cancel.isPending || downgrade.isPending || undo.isPending || billingPortal.isPending,
102
148