@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
@@ -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
+ });