@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,226 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ buildAppHomeSections,
4
+ buildPortalTiles,
5
+ courseProgress,
6
+ pickLearningCourse,
7
+ pickNextCall,
8
+ pickNextTicket,
9
+ upcomingEvents,
10
+ type AppHomeData,
11
+ } from "../siteApp/appHome";
12
+ import type { SiteAppHomeSection } from "../../../data/queries/useSiteApp";
13
+ import type { PortalSummary } from "../portal/usePortal";
14
+ import type { MyBooking } from "../../../data/queries/useMyBookings";
15
+ import type { MyTicket } from "../../../data/queries/useMyTickets";
16
+ import type { MyMembershipCall } from "../../../data/queries/useMembershipCalls";
17
+ import type { CourseAccessData, IEvent } from "../../../types/models";
18
+
19
+ /**
20
+ * The app home's sections: the configured order, hidden entries skipped,
21
+ * empty sections skipped, and the portal drawn whatever it holds.
22
+ */
23
+
24
+ const builtin = (key: string, visible = true) => ({ type: "builtin", key, visible }) as SiteAppHomeSection;
25
+ const link = (id: string, label: string, href: string, visible = true) =>
26
+ ({ type: "link", id, label, href, visible }) as SiteAppHomeSection;
27
+
28
+ const community = (id: string) => ({
29
+ id,
30
+ name: `Community ${id}`,
31
+ slug: `c-${id}`,
32
+ logoUrl: null,
33
+ coverUrl: null,
34
+ tierName: null,
35
+ memberCount: 3,
36
+ href: `/i/communities/c-${id}`,
37
+ });
38
+
39
+ const full: AppHomeData = {
40
+ attention: [
41
+ {
42
+ kind: "invoice_due",
43
+ id: "inv-1",
44
+ invoiceNumber: "1042",
45
+ total: "450",
46
+ currency: "USD",
47
+ dueDate: null,
48
+ projectId: null,
49
+ projectName: null,
50
+ },
51
+ ],
52
+ communities: [community("a")],
53
+ nextCall: { id: "call:1", title: "Group call", startsAt: "2026-10-02T18:00:00Z", joinable: false, href: "/x", context: null },
54
+ nextTicket: null,
55
+ learning: { accessId: "acc-1", title: "Pricing", href: "/i/course-access/acc-1", progress: null },
56
+ explore: [community("b"), community("c"), community("d"), community("e")],
57
+ events: [{ id: "e-1" } as IEvent],
58
+ courses: [],
59
+ products: [],
60
+ };
61
+
62
+ const kinds = (config: SiteAppHomeSection[], data: AppHomeData) => buildAppHomeSections(config, data).map((s) => s.kind);
63
+
64
+ describe("buildAppHomeSections", () => {
65
+ it("draws the sections in the configured order", () => {
66
+ const config = [builtin("learning"), builtin("portal"), builtin("needs_you"), builtin("communities"), builtin("coming_up")];
67
+ expect(kinds(config, full)).toEqual(["learning", "portal", "needs_you", "communities", "coming_up"]);
68
+ });
69
+
70
+ it("skips hidden sections", () => {
71
+ const config = [builtin("needs_you", false), builtin("portal"), builtin("events", false), builtin("communities")];
72
+ expect(kinds(config, full)).toEqual(["portal", "communities"]);
73
+ });
74
+
75
+ it("skips a section with nothing to show, except the portal", () => {
76
+ const config = [
77
+ builtin("needs_you"),
78
+ builtin("portal"),
79
+ builtin("communities"),
80
+ builtin("coming_up"),
81
+ builtin("learning"),
82
+ builtin("explore_communities"),
83
+ builtin("events"),
84
+ builtin("courses"),
85
+ builtin("shop"),
86
+ ];
87
+ expect(kinds(config, {})).toEqual(["portal"]);
88
+ // The portal with no summary still has its Settings tile.
89
+ const [portal] = buildAppHomeSections([builtin("portal")], {});
90
+ expect(portal).toMatchObject({ kind: "portal", tiles: [{ key: "settings" }] });
91
+ // Courses and shop configured but empty are left out while the rest draw.
92
+ expect(kinds(config, full)).toEqual([
93
+ "needs_you",
94
+ "portal",
95
+ "communities",
96
+ "coming_up",
97
+ "learning",
98
+ "explore_communities",
99
+ "events",
100
+ ]);
101
+ });
102
+
103
+ it("draws link sections where they are placed, and only with a safe address", () => {
104
+ const config = [
105
+ link("l1", "Book a call", "/book"),
106
+ builtin("portal"),
107
+ link("l2", "Our podcast", "https://example.com/pod"),
108
+ link("l3", "Bad", "javascript:alert(1)"),
109
+ link("l4", "Off-site trick", "//evil.example"),
110
+ link("l5", " ", "/blank"),
111
+ link("l6", "Hidden", "/hidden", false),
112
+ ];
113
+ const sections = buildAppHomeSections(config, {});
114
+ expect(sections.map((s) => s.key)).toEqual(["l1", "portal", "l2"]);
115
+ expect(sections[0]).toEqual({ kind: "link", key: "l1", label: "Book a call", href: "/book" });
116
+ });
117
+
118
+ it("ignores unknown keys and repeats", () => {
119
+ const config = [builtin("portal"), builtin("mystery"), builtin("portal"), link("x", "A", "/a"), link("x", "B", "/b")];
120
+ expect(buildAppHomeSections(config, {}).map((s) => s.key)).toEqual(["portal", "x"]);
121
+ });
122
+
123
+ it("caps the explore list and turns attention into one-tap actions", () => {
124
+ const sections = buildAppHomeSections([builtin("needs_you"), builtin("explore_communities")], full);
125
+ expect(sections[0]).toMatchObject({ kind: "needs_you", actions: [{ action: "pay", href: "/i/invoice/inv-1" }] });
126
+ expect(sections[1]!.kind === "explore_communities" && sections[1]!.communities.length).toBe(3);
127
+ });
128
+
129
+ it("draws nothing until the settings arrive", () => {
130
+ expect(buildAppHomeSections(undefined, full)).toEqual([]);
131
+ });
132
+ });
133
+
134
+ describe("buildPortalTiles", () => {
135
+ const summary = (sections: Partial<PortalSummary["sections"]>, attention: PortalSummary["attention"] = []): PortalSummary =>
136
+ ({ sections, attention, lastActivity: { work: null, library: null } }) as PortalSummary;
137
+
138
+ it("draws a tile per section the person has anything in, with what needs them as the badge", () => {
139
+ const tiles = buildPortalTiles(
140
+ summary({ projects: 1, invoices: 2, documents: 1, orders: 0 }, [
141
+ { kind: "invoice_overdue", id: "i", invoiceNumber: "1", total: "1", currency: "USD", dueDate: null, projectId: null, projectName: null },
142
+ { kind: "document_to_sign", id: "d", title: "Agreement", token: "t", sentAt: "2026-01-01", expiresAt: null },
143
+ ]),
144
+ );
145
+ expect(tiles).toEqual([
146
+ { key: "projects", href: "/i/account/projects", attention: 0 },
147
+ { key: "invoices", href: "/i/account/invoices", attention: 1 },
148
+ { key: "documents", href: "/i/account/documents", attention: 1 },
149
+ { key: "settings", href: "/i/account/settings", attention: 0 },
150
+ ]);
151
+ });
152
+
153
+ it("is exactly the eight sections when the person has all of them", () => {
154
+ const tiles = buildPortalTiles(
155
+ summary({ projects: 1, bookings: 1, invoices: 1, documents: 1, courses: 1, orders: 1, tickets: 1, saved: 1 }),
156
+ );
157
+ expect(tiles.map((t) => t.key)).toEqual(["projects", "bookings", "invoices", "documents", "courses", "orders", "tickets", "saved"]);
158
+ });
159
+ });
160
+
161
+ describe("the section pickers", () => {
162
+ const now = new Date("2026-10-01T12:00:00Z");
163
+
164
+ it("picks the soonest call that has not ended, joinable when its room is open", () => {
165
+ const calls = [
166
+ { id: "c1", title: "Later call", startsAt: "2026-10-03T10:00:00Z", endsAt: "2026-10-03T11:00:00Z", isOpen: false, callPublicId: "room-1", audiences: [{ label: "Gold" }] },
167
+ { id: "c0", title: "Ended", startsAt: "2026-09-30T10:00:00Z", endsAt: "2026-09-30T11:00:00Z", isOpen: false, callPublicId: "room-0", audiences: [] },
168
+ ] as unknown as MyMembershipCall[];
169
+ const bookings = [
170
+ { id: "b1", status: "confirmed", sessionStartTime: "2026-10-01T12:05:00Z", sessionEndTime: "2026-10-01T13:00:00Z", videoCallId: "vc-1", coachingProductTitle: "1:1 session", location: { type: "video" } },
171
+ { id: "b2", status: "canceled", sessionStartTime: "2026-10-01T12:01:00Z", sessionEndTime: "2026-10-01T13:00:00Z", videoCallId: "vc-2", coachingProductTitle: "Cancelled" },
172
+ ] as unknown as MyBooking[];
173
+ expect(pickNextCall(calls, bookings, now)).toEqual({
174
+ id: "booking:b1",
175
+ title: "1:1 session",
176
+ startsAt: "2026-10-01T12:05:00Z",
177
+ joinable: true,
178
+ href: "/i/video-calls/vc-1",
179
+ context: null,
180
+ });
181
+ expect(pickNextCall(calls, [], now)).toMatchObject({ id: "call:c1", joinable: false, context: "Gold", href: "/i/video-calls/room-1" });
182
+ expect(pickNextCall([], [], now)).toBeNull();
183
+ });
184
+
185
+ it("picks the next ticket, counting its passes and skipping cancelled orders", () => {
186
+ const tickets = [
187
+ { id: "t2", status: "paid", selfCancelledAt: null, eventTitle: "Summit", eventDateTime: "2026-11-14T09:00:00Z", eventEndDateTime: null, items: [{ quantity: 2 }] },
188
+ { id: "t1", status: "refunded", selfCancelledAt: null, eventTitle: "Refunded", eventDateTime: "2026-10-05T09:00:00Z", eventEndDateTime: null, items: [] },
189
+ ] as unknown as MyTicket[];
190
+ expect(pickNextTicket(tickets, now)).toEqual({
191
+ id: "t2",
192
+ title: "Summit",
193
+ startsAt: "2026-11-14T09:00:00Z",
194
+ count: 2,
195
+ href: "/i/account/tickets",
196
+ });
197
+ });
198
+
199
+ it("carries on with a started course, and measures it in lessons", () => {
200
+ const course = (accessId: string, patch: object) => ({ accessId, courseId: accessId, title: accessId, completedAt: null, currentLessonId: null, ...patch });
201
+ expect(pickLearningCourse([course("done", { completedAt: "2026-01-01" }), course("new", {}), course("started", { currentLessonId: "l2" })] as never)?.accessId).toBe("started");
202
+ expect(pickLearningCourse([course("done", { completedAt: "2026-01-01" })] as never)).toBeNull();
203
+
204
+ const access = {
205
+ access: {},
206
+ course: { modules: [{ lessons: [{ id: "l1" }, { id: "l2" }] }, { lessons: [{ id: "l3" }, { id: "l4" }] }] },
207
+ progress: [
208
+ { lessonId: "l1", isCompleted: true },
209
+ { lessonId: "l2", isCompleted: false },
210
+ { lessonId: "l3", isCompleted: true },
211
+ ],
212
+ } as unknown as CourseAccessData;
213
+ expect(courseProgress(access)).toEqual({ done: 2, total: 4, ratio: 0.5 });
214
+ expect(courseProgress(null)).toBeNull();
215
+ });
216
+
217
+ it("lists only events still to come, soonest first", () => {
218
+ const events = [
219
+ { id: "late", status: "on_sale", dateTime: "2026-12-01T00:00:00Z", endDateTime: null, cancelledAt: null },
220
+ { id: "past", status: "on_sale", dateTime: "2026-09-01T00:00:00Z", endDateTime: null, cancelledAt: null },
221
+ { id: "soon", status: "scheduled", dateTime: "2026-10-10T00:00:00Z", endDateTime: null, cancelledAt: null },
222
+ { id: "off", status: "cancelled", dateTime: "2026-10-11T00:00:00Z", endDateTime: null, cancelledAt: "2026-09-01" },
223
+ ] as unknown as IEvent[];
224
+ expect(upcomingEvents(events, now).map((e) => e.id)).toEqual(["soon", "late"]);
225
+ });
226
+ });
@@ -0,0 +1,185 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import {
4
+ APP_INSTALL_REQUEST_TTL_MS,
5
+ APP_INSTALL_SNOOZE_MS,
6
+ clearPendingAppInstall,
7
+ decideAppInstall,
8
+ decideAppPush,
9
+ pushPreferencePatch,
10
+ readInstallDismissedAt,
11
+ readPendingAppInstall,
12
+ readPushAnswer,
13
+ rememberInstallDismissed,
14
+ rememberPushAnswer,
15
+ requestAppInstall,
16
+ subscribeAppInstallRequests,
17
+ } from "../siteApp/appPrompts";
18
+
19
+ /**
20
+ * The gating of the site app's two prompts, and the storage around them.
21
+ */
22
+
23
+ const DAY = 24 * 60 * 60 * 1000;
24
+ const NOW = Date.parse("2026-10-01T12:00:00Z");
25
+
26
+ const install = (patch: Partial<Parameters<typeof decideAppInstall>[0]> = {}) =>
27
+ decideAppInstall({
28
+ installPrompt: true,
29
+ isStandalone: false,
30
+ isIOS: false,
31
+ hasNativePrompt: true,
32
+ dismissedAt: null,
33
+ now: NOW,
34
+ ...patch,
35
+ });
36
+
37
+ beforeEach(() => {
38
+ window.localStorage.clear();
39
+ window.sessionStorage.clear();
40
+ clearPendingAppInstall();
41
+ });
42
+
43
+ afterEach(() => {
44
+ vi.restoreAllMocks();
45
+ });
46
+
47
+ describe("decideAppInstall", () => {
48
+ it("uses the native prompt where the browser holds one", () => {
49
+ expect(install()).toBe("native");
50
+ });
51
+
52
+ it("gives an iPhone the two Safari steps, which has no native prompt", () => {
53
+ expect(install({ hasNativePrompt: false, isIOS: true })).toBe("ios");
54
+ });
55
+
56
+ it("never shows in the installed app", () => {
57
+ expect(install({ isStandalone: true })).toBeNull();
58
+ expect(install({ isStandalone: true, isIOS: true, hasNativePrompt: false })).toBeNull();
59
+ });
60
+
61
+ it("respects the website's setting, and treats an unknown setting as off", () => {
62
+ expect(install({ installPrompt: false })).toBeNull();
63
+ expect(install({ installPrompt: undefined })).toBeNull();
64
+ expect(install({ installPrompt: null })).toBeNull();
65
+ });
66
+
67
+ it("stays away for 14 days after Not now, and comes back after", () => {
68
+ expect(install({ dismissedAt: NOW - 1 * DAY })).toBeNull();
69
+ expect(install({ dismissedAt: NOW - 13 * DAY })).toBeNull();
70
+ expect(install({ dismissedAt: NOW - APP_INSTALL_SNOOZE_MS })).toBe("native");
71
+ expect(install({ dismissedAt: NOW - 15 * DAY })).toBe("native");
72
+ });
73
+
74
+ it("has nothing to show where there is neither a prompt nor Safari's steps", () => {
75
+ expect(install({ hasNativePrompt: false, isIOS: false })).toBeNull();
76
+ });
77
+ });
78
+
79
+ describe("Not now, remembered", () => {
80
+ it("round-trips through localStorage", () => {
81
+ expect(readInstallDismissedAt()).toBeNull();
82
+ rememberInstallDismissed(NOW);
83
+ expect(readInstallDismissedAt()).toBe(NOW);
84
+ });
85
+
86
+ it("never throws when storage is blocked", () => {
87
+ vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
88
+ throw new Error("SecurityError");
89
+ });
90
+ vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
91
+ throw new Error("SecurityError");
92
+ });
93
+ expect(() => rememberInstallDismissed(NOW)).not.toThrow();
94
+ expect(readInstallDismissedAt()).toBeNull();
95
+ expect(() => rememberPushAnswer("accepted")).not.toThrow();
96
+ expect(readPushAnswer()).toBeNull();
97
+ });
98
+ });
99
+
100
+ describe("requestAppInstall", () => {
101
+ it("holds the request for the page that can show it, and tells listeners", () => {
102
+ const heard = vi.fn();
103
+ const off = subscribeAppInstallRequests(heard);
104
+ requestAppInstall("purchase");
105
+ expect(heard).toHaveBeenCalledTimes(1);
106
+ expect(readPendingAppInstall()).toMatchObject({ context: "purchase" });
107
+ off();
108
+ });
109
+
110
+ it("survives a navigation through sessionStorage", () => {
111
+ requestAppInstall("joined_community");
112
+ const stored = window.sessionStorage.getItem("forge-app-install-pending");
113
+ clearPendingAppInstall();
114
+ expect(readPendingAppInstall()).toBeNull();
115
+ // A fresh page: the copy in memory is gone, the session copy is not.
116
+ window.sessionStorage.setItem("forge-app-install-pending", stored!);
117
+ expect(readPendingAppInstall()).toMatchObject({ context: "joined_community" });
118
+ });
119
+
120
+ it("lets a stale request lapse", () => {
121
+ requestAppInstall("booking");
122
+ const at = readPendingAppInstall()!.at;
123
+ expect(readPendingAppInstall(at + APP_INSTALL_REQUEST_TTL_MS + 1)).toBeNull();
124
+ });
125
+
126
+ it("ignores a context it does not know", () => {
127
+ requestAppInstall("nonsense" as never);
128
+ expect(readPendingAppInstall()).toBeNull();
129
+ });
130
+ });
131
+
132
+ describe("decideAppPush", () => {
133
+ const push = (patch: Partial<Parameters<typeof decideAppPush>[0]> = {}) =>
134
+ decideAppPush({
135
+ pushPrompt: true,
136
+ isStandalone: true,
137
+ isSignedIn: true,
138
+ isSupported: true,
139
+ permission: "default",
140
+ answer: null,
141
+ ...patch,
142
+ });
143
+
144
+ it("offers on the first launch of the installed app", () => {
145
+ expect(push()).toBe(true);
146
+ });
147
+
148
+ it("does not offer in a browser tab", () => {
149
+ expect(push({ isStandalone: false })).toBe(false);
150
+ });
151
+
152
+ it("respects the website's setting", () => {
153
+ expect(push({ pushPrompt: false })).toBe(false);
154
+ expect(push({ pushPrompt: undefined })).toBe(false);
155
+ });
156
+
157
+ it("needs someone signed in, and a browser that can push", () => {
158
+ expect(push({ isSignedIn: false })).toBe(false);
159
+ expect(push({ isSupported: false })).toBe(false);
160
+ });
161
+
162
+ it("asks only while the browser has not been asked", () => {
163
+ expect(push({ permission: "granted" })).toBe(false);
164
+ expect(push({ permission: "denied" })).toBe(false);
165
+ });
166
+
167
+ it("asks once: any earlier answer ends it", () => {
168
+ expect(push({ answer: "declined" })).toBe(false);
169
+ expect(push({ answer: "accepted" })).toBe(false);
170
+ rememberPushAnswer("declined");
171
+ expect(push({ answer: readPushAnswer() })).toBe(false);
172
+ });
173
+ });
174
+
175
+ describe("pushPreferencePatch", () => {
176
+ it("switches every known type of each category, and leaves unknown types alone", () => {
177
+ const known = new Set(["dm", "mention", "new_post", "event_reminder"]);
178
+ expect(pushPreferencePatch({ messages: true, community: false, events: true }, known)).toEqual({
179
+ "dm.push": true,
180
+ "mention.push": true,
181
+ "new_post.push": false,
182
+ "event_reminder.push": true,
183
+ });
184
+ });
185
+ });