@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,195 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import { act, renderHook, waitFor } from "@testing-library/react";
4
+ import {
5
+ calendarMonthWindow,
6
+ calendarMonthStart,
7
+ callJoinable,
8
+ groupCalendarItems,
9
+ nextRsvp,
10
+ rsvpErrorCode,
11
+ useCommunityCalendar,
12
+ } from "../communities/useCommunityCalendar";
13
+ import type { CommunityCalendarCall, CommunityCalendarEvent } from "../../../data/queries/useCommunityCalendar";
14
+ import { PROFILE_ID, account, calls, get, installAdapter, restoreAdapter, wrapper, type Route } from "./communityHarness";
15
+
16
+ /**
17
+ * The community calendar as the site reads it: one month per request, grouped
18
+ * by the viewer's own days with what is still ahead on top, and going or not
19
+ * going on a call (what is SENT, and that the new counts land in the cache).
20
+ */
21
+
22
+ afterEach(restoreAdapter);
23
+
24
+ /** Local wall-clock times, so the day grouping holds in any test time zone. */
25
+ const at = (day: number, hour: number) => new Date(2026, 8, day, hour, 0, 0).toISOString();
26
+ const NOW = new Date(2026, 8, 15, 12, 0, 0);
27
+
28
+ const call = (overrides: Partial<CommunityCalendarCall> = {}): CommunityCalendarCall => ({
29
+ kind: "call",
30
+ id: "11111111-aaaa-4aaa-8aaa-111111111111",
31
+ title: "Weekly Q&A",
32
+ description: null,
33
+ startsAt: at(16, 18),
34
+ endsAt: at(16, 19),
35
+ timezone: "Europe/Berlin",
36
+ participation: "open",
37
+ role: "attendee",
38
+ audiences: [],
39
+ opensAt: at(16, 17),
40
+ closesAt: at(16, 19),
41
+ isOpen: false,
42
+ callPublicId: "pub-1",
43
+ rsvp: { going: 2, notGoing: 1, mine: null },
44
+ icsUrl: "https://api.test/calendar/calls/c1.ics",
45
+ recording: null,
46
+ ...overrides,
47
+ });
48
+
49
+ const event = (overrides: Partial<CommunityCalendarEvent> = {}): CommunityCalendarEvent => ({
50
+ kind: "event",
51
+ id: "event-1",
52
+ title: "Album launch",
53
+ description: null,
54
+ slug: "album-launch",
55
+ startsAt: at(16, 20),
56
+ endsAt: null,
57
+ timezone: "Europe/Berlin",
58
+ type: "physical",
59
+ address: { name: "The Club", city: "Berlin" },
60
+ cancelled: false,
61
+ icsUrl: "https://api.test/calendar/events/e1.ics",
62
+ ...overrides,
63
+ });
64
+
65
+ const put =
66
+ (path: string, answer: (req: { body: Record<string, unknown> | null }) => unknown): Route =>
67
+ (req) =>
68
+ req.method === "put" && req.url === path ? answer(req) : undefined;
69
+
70
+ describe("calendar helpers", () => {
71
+ it("reads one month from its first local midnight to the next", () => {
72
+ const { from, to } = calendarMonthWindow(calendarMonthStart(NOW));
73
+ expect(from).toEqual(new Date(2026, 8, 1));
74
+ expect(to).toEqual(new Date(2026, 9, 1));
75
+ expect(calendarMonthStart(NOW, -1)).toEqual(new Date(2026, 7, 1));
76
+ expect(calendarMonthStart(NOW, 4)).toEqual(new Date(2027, 0, 1));
77
+ });
78
+
79
+ it("groups by day, upcoming soonest first, then what already happened, latest first", () => {
80
+ const items = [
81
+ event({ id: "e-late", startsAt: at(16, 20) }),
82
+ call({ id: "c-early", startsAt: at(16, 9), endsAt: at(16, 10) }),
83
+ call({ id: "c-next-week", startsAt: at(22, 18), endsAt: at(22, 19) }),
84
+ call({ id: "c-past", startsAt: at(3, 18), endsAt: at(3, 19) }),
85
+ event({ id: "e-past", startsAt: at(10, 20), endsAt: at(10, 23) }),
86
+ ];
87
+ const { upcoming, past } = groupCalendarItems(items, NOW);
88
+ expect(upcoming.map((day) => [day.key, day.items.map((item) => item.id)])).toEqual([
89
+ ["2026-09-16", ["c-early", "e-late"]],
90
+ ["2026-09-22", ["c-next-week"]],
91
+ ]);
92
+ expect(past.map((day) => [day.key, day.items.map((item) => item.id)])).toEqual([
93
+ ["2026-09-10", ["e-past"]],
94
+ ["2026-09-03", ["c-past"]],
95
+ ]);
96
+ });
97
+
98
+ it("keeps a call that is on right now with what is ahead", () => {
99
+ const live = call({ id: "live", startsAt: at(15, 11), endsAt: at(15, 13), isOpen: true });
100
+ expect(groupCalendarItems([live], NOW).upcoming[0]!.items[0]!.id).toBe("live");
101
+ });
102
+
103
+ it("clears an answer when the same one is pressed again", () => {
104
+ expect(nextRsvp(null, "going")).toBe("going");
105
+ expect(nextRsvp("going", "going")).toBeNull();
106
+ expect(nextRsvp("going", "not_going")).toBe("not_going");
107
+ });
108
+
109
+ it("offers Join only while the room is open and there is one", () => {
110
+ expect(callJoinable(call({ isOpen: true }))).toBe(true);
111
+ expect(callJoinable(call({ isOpen: false }))).toBe(false);
112
+ expect(callJoinable(call({ isOpen: true, callPublicId: null }))).toBe(false);
113
+ });
114
+
115
+ it("reads a refused answer", () => {
116
+ expect(rsvpErrorCode({ response: { status: 409 } })).toBe("closed");
117
+ expect(rsvpErrorCode({ response: { status: 404 } })).toBe("not_found");
118
+ expect(rsvpErrorCode(new Error("network"))).toBe("failed");
119
+ });
120
+ });
121
+
122
+ describe("useCommunityCalendar", () => {
123
+ const PATH = "/public/communities/fan-club/calendar";
124
+ const RSVP = `/public/communities/fan-club/calendar/calls/${call().id}/rsvp`;
125
+
126
+ it("asks for the month shown, and the next one on the arrow", async () => {
127
+ installAdapter([get(PATH, () => [call(), event()])]);
128
+ const { result } = renderHook(() => useCommunityCalendar({ now: NOW }), { wrapper: wrapper({ user: account() }) });
129
+ await waitFor(() => expect(result.current.items).toHaveLength(2));
130
+ expect(calls("get", PATH)[0]!.params).toEqual({
131
+ profileId: PROFILE_ID,
132
+ from: new Date(2026, 8, 1).toISOString(),
133
+ to: new Date(2026, 9, 1).toISOString(),
134
+ });
135
+ act(() => result.current.goToNextMonth());
136
+ await waitFor(() => expect(calls("get", PATH)).toHaveLength(2));
137
+ expect(calls("get", PATH)[1]!.params).toMatchObject({
138
+ from: new Date(2026, 9, 1).toISOString(),
139
+ to: new Date(2026, 10, 1).toISOString(),
140
+ });
141
+ expect(result.current.isCurrentMonth).toBe(false);
142
+ });
143
+
144
+ it("sends going, and puts the new counts in the cache", async () => {
145
+ installAdapter([
146
+ get(PATH, () => [call()]),
147
+ put(RSVP, (req) => ({ going: 3, notGoing: 1, mine: req.body?.response ?? null })),
148
+ ]);
149
+ const { result } = renderHook(() => useCommunityCalendar({ now: NOW }), { wrapper: wrapper({ user: account() }) });
150
+ await waitFor(() => expect(result.current.items).toHaveLength(1));
151
+ act(() => result.current.answer(result.current.items[0] as CommunityCalendarCall, "going"));
152
+ await waitFor(() => expect((result.current.items[0] as CommunityCalendarCall).rsvp).toEqual({ going: 3, notGoing: 1, mine: "going" }));
153
+ expect(calls("put", RSVP)[0]!.body).toEqual({ profileId: PROFILE_ID, response: "going" });
154
+ // Only the one GET: the answer's own reply is what updates the list.
155
+ expect(calls("get", PATH)).toHaveLength(1);
156
+ });
157
+
158
+ it("clears the answer when the chosen one is pressed again", async () => {
159
+ installAdapter([
160
+ get(PATH, () => [call({ rsvp: { going: 3, notGoing: 1, mine: "going" } })]),
161
+ put(RSVP, () => ({ going: 2, notGoing: 1, mine: null })),
162
+ ]);
163
+ const { result } = renderHook(() => useCommunityCalendar({ now: NOW }), { wrapper: wrapper({ user: account() }) });
164
+ await waitFor(() => expect(result.current.items).toHaveLength(1));
165
+ act(() => result.current.answer(result.current.items[0] as CommunityCalendarCall, "going"));
166
+ await waitFor(() => expect((result.current.items[0] as CommunityCalendarCall).rsvp.mine).toBeNull());
167
+ expect(calls("put", RSVP)[0]!.body).toEqual({ profileId: PROFILE_ID, response: null });
168
+ });
169
+
170
+ it("keeps a 409 against the call it was for", async () => {
171
+ installAdapter([
172
+ get(PATH, () => [call()]),
173
+ put(RSVP, () => {
174
+ throw { status: 409, message: "Closed" };
175
+ }),
176
+ ]);
177
+ const { result } = renderHook(() => useCommunityCalendar({ now: NOW }), { wrapper: wrapper({ user: account() }) });
178
+ await waitFor(() => expect(result.current.items).toHaveLength(1));
179
+ act(() => result.current.answer(result.current.items[0] as CommunityCalendarCall, "not_going"));
180
+ await waitFor(() => expect(result.current.rsvpErrorFor(call().id)).toBe("closed"));
181
+ expect(result.current.rsvpErrorFor("another-call")).toBeNull();
182
+ expect((result.current.items[0] as CommunityCalendarCall).rsvp).toEqual(call().rsvp);
183
+ });
184
+
185
+ it("is forbidden for a non-member", async () => {
186
+ installAdapter([
187
+ get(PATH, () => {
188
+ throw { status: 403, message: "Forbidden" };
189
+ }),
190
+ ]);
191
+ const { result } = renderHook(() => useCommunityCalendar({ now: NOW }), { wrapper: wrapper({ user: account() }) });
192
+ await waitFor(() => expect(result.current.forbidden).toBe(true));
193
+ expect(result.current.isError).toBe(false);
194
+ });
195
+ });
@@ -0,0 +1,120 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import { renderHook, waitFor } from "@testing-library/react";
4
+ import {
5
+ classroomCourseHref,
6
+ classroomCourseState,
7
+ classroomProgressPercent,
8
+ classroomUnlockParts,
9
+ useCommunityClassroom,
10
+ } from "../communities/useCommunityClassroom";
11
+ import type { CommunityClassroomCourse } from "../../../data/queries/useCommunityClassroom";
12
+ import { PROFILE_ID, account, calls, get, installAdapter, restoreAdapter, wrapper } from "./communityHarness";
13
+
14
+ /**
15
+ * The classroom tab's reading of the server: what state a card is in, how far
16
+ * through it the member is, what opens a locked one, where an open one leads,
17
+ * and what the hook SENDS (the community by slug and the profile, nothing more).
18
+ */
19
+
20
+ afterEach(restoreAdapter);
21
+
22
+ const classroomCourse =(overrides: Partial<CommunityClassroomCourse> = {}): CommunityClassroomCourse => ({
23
+ courseId: "course-1",
24
+ title: "Songwriting 101",
25
+ description: "From idea to demo.",
26
+ slug: "songwriting-101",
27
+ coverUrl: null,
28
+ open: true,
29
+ progress: { completed: 3, total: 10, completedAt: null, currentLessonId: "lesson-4" },
30
+ unlockLevel: null,
31
+ unlockTiers: [],
32
+ accessId: "acc-1",
33
+ ...overrides,
34
+ });
35
+
36
+ describe("classroom helpers", () => {
37
+ it("reads progress as a percentage, full once completed", () => {
38
+ expect(classroomProgressPercent({ completed: 3, total: 10, completedAt: null, currentLessonId: null })).toBe(30);
39
+ expect(classroomProgressPercent({ completed: 0, total: 0, completedAt: null, currentLessonId: null })).toBe(0);
40
+ expect(
41
+ classroomProgressPercent({ completed: 2, total: 10, completedAt: "2026-09-01T00:00:00.000Z", currentLessonId: null }),
42
+ ).toBe(100);
43
+ expect(classroomProgressPercent(null)).toBe(0);
44
+ });
45
+
46
+ it("names the card's state", () => {
47
+ expect(classroomCourseState(classroomCourse())).toBe("in_progress");
48
+ expect(classroomCourseState(classroomCourse({ progress: { completed: 0, total: 5, completedAt: null, currentLessonId: null } }))).toBe(
49
+ "not_started",
50
+ );
51
+ expect(classroomCourseState(classroomCourse({ progress: { completed: 5, total: 5, completedAt: null, currentLessonId: null } }))).toBe(
52
+ "completed",
53
+ );
54
+ expect(classroomCourseState(classroomCourse({ open: false, progress: null }))).toBe("locked");
55
+ });
56
+
57
+ it("says what opens a locked course", () => {
58
+ expect(classroomUnlockParts(classroomCourse({ open: false, unlockLevel: 4 }))).toEqual({ level: 4, tiers: "" });
59
+ expect(
60
+ classroomUnlockParts(
61
+ classroomCourse({
62
+ open: false,
63
+ unlockTiers: [
64
+ { id: "t-gold", name: "Gold" },
65
+ { id: "t-vip", name: "VIP" },
66
+ ],
67
+ }),
68
+ ),
69
+ ).toEqual({ level: null, tiers: "Gold, VIP" });
70
+ });
71
+
72
+ it("opens the course player, and never falls back to the sales page", () => {
73
+ expect(classroomCourseHref({ open: true, accessId: "acc-9" })).toBe("/i/course-access/acc-9");
74
+ expect(classroomCourseHref({ open: true, accessId: null })).toBeNull();
75
+ expect(classroomCourseHref({ open: false, accessId: null })).toBeNull();
76
+ });
77
+ });
78
+
79
+ describe("useCommunityClassroom", () => {
80
+ it("reads the classroom by slug with the profile", async () => {
81
+ installAdapter([get("/public/communities/fan-club/classroom", () => [classroomCourse()])]);
82
+ const { result } = renderHook(() => useCommunityClassroom(), { wrapper: wrapper({ user: account() }) });
83
+ await waitFor(() => expect(result.current.courses).toHaveLength(1));
84
+ const sent = calls("get", "/public/communities/fan-club/classroom");
85
+ expect(sent).toHaveLength(1);
86
+ expect(sent[0]!.params).toEqual({ profileId: PROFILE_ID });
87
+ expect(result.current.forbidden).toBe(false);
88
+ });
89
+
90
+ it("is forbidden, not broken, for a non-member", async () => {
91
+ installAdapter([
92
+ get("/public/communities/fan-club/classroom", () => {
93
+ throw { status: 403, message: "Forbidden" };
94
+ }),
95
+ ]);
96
+ const { result } = renderHook(() => useCommunityClassroom(), { wrapper: wrapper({ user: account() }) });
97
+ await waitFor(() => expect(result.current.forbidden).toBe(true));
98
+ expect(result.current.isError).toBe(false);
99
+ // A 403 is an answer: asking again changes nothing.
100
+ expect(calls("get", "/public/communities/fan-club/classroom")).toHaveLength(1);
101
+ });
102
+
103
+ it("asks nothing while signed out", async () => {
104
+ installAdapter([get("/public/communities/fan-club/classroom", () => [classroomCourse()])]);
105
+ const { result } = renderHook(() => useCommunityClassroom(), { wrapper: wrapper() });
106
+ await new Promise((resolve) => setTimeout(resolve, 20));
107
+ expect(result.current.courses).toEqual([]);
108
+ expect(calls("get", "/public/communities/fan-club/classroom")).toHaveLength(0);
109
+ });
110
+
111
+ it("offers an upgrade only when a tier includes the course", async () => {
112
+ installAdapter([get("/public/communities/fan-club/classroom", () => [])]);
113
+ const { result } = renderHook(() => useCommunityClassroom(), { wrapper: wrapper({ user: account() }) });
114
+ const tierLocked = classroomCourse({ open: false, progress: null, unlockTiers: [{ id: "t-gold", name: "Gold" }] });
115
+ const levelLocked = classroomCourse({ open: false, progress: null, unlockLevel: 3 });
116
+ expect(result.current.canUpgrade(tierLocked)).toBe(true);
117
+ expect(result.current.canUpgrade(levelLocked)).toBe(false);
118
+ expect(result.current.unlockHref(tierLocked)).toBe("/i/communities/fan-club?tier=t-gold");
119
+ });
120
+ });
@@ -141,12 +141,15 @@ export const get = (path: string | RegExp, answer: (req: Sent) => unknown): Rout
141
141
  req.method === "get" && (typeof path === "string" ? req.url === path : path.test(req.url)) ? answer(req) : undefined;
