@tribe-nest/forge 3.60.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 (207) hide show
  1. package/package.json +2 -1
  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 +17 -0
  34. package/src/runtime/pages.spec.ts +5 -0
  35. package/src/runtime/pages.ts +32 -6
  36. package/src/types/models.ts +11 -0
  37. package/src/ui/analytics/ForgeAnalytics.tsx +64 -34
  38. package/src/ui/analytics/PageMetaPixel.tsx +23 -8
  39. package/src/ui/analytics/_tests/ForgeAnalytics.meta.spec.tsx +129 -0
  40. package/src/ui/analytics/_tests/metaCommerceEvents.spec.tsx +159 -0
  41. package/src/ui/analytics/useMetaEvents.ts +78 -0
  42. package/src/ui/headless/_tests/communityAgent.spec.tsx +519 -0
  43. package/src/ui/headless/_tests/communityCalendar.spec.tsx +195 -0
  44. package/src/ui/headless/_tests/communityClassroom.spec.tsx +120 -0
  45. package/src/ui/headless/_tests/communityHarness.tsx +10 -3
  46. package/src/ui/headless/_tests/communityMembers.spec.tsx +316 -0
  47. package/src/ui/headless/_tests/communityPoll.spec.tsx +389 -0
  48. package/src/ui/headless/_tests/memberHomeBar.spec.ts +84 -0
  49. package/src/ui/headless/_tests/siteAppBar.spec.ts +83 -0
  50. package/src/ui/headless/_tests/siteAppHarness.ts +19 -0
  51. package/src/ui/headless/_tests/siteAppHome.spec.ts +226 -0
  52. package/src/ui/headless/_tests/siteAppPrompts.spec.ts +185 -0
  53. package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +228 -14
  54. package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +29 -1
  55. package/src/ui/headless/_tests/useCommunityModeration.spec.tsx +420 -0
  56. package/src/ui/headless/_tests/useCommunityReads.spec.tsx +11 -2
  57. package/src/ui/headless/_tests/useCommunityWiki.spec.tsx +224 -0
  58. package/src/ui/headless/_tests/useSiteApp.spec.tsx +52 -0
  59. package/src/ui/headless/_tests/wikiMediaImage.spec.tsx +132 -0
  60. package/src/ui/headless/auth/useEmailCodeSignIn.ts +186 -0
  61. package/src/ui/headless/auth/useSignupForm.ts +20 -1
  62. package/src/ui/headless/chat/useChatAttachmentUpload.ts +9 -6
  63. package/src/ui/headless/chat/useChatRoom.ts +35 -1
  64. package/src/ui/headless/communities/index.ts +41 -0
  65. package/src/ui/headless/communities/useCommunityCalendar.ts +156 -0
  66. package/src/ui/headless/communities/useCommunityClassroom.ts +84 -0
  67. package/src/ui/headless/communities/useCommunityHome.ts +3 -0
  68. package/src/ui/headless/communities/useCommunityJoin.ts +73 -23
  69. package/src/ui/headless/communities/useCommunityMembership.ts +48 -2
  70. package/src/ui/headless/communities/useCommunityWiki.ts +266 -0
  71. package/src/ui/headless/community/communityPoll.ts +297 -0
  72. package/src/ui/headless/community/countries.ts +123 -0
  73. package/src/ui/headless/community/index.ts +81 -1
  74. package/src/ui/headless/community/linkPreview.ts +71 -0
  75. package/src/ui/headless/community/richLexical.ts +27 -2
  76. package/src/ui/headless/community/useCommunityAgent.ts +285 -0
  77. package/src/ui/headless/community/useCommunityMembers.ts +175 -0
  78. package/src/ui/headless/community/useCommunityModeration.ts +300 -0
  79. package/src/ui/headless/community/useCommunitySpaceFeed.ts +22 -4
  80. package/src/ui/headless/community/useCommunityThread.ts +16 -3
  81. package/src/ui/headless/community/useMentionComposer.ts +17 -9
  82. package/src/ui/headless/community/useRichMentionComposer.ts +21 -5
  83. package/src/ui/headless/downloads/index.ts +8 -0
  84. package/src/ui/headless/downloads/useDownloadPage.ts +134 -0
  85. package/src/ui/headless/index.ts +13 -0
  86. package/src/ui/headless/memberHome/index.ts +10 -0
  87. package/src/ui/headless/memberHome/memberHomeBar.ts +91 -0
  88. package/src/ui/headless/memberHome/useMemberHomeNav.ts +56 -18
  89. package/src/ui/headless/portal/index.ts +57 -0
  90. package/src/ui/headless/portal/usePortal.ts +274 -0
  91. package/src/ui/headless/portal/usePortalFiles.ts +68 -0
  92. package/src/ui/headless/portal/usePortalMessages.ts +189 -0
  93. package/src/ui/headless/portal/usePortalPreview.ts +142 -0
  94. package/src/ui/headless/siteApp/appBar.ts +102 -0
  95. package/src/ui/headless/siteApp/appHome.ts +344 -0
  96. package/src/ui/headless/siteApp/appPrompts.ts +229 -0
  97. package/src/ui/headless/siteApp/index.ts +58 -0
  98. package/src/ui/headless/siteApp/useAppHome.ts +210 -0
  99. package/src/ui/headless/siteApp/useAppInstallRequest.ts +16 -0
  100. package/src/ui/headless/work/index.ts +2 -0
  101. package/src/ui/headless/work/useWorkPortal.ts +37 -26
  102. package/src/ui/index.ts +11 -0
  103. package/src/ui/styled/AccountDashboard.tsx +712 -352
  104. package/src/ui/styled/AiAgentWidget.tsx +13 -1
  105. package/src/ui/styled/ChatRoom.tsx +126 -16
  106. package/src/ui/styled/Checkout.tsx +8 -0
  107. package/src/ui/styled/CheckoutConfirmation.tsx +8 -0
  108. package/src/ui/styled/CoachingConfirmation.tsx +8 -0
  109. package/src/ui/styled/CoachingDetail.tsx +2 -0
  110. package/src/ui/styled/CourseConfirmation.tsx +5 -0
  111. package/src/ui/styled/CourseDetail.tsx +2 -0
  112. package/src/ui/styled/DownloadPage.tsx +263 -0
  113. package/src/ui/styled/EventConfirmation.tsx +10 -0
  114. package/src/ui/styled/EventDetail.tsx +10 -0
  115. package/src/ui/styled/FilmLibrary.tsx +22 -9
  116. package/src/ui/styled/MusicLinkPage.tsx +5 -5
  117. package/src/ui/styled/ProductDetail.tsx +12 -0
  118. package/src/ui/styled/PwaRegistration.tsx +5 -1
  119. package/src/ui/styled/SignupForm.tsx +18 -3
  120. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +5 -5
  121. package/src/ui/styled/_tests/AccountNotifications.spec.tsx +135 -0
  122. package/src/ui/styled/_tests/DownloadPage.spec.tsx +68 -0
  123. package/src/ui/styled/app/AppBar.tsx +298 -0
  124. package/src/ui/styled/app/AppHome.tsx +1139 -0
  125. package/src/ui/styled/app/AppHomeMark.tsx +66 -0
  126. package/src/ui/styled/app/AppPrompts.tsx +464 -0
  127. package/src/ui/styled/app/_tests/AppBar.spec.tsx +96 -0
  128. package/src/ui/styled/app/_tests/AppHome.spec.tsx +209 -0
  129. package/src/ui/styled/app/_tests/AppPrompts.spec.tsx +232 -0
  130. package/src/ui/styled/app/index.ts +7 -0
  131. package/src/ui/styled/chat/ChatThreadPanel.tsx +29 -4
  132. package/src/ui/styled/chat/MessageBubble.tsx +43 -6
  133. package/src/ui/styled/communities/CommunityAbout.tsx +6 -1
  134. package/src/ui/styled/communities/CommunityCalendar.tsx +484 -0
  135. package/src/ui/styled/communities/CommunityClassroom.tsx +302 -0
  136. package/src/ui/styled/communities/CommunityHome.tsx +134 -11
  137. package/src/ui/styled/communities/CommunityJoinSheet.tsx +119 -19
  138. package/src/ui/styled/communities/CommunityMembershipMenu.tsx +168 -52
  139. package/src/ui/styled/communities/CommunityPublicWiki.tsx +75 -0
  140. package/src/ui/styled/communities/CommunityWiki.tsx +289 -0
  141. package/src/ui/styled/communities/MyMembershipsList.tsx +31 -5
  142. package/src/ui/styled/communities/_tests/CommunityAppHome.spec.tsx +92 -0
  143. package/src/ui/styled/communities/_tests/CommunityClassroomCalendar.spec.tsx +453 -0
  144. package/src/ui/styled/communities/_tests/CommunityMobile.spec.tsx +399 -0
  145. package/src/ui/styled/communities/_tests/CommunityWiki.spec.tsx +223 -0
  146. package/src/ui/styled/communities/index.ts +9 -0
  147. package/src/ui/styled/communities/shared.tsx +39 -11
  148. package/src/ui/styled/community/CommunityAgent.tsx +286 -0
  149. package/src/ui/styled/community/CommunityComposer.tsx +266 -15
  150. package/src/ui/styled/community/CommunityFeed.tsx +50 -1
  151. package/src/ui/styled/community/CommunityLinkPreview.tsx +159 -0
  152. package/src/ui/styled/community/CommunityMemberGlobe.tsx +171 -0
  153. package/src/ui/styled/community/CommunityMemberProfile.tsx +137 -4
  154. package/src/ui/styled/community/CommunityMembers.tsx +387 -0
  155. package/src/ui/styled/community/CommunityModeration.tsx +587 -0
  156. package/src/ui/styled/community/CommunityModerationState.tsx +127 -0
  157. package/src/ui/styled/community/CommunityModeratorMenu.tsx +317 -0
  158. package/src/ui/styled/community/CommunityNotifications.tsx +52 -6
  159. package/src/ui/styled/community/CommunityPollCard.tsx +250 -0
  160. package/src/ui/styled/community/CommunityPostCard.tsx +110 -34
  161. package/src/ui/styled/community/CommunityPostDetail.tsx +157 -71
  162. package/src/ui/styled/community/CommunityRichText.tsx +177 -32
  163. package/src/ui/styled/community/CommunitySpaces.tsx +49 -14
  164. package/src/ui/styled/community/_tests/CommunityAgent.spec.tsx +571 -0
  165. package/src/ui/styled/community/_tests/CommunityLinkPreview.spec.tsx +167 -0
  166. package/src/ui/styled/community/_tests/CommunityMembers.spec.tsx +137 -0
  167. package/src/ui/styled/community/_tests/CommunityModeration.spec.tsx +291 -0
  168. package/src/ui/styled/community/_tests/CommunityPoll.spec.tsx +386 -0
  169. package/src/ui/styled/community/index.ts +37 -2
  170. package/src/ui/styled/forge-utilities.css +234 -0
  171. package/src/ui/styled/members/CallRecordingActions.tsx +182 -0
  172. package/src/ui/styled/members/MemberCallsCalendar.tsx +13 -158
  173. package/src/ui/styled/members/MemberHomeLayout.tsx +295 -15
  174. package/src/ui/styled/members/MemberPostsFeed.tsx +3 -2
  175. package/src/ui/styled/members/index.ts +8 -0
  176. package/src/ui/styled/portal/PortalAccountSection.tsx +61 -0
  177. package/src/ui/styled/portal/PortalActivity.tsx +167 -0
  178. package/src/ui/styled/portal/PortalBilling.tsx +197 -0
  179. package/src/ui/styled/portal/PortalFiles.tsx +279 -0
  180. package/src/ui/styled/portal/PortalHome.tsx +401 -0
  181. package/src/ui/styled/portal/PortalMessages.tsx +300 -0
  182. package/src/ui/styled/portal/PortalPreviewBanner.tsx +75 -0
  183. package/src/ui/styled/portal/PortalProjects.tsx +407 -0
  184. package/src/ui/styled/portal/PortalShell.tsx +397 -0
  185. package/src/ui/styled/portal/_tests/PortalAppHome.spec.tsx +90 -0
  186. package/src/ui/styled/portal/_tests/PortalFiles.spec.tsx +159 -0
  187. package/src/ui/styled/portal/_tests/PortalHome.spec.tsx +148 -0
  188. package/src/ui/styled/portal/_tests/PortalMessages.spec.tsx +168 -0
  189. package/src/ui/styled/portal/_tests/PortalPreviewBanner.spec.tsx +132 -0
  190. package/src/ui/styled/portal/_tests/portalNav.spec.ts +123 -0
  191. package/src/ui/styled/portal/index.ts +13 -0
  192. package/src/ui/styled/portal/portalNav.ts +260 -0
  193. package/src/ui/styled/portal/shared.tsx +206 -0
  194. package/src/ui/styled/work/WorkInviteAccept.tsx +228 -168
  195. package/src/ui/styled/work/WorkPortalProjects.tsx +68 -120
  196. package/src/ui/styled/work/WorkProjectDocuments.tsx +99 -128
  197. package/src/ui/styled/work/WorkProjectInvoices.tsx +91 -86
  198. package/src/ui/styled/work/WorkProjectReport.tsx +11 -17
  199. package/src/ui/styled/work/WorkReportBody.tsx +223 -248
  200. package/src/ui/styled/work/WorkTaskAttachments.tsx +23 -29
  201. package/src/ui/styled/work/WorkTaskComments.tsx +75 -102
  202. package/src/ui/styled/work/WorkTaskDetail.tsx +48 -59
  203. package/src/ui/styled/work/WorkTokenReport.tsx +8 -16
  204. package/src/ui/styled/work/index.ts +1 -1
  205. package/src/utils/_tests/metaEvents.spec.ts +264 -0
  206. package/src/utils/landing.ts +19 -6
  207. package/src/utils/metaEvents.ts +302 -0