142
142
 
143
143
  export interface AuthControls {
144
- logout: () => void;
144
+ logout?: () => void;
145
+ /** Sees every session the email-first flow hands to `adoptSession`. */
146
+ adopted?: (session: unknown) => void;
145
147
  }
146
148
 
147
149
  /**
148
150
  * A stateful auth context: `register` succeeds and asks for a code,
149
- * `verifyRegistration` signs the fan in, `validateLoginCredentials` +
151
+ * `verifyRegistration` signs the fan in, `adoptSession` (the email-first flow)
152
+ * signs in whoever the session names, `validateLoginCredentials` +
150
153
  * `verifyLoginCode` do the same for sign-in, `logout` signs out.
151
154
  */
152
155
  function TestAuth({ initialUser, children, controls }: { initialUser: unknown; children: ReactNode; controls?: AuthControls }) {
@@ -168,8 +171,12 @@ function TestAuth({ initialUser, children, controls }: { initialUser: unknown; c
168
171
  return {};
169
172
  },
170
173
  resendLoginCode: async () => {},
174
+ adoptSession: (session: { account: ReturnType<typeof account> }) => {
175
+ controls?.adopted?.(session);
176
+ setUser(session.account);
177
+ },
171
178
  logout: async () => {
172
- controls?.logout();
179
+ controls?.logout?.();
173
180
  setUser(null);
174
181
  },
175
182
  refetchUser: async () => {},
@@ -0,0 +1,316 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import { act, renderHook, waitFor } from "@testing-library/react";
4
+ import { buildDirectoryParams } from "../../../data/queries/useCommunityMembers";
5
+ import { mentionDirectoryParams, useCommunityMentionDirectory } from "../../../data/queries/useCommunity";
6
+ import { COUNTRY_CENTROIDS, COUNTRY_CODES, countryName, countryOptions, isCountryCode } from "../community/countries";
7
+ import {
8
+ memberGlobeMarkers,
9
+ memberGlobePoints,
10
+ memberLocationLabel,
11
+ memberMapCountries,
12
+ useCommunityMemberDirectory,
13
+ } from "../community/useCommunityMembers";
14
+ import { useMentionComposer } from "../community/useMentionComposer";
15
+ import { useMemberHomeNav } from "../memberHome/useMemberHomeNav";
16
+ import {
17
+ COMMUNITY,
18
+ PROFILE_ID,
19
+ account,
20
+ calls,
21
+ get,
22
+ installAdapter,
23
+ restoreAdapter,
24
+ viewer,
25
+ wrapper,
26
+ } from "./communityHarness";
27
+
28
+ /**
29
+ * The member directory and map on the site: the query string the directory
30
+ * sends, country names in the viewer's language, the globe's dots, where a
31
+ * member says they are, the @mention picker searching every member by what is
32
+ * typed, and the Members tab showing to members only.
33
+ */
34
+
35
+ afterEach(restoreAdapter);
36
+
37
+ const PAGE = (data: unknown[] = [], extra: Record<string, unknown> = {}) => ({
38
+ data,
39
+ total: data.length,
40
+ page: 1,
41
+ pageSize: 24,
42
+ hasNextPage: false,
43
+ ...extra,
44
+ });
45
+
46
+ describe("buildDirectoryParams", () => {
47
+ it("defaults to the most active members, page 1, 24 a page", () => {
48
+ expect(buildDirectoryParams()).toEqual({ sort: "active", page: 1, limit: 24 });
49
+ });
50
+
51
+ it("sends a trimmed search, a role, a level and a sort", () => {
52
+ expect(
53
+ buildDirectoryParams({ q: " ada ", role: "moderator", minLevel: 3, sort: "points", page: 2, limit: 12 }),
54
+ ).toEqual({
55
+ q: "ada",
56
+ role: "moderator",
57
+ minLevel: 3,
58
+ sort: "points",
59
+ page: 2,
60
+ limit: 12,
61
+ });
62
+ });
63
+
64
+ it("drops what the server would refuse", () => {
65
+ expect(buildDirectoryParams({ q: " ", role: "owner" as never, minLevel: 12, page: 0, limit: 500 })).toEqual({
66
+ sort: "active",
67
+ page: 1,
68
+ limit: 100,
69
+ });
70
+ expect(buildDirectoryParams({ minLevel: 0, limit: -3 })).toEqual({ sort: "active", page: 1, limit: 1 });
71
+ expect(buildDirectoryParams({ q: "x".repeat(200) }).q).toHaveLength(80);
72
+ });
73
+ });
74
+
75
+ describe("countries", () => {
76
+ it("names a country in the viewer's language", () => {
77
+ expect(countryName("DE", "en")).toBe("Germany");
78
+ expect(countryName("de", "de")).toBe("Deutschland");
79
+ expect(countryName(null)).toBeNull();
80
+ });
81
+
82
+ it("lists every code once, sorted by name in the locale", () => {
83
+ const options = countryOptions("en");
84
+ expect(options).toHaveLength(COUNTRY_CODES.length);
85
+ expect(new Set(COUNTRY_CODES).size).toBe(COUNTRY_CODES.length);
86
+ const names = options.map((o) => o.name);
87
+ expect([...names].sort(new Intl.Collator("en").compare)).toEqual(names);
88
+ expect(options.find((o) => o.code === "NG")?.name).toBe("Nigeria");
89
+ });
90
+
91
+ it("knows a code, whatever its case, and has a centre for every centred code it lists", () => {
92
+ expect(isCountryCode("gb")).toBe(true);
93
+ expect(isCountryCode("XX")).toBe(false);
94
+ for (const code of Object.keys(COUNTRY_CENTROIDS)) expect(COUNTRY_CODES).toContain(code);
95
+ });
96
+ });
97
+
98
+ describe("memberGlobePoints", () => {
99
+ it("places each country at its centre, merges repeats, drops unknown and empty, biggest first", () => {
100
+ const points = memberGlobePoints([
101
+ { countryCode: "de", members: 3 },
102
+ { countryCode: "NG", members: 10 },
103
+ { countryCode: "DE", members: 2 },
104
+ { countryCode: "XX", members: 5 },
105
+ { countryCode: "FR", members: 0 },
106
+ ]);
107
+ expect(points).toEqual([
108
+ { countryCode: "NG", latitude: COUNTRY_CENTROIDS.NG[0], longitude: COUNTRY_CENTROIDS.NG[1], members: 10 },
109
+ { countryCode: "DE", latitude: COUNTRY_CENTROIDS.DE[0], longitude: COUNTRY_CENTROIDS.DE[1], members: 5 },
110
+ ]);
111
+ expect(memberGlobePoints(undefined)).toEqual([]);
112
+ });
113
+
114
+ it("keeps a country with no known centre in the list, but not on the globe", () => {
115
+ const rows = [
116
+ { countryCode: "GU", members: 2 },
117
+ { countryCode: "NG", members: 1 },
118
+ { countryCode: "bad", members: 9 },
119
+ ];
120
+ expect(memberMapCountries(rows)).toEqual([
121
+ { countryCode: "GU", members: 2 },
122
+ { countryCode: "NG", members: 1 },
123
+ ]);
124
+ expect(memberGlobePoints(rows).map((p) => p.countryCode)).toEqual(["NG"]);
125
+ });
126
+
127
+ it("sizes dots by the square root of each country's share", () => {
128
+ const markers = memberGlobeMarkers([
129
+ { countryCode: "NG", latitude: 9.1, longitude: 8.7, members: 100 },
130
+ { countryCode: "DE", latitude: 51.2, longitude: 10.5, members: 25 },
131
+ ]);
132
+ expect(markers[0]).toEqual({ location: [9.1, 8.7], size: 0.12 });
133
+ expect(markers[1].size).toBeCloseTo(0.03 + 0.09 * 0.5);
134
+ });
135
+ });
136
+
137
+ describe("memberLocationLabel", () => {
138
+ it("joins city and country, or shows whichever is set", () => {
139
+ expect(memberLocationLabel({ city: "Berlin", countryCode: "DE" }, "en")).toBe("Berlin, Germany");
140
+ expect(memberLocationLabel({ city: " ", countryCode: "DE" }, "de")).toBe("Deutschland");
141
+ expect(memberLocationLabel({ city: "Lagos", countryCode: null })).toBe("Lagos");
142
+ expect(memberLocationLabel({ city: null, countryCode: null })).toBeNull();
143
+ });
144
+ });
145
+
146
+ describe("mention search", () => {
147
+ it("builds the member search from the typed text", () => {
148
+ expect(mentionDirectoryParams("ad")).toEqual({ q: "ad", limit: 10, sort: "active" });
149
+ expect(mentionDirectoryParams("")).toEqual({ limit: 10, sort: "active" });
150
+ expect(mentionDirectoryParams(undefined)).toEqual({ limit: 10, sort: "active" });
151
+ });
152
+
153
+ it("asks nothing until there is an @token, then searches the members endpoint with q", async () => {
154
+ installAdapter([
155
+ get("/public/community/members", (req) =>
156
+ PAGE([
157
+ {
158
+ accountId: "a-2",
159
+ displayName: `Found ${req.params?.q ?? "all"}`,
160
+ avatarUrl: null,
161
+ level: 2,
162
+ points: 40,
163
+ firstName: "Ada",
164
+ lastName: null,
165
+ },
166
+ ]),
167
+ ),
168
+ ]);
169
+ const { result, rerender } = renderHook(({ q }: { q?: string }) => useCommunityMentionDirectory(q), {
170
+ wrapper: wrapper({ user: account() }),
171
+ initialProps: {},
172
+ });
173
+ await new Promise((resolve) => setTimeout(resolve, 200));
174
+ expect(calls("get", "/public/community/members")).toHaveLength(0);
175
+
176
+ rerender({ q: "ad" });
177
+ await waitFor(() => expect(result.current.data?.[0]?.displayName).toBe("Found ad"));
178
+ expect(calls("get", "/public/community/members").at(-1)?.params).toEqual({
179
+ profileId: PROFILE_ID,
180
+ communityId: COMMUNITY.id,
181
+ q: "ad",
182
+ limit: 10,
183
+ sort: "active",
184
+ });
185
+ // The composers read the leaderboard entry shape they always have.
186
+ expect(result.current.data?.[0]).toEqual({
187
+ accountId: "a-2",
188
+ displayName: "Found ad",
189
+ avatarUrl: null,
190
+ points: 40,
191
+ level: 2,
192
+ firstName: "Ada",
193
+ lastName: null,
194
+ });
195
+ expect(calls("get", "/public/community/leaderboard")).toHaveLength(0);
196
+ });
197
+
198
+ it("feeds the composer's suggestions from what the member types", async () => {
199
+ installAdapter([
200
+ get("/public/community/members", () =>
201
+ PAGE([
202
+ {
203
+ accountId: "a-9",
204
+ displayName: "Zara",
205
+ avatarUrl: null,
206
+ level: 1,
207
+ points: 0,
208
+ firstName: null,
209
+ lastName: null,
210
+ },
211
+ ]),
212
+ ),
213
+ ]);
214
+ const { result } = renderHook(() => useMentionComposer(), { wrapper: wrapper({ user: account() }) });
215
+ act(() => result.current.onTextChange("hi @za", 6));
216
+ await waitFor(() => expect(result.current.suggestions.map((s) => s.name)).toEqual(["Zara"]));
217
+ // Every request carried what was typed: none went out for the whole directory first.
218
+ expect(calls("get", "/public/community/members").map((r) => r.params?.q)).toEqual(["za"]);
219
+ });
220
+ });
221
+
222
+ describe("useCommunityMemberDirectory", () => {
223
+ it("sends the search once typing settles, and goes back to page 1 on a filter", async () => {
224
+ installAdapter([get("/public/community/members", () => PAGE([], { hasNextPage: true, total: 60 }))]);
225
+ const { result } = renderHook(() => useCommunityMemberDirectory(), { wrapper: wrapper({ user: account() }) });
226
+ await waitFor(() => expect(result.current.hasNextPage).toBe(true));
227
+ act(() => result.current.nextPage());
228
+ await waitFor(() => expect(calls("get", "/public/community/members").some((r) => r.params?.page === 2)).toBe(true));
229
+
230
+ act(() => result.current.setSearch("ada"));
231
+ await waitFor(() =>
232
+ expect(calls("get", "/public/community/members").some((r) => r.params?.q === "ada" && r.params?.page === 1)).toBe(
233
+ true,
234
+ ),
235
+ );
236
+
237
+ act(() => result.current.setRole("moderator"));
238
+ await waitFor(() =>
239
+ expect(
240
+ calls("get", "/public/community/members").some(
241
+ (r) => r.params?.role === "moderator" && r.params?.q === "ada" && r.params?.page === 1,
242
+ ),
243
+ ).toBe(true),
244
+ );
245
+ expect(result.current.filtered).toBe(true);
246
+ });
247
+
248
+ it("loads the map only once the map is shown, and shapes it into dots", async () => {
249
+ installAdapter([
250
+ get("/public/community/members", () => PAGE()),
251
+ get("/public/community/members/map", () => [
252
+ { countryCode: "NG", members: 4 },
253
+ { countryCode: "GB", members: 2 },
254
+ ]),
255
+ ]);
256
+ const { result } = renderHook(() => useCommunityMemberDirectory(), { wrapper: wrapper({ user: account() }) });
257
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
258
+ expect(calls("get", "/public/community/members/map")).toHaveLength(0);
259
+ act(() => result.current.setView("map"));
260
+ await waitFor(() => expect(result.current.mapPoints.map((p) => p.countryCode)).toEqual(["NG", "GB"]));
261
+ expect(result.current.mapMembers).toBe(6);
262
+ });
263
+
264
+ it("says when the viewer is not a member", async () => {
265
+ installAdapter([
266
+ get("/public/community/members", () => {
267
+ throw { status: 403, message: "Forbidden" };
268
+ }),
269
+ ]);
270
+ const { result } = renderHook(() => useCommunityMemberDirectory(), { wrapper: wrapper({ user: account() }) });
271
+ await waitFor(() => expect(result.current.forbidden).toBe(true));
272
+ });
273
+ });
274
+
275
+ describe("the Members tab in the member home nav", () => {
276
+ it("shows to a member", async () => {
277
+ installAdapter([
278
+ get(/^\/public\/communities\/fan-club\/me$/, () => viewer({ state: "member", canInteract: true })),
279
+ ]);
280
+ const { result } = renderHook(() => useMemberHomeNav({ currentPath: "/i/communities/fan-club/home/members" }), {
281
+ wrapper: wrapper({ user: account() }),
282
+ });
283
+ await waitFor(() => expect(result.current.visibleItems.map((i) => i.key)).toContain("members"));
284
+ expect(result.current.activeKey).toBe("members");
285
+ });
286
+
287
+ it("stays hidden signed out", async () => {
288
+ installAdapter([]);
289
+ const { result } = renderHook(() => useMemberHomeNav({}), { wrapper: wrapper() });
290
+ await waitFor(() => expect(result.current.isReady).toBe(true));
291
+ expect(result.current.visibleItems.map((i) => i.key)).not.toContain("members");
292
+ });
293
+
294
+ it("shows Calendar and Classroom to a member by the same rule, with no call needed", async () => {
295
+ installAdapter([
296
+ get(/^\/public\/communities\/fan-club\/me$/, () => viewer({ state: "member", canInteract: true })),
297
+ ]);
298
+ const { result } = renderHook(() => useMemberHomeNav({ currentPath: "/i/communities/fan-club/home/classroom" }), {
299
+ wrapper: wrapper({ user: account() }),
300
+ });
301
+ await waitFor(() => expect(result.current.visibleItems.map((i) => i.key)).toContain("classroom"));
302
+ expect(result.current.visibleItems.map((i) => i.key)).toContain("calendar");
303
+ expect(result.current.activeKey).toBe("classroom");
304
+ expect(result.current.items.find((i) => i.key === "classroom")?.href).toBe("/i/communities/fan-club/home/classroom");
305
+ });
306
+
307
+ it("hides Calendar and Classroom from someone who is not a member", async () => {
308
+ installAdapter([get(/^\/public\/communities\/fan-club\/me$/, () => viewer({ state: "none" }))]);
309
+ const { result } = renderHook(() => useMemberHomeNav({}), { wrapper: wrapper({ user: account() }) });
310
+ await waitFor(() => expect(result.current.isReady).toBe(true));
311
+ await waitFor(() => expect(calls("get", /^\/public\/communities\/fan-club\/me$/).length).toBeGreaterThan(0));
312
+ const keys = result.current.visibleItems.map((i) => i.key);
313
+ expect(keys).not.toContain("calendar");
314
+ expect(keys).not.toContain("classroom");
315
+ });
316
+ });