@@ -27,7 +27,7 @@ vi.mock("../../../utils/paystackCheckout", async (importOriginal) => ({
27
27
  /**
28
28
  * The join sheet's step machine, driven with no markup.
29
29
  *
30
- * email -> code -> tier -> questions -> payment -> inside
30
+ * email -> code -> (profile) -> tier -> questions -> payment -> inside
31
31
  *
32
32
  * Every step that does not apply is skipped, every success lands INSIDE the
33
33
  * community (never `/i/account`), invites preset their tier and lock their
@@ -36,9 +36,13 @@ vi.mock("../../../utils/paystackCheckout", async (importOriginal) => ({
36
36
 
37
37
  const HOME = "/i/communities/fan-club/home";
38
38
  let onJoined: ReturnType<typeof vi.fn>;
39
+ /** Whether the email-first verify finds an account for the address. */
40
+ let existingAccount = false;
41
+ const TICKET = "ab".repeat(32);
39
42
 
40
43
  beforeEach(() => {
41
44
  onJoined = vi.fn();
45
+ existingAccount = false;
42
46
  paystack.open.mockClear();
43
47
  window.history.replaceState(null, "", "/i/communities/fan-club");
44
48
  });
@@ -63,6 +67,7 @@ function routes({
63
67
  }): Route[] {
64
68
  return [
65
69
  ...extra,
70
+ ...emailRoutes(),
66
71
  get(/^\/public\/communities\/fan-club\/invite$/, () => invite),
67
72
  meRoute(me),
68
73
  detailRoute((req) => ({
@@ -80,22 +85,47 @@ function routes({
80
85
  ];
81
86
  }
82
87
 
83
- async function signUp(result: { current: ReturnType<typeof useCommunityJoin> }, email = "fan@example.com") {
84
- act(() => {
85
- result.current.signup.setFirstName("Fan");
86
- result.current.signup.setLastName("Person");
87
- if (!result.current.emailLocked) result.current.signup.setEmail(email);
88
- result.current.signup.setPassword("password123");
89
- result.current.signup.setAcceptedTerms(true);
90
- });
88
+ /** A session as `/public/sessions/email/verify` and `/complete` answer it. */
89
+ const signedIn = (email: string) => ({
90
+ status: "signed_in",
91
+ account: account(email),
92
+ token: "access-1",
93
+ refreshToken: "refresh-1",
94
+ associationId: "assoc-1",
95
+ });
96
+
97
+ /** The email-first endpoints. A code of `000000` is wrong; `existingAccount` decides the verify answer. */
98
+ function emailRoutes(): Route[] {
99
+ return [
100
+ post("/public/sessions/email/start", (req) => ({ status: "code_sent", email: req.body?.email })),
101
+ post("/public/sessions/email/verify", (req) => {
102
+ if (req.body?.code === "000000") throw { status: 400, message: "Invalid or expired code." };
103
+ return existingAccount ? signedIn(String(req.body?.email)) : { status: "needs_profile", ticket: TICKET };
104
+ }),
105
+ post("/public/sessions/email/complete", (req) => signedIn(String(req.body?.email))),
106
+ ];
107
+ }
108
+
109
+ const press = async (result: { current: ReturnType<typeof useCommunityJoin> }) => {
91
110
  await act(async () => {
92
111
  await result.current.next();
93
112
  });
113
+ };
114
+
115
+ /** Email first, for someone new: email, code, then a name and the terms. */
116
+ async function signUp(result: { current: ReturnType<typeof useCommunityJoin> }, email = "fan@example.com") {
117
+ if (!result.current.emailLocked) act(() => result.current.emailFlow.setEmail(email));
118
+ await press(result);
94
119
  expect(result.current.step).toBe("code");
95
120
  act(() => result.current.setCode("123456"));
96
- await act(async () => {
97
- await result.current.next();
121
+ await press(result);
122
+ expect(result.current.step).toBe("profile");
123
+ act(() => {
124
+ result.current.emailFlow.setFirstName("Fan");
125
+ result.current.emailFlow.setLastName("Person");
126
+ result.current.emailFlow.setAcceptedTerms(true);
98
127
  });
128
+ await press(result);
99
129
  }
100
130
 
101
131
  describe("the step order", () => {
@@ -115,6 +145,7 @@ describe("the step order", () => {
115
145
  it.each<[string, Partial<CommunityJoinStepInput>, string]>([
116
146
  ["anonymous asks who the fan is", { isAuthenticated: false }, "email"],
117
147
  ["then the emailed code", { isAuthenticated: false, awaitingCode: true }, "code"],
148
+ ["then a name, for someone new", { isAuthenticated: false, awaitingCode: true, needsProfile: true }, "profile"],
118
149
  ["a mismatched invite stays on identity even when signed in", { emailMismatch: true }, "email"],
119
150
  ["no tier chosen yet", { hasSelectedTier: false }, "tier"],
120
151
  ["a tier with a choice to make", { tierStepOpen: true }, "tier"],
@@ -316,7 +347,7 @@ describe("invites", () => {
316
347
  installAdapter(routes({ tiers: [free], invite: emailInvite("valid") }));
317
348
  const { result } = renderHook(() => useCommunityJoin({ invite: "tok-1", onJoined }), { wrapper: wrapper() });
318
349
  await waitFor(() => expect(result.current.emailLocked).toBe(true));
319
- expect(result.current.signup.email).toBe("invited@example.com");
350
+ expect(result.current.emailFlow.email).toBe("invited@example.com");
320
351
  expect(result.current.lockedEmail).toBe("invited@example.com");
321
352
 
322
353
  await signUp(result);
@@ -342,9 +373,9 @@ describe("invites", () => {
342
373
  await result.current.signInAsInvited();
343
374
  });
344
375
  expect(logout).toHaveBeenCalledTimes(1);
345
- expect(result.current.mode).toBe("signin");
376
+ expect(result.current.mode).toBe("email");
346
377
  expect(result.current.emailMismatch).toBeNull();
347
- await waitFor(() => expect(result.current.login.email).toBe("invited@example.com"));
378
+ await waitFor(() => expect(result.current.emailFlow.email).toBe("invited@example.com"));
348
379
  expect(result.current.emailLocked).toBe(true);
349
380
  });
350
381
 
@@ -380,3 +411,186 @@ describe("invites", () => {
380
411
  expect(calls("post", "/public/payments/subscriptions/free")[0].body).not.toHaveProperty("joinToken");
381
412
  });
382
413
  });
414
+
415
+ describe("email first", () => {
416
+ it("an address with an account: start, code, signed in by the code alone, then joined", async () => {
417
+ existingAccount = true;
418
+ const adopted = vi.fn();
419
+ installAdapter(routes({ tiers: [free] }));
420
+ const { result } = renderHook(() => useCommunityJoin({ onJoined }), { wrapper: wrapper({ controls: { adopted } }) });
421
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
422
+ expect(result.current.step).toBe("email");
423
+ expect(result.current.mode).toBe("email");
424
+
425
+ act(() => result.current.emailFlow.setEmail(" known@example.com "));
426
+ await press(result);
427
+ expect(result.current.step).toBe("code");
428
+ expect(result.current.codeEmail).toBe(" known@example.com ");
429
+ expect(calls("post", "/public/sessions/email/start").map((r) => r.body)).toEqual([
430
+ { profileId: "profile-1", email: "known@example.com" },
431
+ ]);
432
+
433
+ act(() => result.current.setCode("123456"));
434
+ await press(result);
435
+ expect(calls("post", "/public/sessions/email/verify").map((r) => r.body)).toEqual([
436
+ { profileId: "profile-1", email: "known@example.com", code: "123456" },
437
+ ]);
438
+ // The session went through the context's one storage path, untouched.
439
+ expect(adopted).toHaveBeenCalledWith({ token: "access-1", refreshToken: "refresh-1", account: account("known@example.com") });
440
+ expect(calls("post", "/public/sessions/email/complete")).toHaveLength(0);
441
+
442
+ await waitFor(() => expect(result.current.step).toBe("joined"));
443
+ expect(calls("post", "/public/payments/subscriptions/free")[0].body).toMatchObject({ membershipTierId: "tier-free" });
444
+ });
445
+
446
+ it("a new address: the code is right, then a name and the terms, then signed in and joined", async () => {
447
+ const adopted = vi.fn();
448
+ installAdapter(routes({ tiers: [free] }));
449
+ const { result } = renderHook(() => useCommunityJoin({ onJoined }), { wrapper: wrapper({ controls: { adopted } }) });
450
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
451
+
452
+ act(() => result.current.emailFlow.setEmail("new@example.com"));
453
+ await press(result);
454
+ act(() => result.current.setCode("654321"));
455
+ await press(result);
456
+ expect(result.current.step).toBe("profile");
457
+ expect(adopted).not.toHaveBeenCalled();
458
+
459
+ // The terms are required before anything is sent.
460
+ act(() => {
461
+ result.current.emailFlow.setFirstName(" New ");
462
+ result.current.emailFlow.setLastName(" Person ");
463
+ });
464
+ await press(result);
465
+ expect(result.current.error).toBe("You must accept the Terms and Privacy Policy.");
466
+ expect(calls("post", "/public/sessions/email/complete")).toHaveLength(0);
467
+
468
+ act(() => result.current.emailFlow.setAcceptedTerms(true));
469
+ await press(result);
470
+ expect(calls("post", "/public/sessions/email/complete").map((r) => r.body)).toEqual([
471
+ {
472
+ profileId: "profile-1",
473
+ email: "new@example.com",
474
+ ticket: TICKET,
475
+ firstName: "New",
476
+ lastName: "Person",
477
+ acceptedTerms: true,
478
+ acceptedPrivacy: true,
479
+ },
480
+ ]);
481
+ expect(adopted).toHaveBeenCalledWith({ token: "access-1", refreshToken: "refresh-1", account: account("new@example.com") });
482
+ await waitFor(() => expect(result.current.step).toBe("joined"));
483
+ expect(onJoined).toHaveBeenCalledWith({ pending: false, requiresConfirmation: false, path: `${HOME}?welcome=1` });
484
+ });
485
+
486
+ it("a wrong code says so and stays on the code step", async () => {
487
+ installAdapter(routes({ tiers: [free] }));
488
+ const { result } = renderHook(() => useCommunityJoin({ onJoined }), { wrapper: wrapper() });
489
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
490
+ act(() => result.current.emailFlow.setEmail("fan@example.com"));
491
+ await press(result);
492
+ act(() => result.current.setCode("000000"));
493
+ await press(result);
494
+ expect(result.current.step).toBe("code");
495
+ expect(result.current.error).toBe("Invalid or expired code.");
496
+ });
497
+
498
+ it("a code that is not six digits is refused before anything is sent", async () => {
499
+ installAdapter(routes({ tiers: [free] }));
500
+ const { result } = renderHook(() => useCommunityJoin({ onJoined }), { wrapper: wrapper() });
501
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
502
+ act(() => result.current.emailFlow.setEmail("fan@example.com"));
503
+ await press(result);
504
+ act(() => result.current.setCode("123"));
505
+ await press(result);
506
+ expect(result.current.error).toBe("Enter the 6-digit code we emailed you.");
507
+ expect(calls("post", "/public/sessions/email/verify")).toHaveLength(0);
508
+ });
509
+
510
+ it("an invalid email is refused before a code is asked for", async () => {
511
+ installAdapter(routes({ tiers: [free] }));
512
+ const { result } = renderHook(() => useCommunityJoin({ onJoined }), { wrapper: wrapper() });
513
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
514
+ act(() => result.current.emailFlow.setEmail("not-an-email"));
515
+ await press(result);
516
+ expect(result.current.step).toBe("email");
517
+ expect(result.current.error).toBe("Please enter a valid email.");
518
+ expect(calls("post", "/public/sessions/email/start")).toHaveLength(0);
519
+ });
520
+
521
+ it("resend asks for another code; a throttled resend shows the server's message", async () => {
522
+ let throttle = false;
523
+ installAdapter(
524
+ routes({
525
+ tiers: [free],
526
+ extra: [
527
+ post("/public/sessions/email/start", (req) => {
528
+ if (throttle) throw { status: 400, message: "A code was already sent. Please wait a minute." };
529
+ return { status: "code_sent", email: req.body?.email };
530
+ }),
531
+ ],
532
+ }),
533
+ );
534
+ const { result } = renderHook(() => useCommunityJoin({ onJoined }), { wrapper: wrapper() });
535
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
536
+ act(() => result.current.emailFlow.setEmail("fan@example.com"));
537
+ await press(result);
538
+ await act(async () => {
539
+ await result.current.resendCode();
540
+ });
541
+ expect(calls("post", "/public/sessions/email/start")).toHaveLength(2);
542
+ expect(calls("post", "/public/sessions/email/start")[1].body).toEqual({ profileId: "profile-1", email: "fan@example.com" });
543
+ expect(result.current.emailFlow.resent).toBe(true);
544
+
545
+ throttle = true;
546
+ await act(async () => {
547
+ await result.current.resendCode();
548
+ });
549
+ expect(result.current.error).toBe("A code was already sent. Please wait a minute.");
550
+ expect(result.current.step).toBe("code");
551
+ });
552
+
553
+ it("use a different email goes back to the email step, keeping the address to correct", async () => {
554
+ installAdapter(routes({ tiers: [free] }));
555
+ const { result } = renderHook(() => useCommunityJoin({ onJoined }), { wrapper: wrapper() });
556
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
557
+ act(() => result.current.emailFlow.setEmail("typo@example.con"));
558
+ await press(result);
559
+ act(() => result.current.setCode("12"));
560
+ act(() => result.current.back());
561
+ expect(result.current.step).toBe("email");
562
+ expect(result.current.emailFlow.email).toBe("typo@example.con");
563
+ expect(result.current.code).toBe("");
564
+ });
565
+
566
+ it("use your password instead: the email and password sign-in, with the address carried over", async () => {
567
+ installAdapter(routes({ tiers: [free] }));
568
+ const { result } = renderHook(() => useCommunityJoin({ onJoined }), { wrapper: wrapper() });
569
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
570
+ act(() => result.current.emailFlow.setEmail("pw@example.com"));
571
+ act(() => result.current.switchToPassword());
572
+ expect(result.current.mode).toBe("signin");
573
+ expect(result.current.login.email).toBe("pw@example.com");
574
+ act(() => result.current.login.setPassword("password123"));
575
+ await press(result);
576
+ expect(result.current.step).toBe("code");
577
+ expect(result.current.codeEmail).toBe("pw@example.com");
578
+ act(() => result.current.setCode("123456"));
579
+ await press(result);
580
+ await waitFor(() => expect(result.current.step).toBe("joined"));
581
+ expect(calls("post", "/public/sessions/email/start")).toHaveLength(0);
582
+
583
+ });
584
+
585
+ it("switching back from the password sign-in returns to email first", async () => {
586
+ installAdapter(routes({ tiers: [free] }));
587
+ const { result } = renderHook(() => useCommunityJoin({ onJoined }), { wrapper: wrapper() });
588
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
589
+ act(() => result.current.switchToPassword());
590
+ act(() => result.current.login.setEmail("back@example.com"));
591
+ act(() => result.current.switchToEmailCode());
592
+ expect(result.current.mode).toBe("email");
593
+ expect(result.current.step).toBe("email");
594
+ expect(result.current.emailFlow.email).toBe("back@example.com");
595
+ });
596
+ });
@@ -1,7 +1,7 @@
1
1
  // @vitest-environment jsdom
2
2
  import { afterEach, describe, expect, it } from "vitest";
3
3
  import { act, renderHook, waitFor } from "@testing-library/react";
4
- import { communityDowngradeTiers, useCommunityMembership } from "../communities/useCommunityMembership";
4
+ import { communityDowngradeTiers, communityMembershipBilling, useCommunityMembership } from "../communities/useCommunityMembership";
5
5
  import { account, calls, detailRoute, installAdapter, meRoute, post, restoreAdapter, tier, viewer, wrapper } from "./communityHarness";
6
6
 
7
7
  /**
@@ -63,6 +63,34 @@ describe("communityDowngradeTiers", () => {
63
63
  });
64
64
  });
65
65
 
66
+ describe("communityMembershipBilling", () => {
67
+ it("reads the member's own subscription: amount, currency and cycle", () => {
68
+ expect(communityMembershipBilling({ amount: 12.5, currency: "EUR", billingCycle: "year" }, basic)).toEqual({
69
+ isPaid: true,
70
+ amount: 12.5,
71
+ currency: "EUR",
72
+ cycle: "year",
73
+ });
74
+ });
75
+
76
+ it("no price on the row is a free membership, whatever the tier says", () => {
77
+ expect(communityMembershipBilling({ amount: null, currency: null, billingCycle: null }, gold)).toEqual({
78
+ isPaid: false,
79
+ amount: null,
80
+ currency: null,
81
+ cycle: null,
82
+ });
83
+ expect(communityMembershipBilling({ amount: 0, currency: "USD", billingCycle: "month" }, gold).isPaid).toBe(false);
84
+ });
85
+
86
+ it("until the row arrives, the tier's own price decides paid or free, with no amount shown", () => {
87
+ expect(communityMembershipBilling(null, gold)).toEqual({ isPaid: true, amount: null, currency: null, cycle: null });
88
+ expect(communityMembershipBilling(null, free).isPaid).toBe(false);
89
+ expect(communityMembershipBilling(null, yearlyCheap).isPaid).toBe(true);
90
+ expect(communityMembershipBilling(undefined, null).isPaid).toBe(false);
91
+ });
92
+ });
93
+
66
94
  describe("useCommunityMembership", () => {
67
95
  it("shows the tier, the renewal date and what it can move down to", async () => {
68
96
  state = onGold();