@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,66 @@
1
+ import { Home } from "lucide-react";
2
+ import { useAppHomeHref, useSiteApp } from "../../../data/queries/useSiteApp";
3
+ import { useForgeT } from "../../../i18n";
4
+ import { useThemeTokens } from "../../theme/ForgeThemeProvider";
5
+
6
+ export interface AppHomeMarkProps {
7
+ /** SPA navigation; a plain click otherwise follows the link. */
8
+ go?: (href: string) => void;
9
+ /** Side of the square mark, in pixels. */
10
+ size: number;
11
+ testId: string;
12
+ /** Draw the app's name beside the mark. */
13
+ withName?: boolean;
14
+ }
15
+
16
+ /**
17
+ * The way back to the site app's Home from a screen with its own shell (a
18
+ * community, the client portal). It follows the Home rule (`useAppHomeHref`):
19
+ * the creator's own start page when they chose one, `/i/app` otherwise.
20
+ *
21
+ * The app's icon when it has one, else a house.
22
+ */
23
+ export function AppHomeMark({ go, size, testId, withName = false }: AppHomeMarkProps) {
24
+ const tr = useForgeT();
25
+ const t = useThemeTokens();
26
+ const href = useAppHomeHref();
27
+ const { data: app } = useSiteApp();
28
+ const name = withName ? app?.appName : null;
29
+
30
+ return (
31
+ <a
32
+ href={href}
33
+ data-testid={testId}
34
+ aria-label={name ? undefined : tr("forge.app_home.mark_label")}
35
+ onClick={(event) => {
36
+ if (!go || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
37
+ event.preventDefault();
38
+ go(href);
39
+ }}
40
+ className="fg:inline-flex fg:min-w-0 fg:shrink-0 fg:items-center fg:gap-2"
41
+ style={{ color: t.text, textDecoration: "none" }}
42
+ >
43
+ <span
44
+ className="fg:inline-flex fg:shrink-0 fg:items-center fg:justify-center"
45
+ style={{
46
+ width: size,
47
+ height: size,
48
+ borderRadius: Math.round(size * 0.28),
49
+ border: app?.iconUrl ? "none" : `1px solid ${t.border}`,
50
+ overflow: "hidden",
51
+ }}
52
+ >
53
+ {app?.iconUrl ? (
54
+ <img src={app.iconUrl} alt="" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
55
+ ) : (
56
+ <Home size={Math.round(size * 0.5)} aria-hidden />
57
+ )}
58
+ </span>
59
+ {name ? (
60
+ <span className="fg:truncate" style={{ fontSize: 15, fontWeight: 600 }}>
61
+ {name}
62
+ </span>
63
+ ) : null}
64
+ </a>
65
+ );
66
+ }
@@ -0,0 +1,464 @@
1
+ import { useContext, useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
2
+ import { Bell, LayoutGrid, MessageCircle, PlusSquare, Share, Smartphone } from "lucide-react";
3
+ import { useThemeTokens } from "../../theme/ForgeThemeProvider";
4
+ import { useForgeT } from "../../../i18n";
5
+ import { useForge } from "../../../provider/ForgeProvider";
6
+ import { PublicAuthContext } from "../../../contexts/PublicAuthContext";
7
+ import { useSiteApp } from "../../../data/queries/useSiteApp";
8
+ import {
9
+ captureInstallPrompt,
10
+ hasStoredInstallPrompt,
11
+ isIOSDevice,
12
+ isStandaloneDisplay,
13
+ promptStoredInstall,
14
+ subscribeInstallPrompt,
15
+ } from "../../../data/queries/usePWA";
16
+ import {
17
+ useNotificationMatrix,
18
+ usePushSubscription,
19
+ useUpdateNotificationMatrix,
20
+ } from "../../../data/queries/useNotifications";
21
+ import {
22
+ APP_PUSH_CATEGORIES,
23
+ clearPendingAppInstall,
24
+ decideAppInstall,
25
+ decideAppPush,
26
+ pushPreferencePatch,
27
+ readInstallDismissedAt,
28
+ readPendingAppInstall,
29
+ readPushAnswer,
30
+ rememberInstallDismissed,
31
+ rememberPushAnswer,
32
+ subscribeAppInstallRequests,
33
+ type AppInstallContext,
34
+ type AppInstallMode,
35
+ type AppPushCategoryKey,
36
+ } from "../../headless/siteApp/appPrompts";
37
+ import { CommunitySheet } from "../communities/shared";
38
+ import { shareRuntimeValue } from "../../../runtime/registry";
39
+
40
+ /** Literal keys, so the translation spec can see every one of them. */
41
+ const INSTALL_TITLE_KEYS: Record<AppInstallContext, string> = {
42
+ joined_community: "forge.app_prompts.install_title_joined",
43
+ purchase: "forge.app_prompts.install_title_purchase",
44
+ booking: "forge.app_prompts.install_title_booking",
45
+ invite_accepted: "forge.app_prompts.install_title_invite",
46
+ };
47
+
48
+ const PUSH_CATEGORY_KEYS: Record<AppPushCategoryKey, string> = {
49
+ messages: "forge.app_prompts.push_category_messages",
50
+ community: "forge.app_prompts.push_category_community",
51
+ events: "forge.app_prompts.push_category_events",
52
+ };
53
+
54
+ // ---- One host per page ---------------------------------------------------------
55
+ //
56
+ // `<AppPrompts />` is mounted by the hosted pages' frame AND by every
57
+ // `<AppBar />`, so a page can easily hold two. Only the first mounted one
58
+ // draws; the rest wait, and take over if it unmounts.
59
+
60
+ // Shared through the runtime registry, because the site's copy of Forge (an
61
+ // `<AppBar />` on a custom start page) and the hosted pages' copy can both mount one.
62
+ const hosts = () =>
63
+ shareRuntimeValue<{ owner: symbol | null; listeners: Set<() => void> }>("state:app-prompts-host", () => ({
64
+ owner: null,
65
+ listeners: new Set(),
66
+ }));
67
+
68
+ function useIsPromptHost(): boolean {
69
+ const id = useRef(Symbol("app-prompts")).current;
70
+ const [isHost, setIsHost] = useState(false);
71
+ useEffect(() => {
72
+ const state = hosts();
73
+ const claim = () => {
74
+ if (!state.owner) state.owner = id;
75
+ setIsHost(state.owner === id);
76
+ };
77
+ claim();
78
+ state.listeners.add(claim);
79
+ return () => {
80
+ state.listeners.delete(claim);
81
+ if (state.owner === id) {
82
+ state.owner = null;
83
+ state.listeners.forEach((listener) => listener());
84
+ }
85
+ };
86
+ }, [id]);
87
+ return isHost;
88
+ }
89
+
90
+ /**
91
+ * The site app's two prompts: install, after a moment worth coming back for
92
+ * (`requestAppInstall`), and notifications, on the first launch of the
93
+ * installed app. Renders nothing until one is due. Mount it once anywhere in
94
+ * the page; `<AppBar />` already includes it, and so does every hosted `/i/`
95
+ * page.
96
+ */
97
+ export function AppPrompts() {
98
+ const isHost = useIsPromptHost();
99
+ if (!isHost) return null;
100
+ return (
101
+ <>
102
+ <AppInstallPrompt />
103
+ <AppPushPrompt />
104
+ </>
105
+ );
106
+ }
107
+
108
+ // ---- Install ------------------------------------------------------------------
109
+
110
+ function AppInstallPrompt() {
111
+ const tr = useForgeT();
112
+ const t = useThemeTokens();
113
+ const app = useSiteApp();
114
+ const [tick, setTick] = useState(0);
115
+ const [shown, setShown] = useState<{ context: AppInstallContext; mode: AppInstallMode } | null>(null);
116
+
117
+ useEffect(() => {
118
+ captureInstallPrompt();
119
+ const bump = () => setTick((n) => n + 1);
120
+ const offRequests = subscribeAppInstallRequests(bump);
121
+ const offPrompt = subscribeInstallPrompt(bump);
122
+ return () => {
123
+ offRequests();
124
+ offPrompt();
125
+ };
126
+ }, []);
127
+
128
+ useEffect(() => {
129
+ if (shown) return;
130
+ const request = readPendingAppInstall();
131
+ if (!request) return;
132
+ // Wait for the website's answer; until it arrives the setting is unknown.
133
+ // An unknown host (a preview) answers 404, which settles it as "off".
134
+ if (!app.data && !app.isError) return;
135
+ const mode = decideAppInstall({
136
+ installPrompt: app.data?.installPrompt,
137
+ isStandalone: isStandaloneDisplay(),
138
+ isIOS: isIOSDevice(),
139
+ hasNativePrompt: hasStoredInstallPrompt(),
140
+ dismissedAt: readInstallDismissedAt(),
141
+ now: Date.now(),
142
+ });
143
+ // Asked once: shown now or not at all, so a later page does not raise it out of nowhere.
144
+ clearPendingAppInstall();
145
+ if (mode) setShown({ context: request.context, mode });
146
+ }, [tick, shown, app.data, app.isError]);
147
+
148
+ if (!shown) return null;
149
+ const name = app.data?.shortName || app.data?.appName || tr("forge.app_prompts.this_app");
150
+
151
+ const notNow = () => {
152
+ rememberInstallDismissed();
153
+ setShown(null);
154
+ };
155
+
156
+ return (
157
+ <CommunitySheet
158
+ open
159
+ onOpenChange={(open) => {
160
+ if (!open) notNow();
161
+ }}
162
+ testId={`app-install-${shown.mode}`}
163
+ title={tr(INSTALL_TITLE_KEYS[shown.context])}
164
+ closeLabel={tr("forge.app_prompts.aria_close")}
165
+ >
166
+ <div className="fg:flex fg:flex-col fg:gap-5" data-context={shown.context}>
167
+ <AppIdentity iconUrl={app.data?.iconUrl ?? null} name={name}>
168
+ {shown.mode === "ios" ? tr("forge.app_prompts.ios_lead") : tr("forge.app_prompts.install_lead", { app: name })}
169
+ </AppIdentity>
170
+
171
+ {shown.mode === "ios" ? (
172
+ <ol className="fg:m-0 fg:flex fg:list-none fg:flex-col fg:gap-2 fg:p-0" data-testid="app-install-ios-steps">
173
+ <IosStep n={1} icon={<Share size={20} aria-hidden />}>
174
+ {tr("forge.app_prompts.ios_step_share")}
175
+ </IosStep>
176
+ <IosStep n={2} icon={<PlusSquare size={20} aria-hidden />}>
177
+ {tr("forge.app_prompts.ios_step_add")}
178
+ </IosStep>
179
+ </ol>
180
+ ) : (
181
+ <ul className="fg:m-0 fg:flex fg:list-none fg:flex-col fg:gap-3 fg:p-0">
182
+ <Benefit icon={<Smartphone size={18} aria-hidden />}>{tr("forge.app_prompts.benefit_one_tap")}</Benefit>
183
+ <Benefit icon={<Bell size={18} aria-hidden />}>{tr("forge.app_prompts.benefit_notifications")}</Benefit>
184
+ <Benefit icon={<LayoutGrid size={18} aria-hidden />}>{tr("forge.app_prompts.benefit_one_place")}</Benefit>
185
+ </ul>
186
+ )}
187
+
188
+ <div className="fg:flex fg:flex-col fg:gap-2">
189
+ {shown.mode === "native" ? (
190
+ <button
191
+ type="button"
192
+ data-testid="app-install-accept"
193
+ style={primaryButton(t)}
194
+ onClick={() => {
195
+ void promptStoredInstall();
196
+ setShown(null);
197
+ }}
198
+ >
199
+ {tr("forge.app_prompts.install_accept")}
200
+ </button>
201
+ ) : null}
202
+ <button type="button" data-testid="app-install-not-now" style={quietButton(t)} onClick={notNow}>
203
+ {tr("forge.app_prompts.not_now")}
204
+ </button>
205
+ </div>
206
+ </div>
207
+ </CommunitySheet>
208
+ );
209
+ }
210
+
211
+ // ---- Notifications --------------------------------------------------------------------
212
+
213
+ function AppPushPrompt() {
214
+ const auth = useContext(PublicAuthContext);
215
+ const { profileId } = useForge();
216
+ const app = useSiteApp();
217
+ const push = usePushSubscription(profileId ?? "");
218
+ const [standalone, setStandalone] = useState(false);
219
+ const [permission, setPermission] = useState<NotificationPermission | null>(null);
220
+ const [answered, setAnswered] = useState(() => (typeof window === "undefined" ? null : readPushAnswer()));
221
+
222
+ useEffect(() => {
223
+ setStandalone(isStandaloneDisplay());
224
+ setPermission(typeof Notification !== "undefined" ? Notification.permission : null);
225
+ setAnswered(readPushAnswer());
226
+ }, []);
227
+
228
+ const due = decideAppPush({
229
+ pushPrompt: app.data?.pushPrompt,
230
+ isStandalone: standalone,
231
+ isSignedIn: !!auth?.user,
232
+ isSupported: push.isSupported && !push.isLoading,
233
+ permission,
234
+ answer: answered,
235
+ });
236
+
237
+ if (!due || push.isSubscribed) return null;
238
+ return (
239
+ <AppPushSheet
240
+ name={app.data?.shortName || app.data?.appName || null}
241
+ subscribe={push.subscribe}
242
+ onAnswered={(answer) => {
243
+ rememberPushAnswer(answer);
244
+ setAnswered(answer);
245
+ }}
246
+ />
247
+ );
248
+ }
249
+
250
+ /**
251
+ * The first-launch notifications sheet. Offers a switch per category the
252
+ * member's preference matrix knows; a server without the matrix gets one
253
+ * plain opt-in.
254
+ */
255
+ function AppPushSheet({
256
+ name,
257
+ subscribe,
258
+ onAnswered,
259
+ }: {
260
+ name: string | null;
261
+ subscribe: () => Promise<boolean>;
262
+ onAnswered: (answer: "accepted" | "declined") => void;
263
+ }) {
264
+ const tr = useForgeT();
265
+ const t = useThemeTokens();
266
+ const matrix = useNotificationMatrix();
267
+ const update = useUpdateNotificationMatrix();
268
+ const [busy, setBusy] = useState(false);
269
+ const known = new Set(Object.keys(matrix.data?.defaults ?? {}));
270
+ const categories = APP_PUSH_CATEGORIES.filter((category) => category.types.some((type) => known.has(type)));
271
+ const [chosen, setChosen] = useState<Record<AppPushCategoryKey, boolean> | null>(null);
272
+
273
+ // Start from what the member already has: a category is on when any of its types pushes.
274
+ const current: Record<AppPushCategoryKey, boolean> =
275
+ chosen ??
276
+ (Object.fromEntries(
277
+ APP_PUSH_CATEGORIES.map((category) => [
278
+ category.key,
279
+ !matrix.data || category.types.some((type) => matrix.data!.preferences?.[`${type}.push`] !== false && known.has(type)),
280
+ ]),
281
+ ) as Record<AppPushCategoryKey, boolean>);
282
+
283
+ const accept = async () => {
284
+ setBusy(true);
285
+ const ok = await subscribe();
286
+ if (ok && categories.length > 0) {
287
+ try {
288
+ await update.mutateAsync({ preferences: pushPreferencePatch(current, known) });
289
+ } catch {
290
+ /* the subscription stands; the choices can be changed in notification settings */
291
+ }
292
+ }
293
+ setBusy(false);
294
+ onAnswered(ok ? "accepted" : "declined");
295
+ };
296
+
297
+ const appName = name ?? tr("forge.app_prompts.this_app");
298
+ // Wait for the member's settings, so the sheet does not open as one switch
299
+ // and then grow three. A server without them answers an error: one switch.
300
+ if (matrix.isLoading) return null;
301
+
302
+ return (
303
+ <CommunitySheet
304
+ open
305
+ onOpenChange={(open) => {
306
+ if (!open) onAnswered("declined");
307
+ }}
308
+ testId="app-push-prompt"
309
+ title={tr("forge.app_prompts.push_title")}
310
+ closeLabel={tr("forge.app_prompts.aria_close")}
311
+ >
312
+ <div className="fg:flex fg:flex-col fg:gap-5">
313
+ <p style={{ margin: 0, fontSize: 15, color: t.muted }}>
314
+ {categories.length > 0
315
+ ? tr("forge.app_prompts.push_lead_categories", { app: appName })
316
+ : tr("forge.app_prompts.push_lead_single", { app: appName })}
317
+ </p>
318
+ {categories.length > 0 ? (
319
+ <fieldset
320
+ data-testid="app-push-categories"
321
+ style={{ margin: 0, padding: 0, border: "none", borderTop: `1px solid ${t.border}` }}
322
+ >
323
+ <legend className="fg:sr-only">{tr("forge.app_prompts.push_title")}</legend>
324
+ {categories.map((category) => (
325
+ <label
326
+ key={category.key}
327
+ data-testid={`app-push-category-${category.key}`}
328
+ className="fg:flex fg:items-center fg:gap-3"
329
+ style={{ padding: "14px 0", borderBottom: `1px solid ${t.border}`, cursor: "pointer" }}
330
+ >
331
+ <MessageCircle size={18} aria-hidden style={{ color: t.muted, flexShrink: 0 }} />
332
+ <span style={{ flex: 1, fontWeight: 600, fontSize: 15 }}>{tr(PUSH_CATEGORY_KEYS[category.key])}</span>
333
+ <input
334
+ type="checkbox"
335
+ checked={current[category.key]}
336
+ onChange={(event) => setChosen({ ...current, [category.key]: event.target.checked })}
337
+ style={{ width: 22, height: 22, accentColor: t.primary }}
338
+ />
339
+ </label>
340
+ ))}
341
+ </fieldset>
342
+ ) : null}
343
+ <div className="fg:flex fg:flex-col fg:gap-2">
344
+ <button
345
+ type="button"
346
+ data-testid="app-push-accept"
347
+ disabled={busy}
348
+ style={{ ...primaryButton(t), opacity: busy ? 0.7 : 1 }}
349
+ onClick={() => void accept()}
350
+ >
351
+ {tr("forge.app_prompts.push_accept")}
352
+ </button>
353
+ <button type="button" data-testid="app-push-later" style={quietButton(t)} onClick={() => onAnswered("declined")}>
354
+ {tr("forge.app_prompts.push_later")}
355
+ </button>
356
+ </div>
357
+ </div>
358
+ </CommunitySheet>
359
+ );
360
+ }
361
+
362
+ // ---- Pieces ------------------------------------------------------------------------------
363
+
364
+ type Tokens = ReturnType<typeof useThemeTokens>;
365
+
366
+ function primaryButton(t: Tokens): CSSProperties {
367
+ return {
368
+ minHeight: 52,
369
+ borderRadius: Math.max(t.cornerRadius, 10),
370
+ border: "none",
371
+ background: t.primary,
372
+ color: t.textPrimary,
373
+ fontWeight: 700,
374
+ fontSize: 16,
375
+ fontFamily: t.fontFamily,
376
+ cursor: "pointer",
377
+ };
378
+ }
379
+
380
+ function quietButton(t: Tokens): CSSProperties {
381
+ return {
382
+ minHeight: 48,
383
+ borderRadius: Math.max(t.cornerRadius, 10),
384
+ border: "none",
385
+ background: "transparent",
386
+ color: t.muted,
387
+ fontWeight: 600,
388
+ fontSize: 15,
389
+ fontFamily: t.fontFamily,
390
+ cursor: "pointer",
391
+ };
392
+ }
393
+
394
+ function AppIdentity({ iconUrl, name, children }: { iconUrl: string | null; name: string; children: ReactNode }) {
395
+ const t = useThemeTokens();
396
+ return (
397
+ <div className="fg:flex fg:items-center fg:gap-3">
398
+ {iconUrl ? (
399
+ <img src={iconUrl} alt="" style={{ width: 56, height: 56, borderRadius: 16, objectFit: "cover", flexShrink: 0 }} />
400
+ ) : (
401
+ <span
402
+ aria-hidden
403
+ className="fg:flex fg:items-center fg:justify-center"
404
+ style={{
405
+ width: 56,
406
+ height: 56,
407
+ borderRadius: 16,
408
+ background: t.primary,
409
+ color: t.textPrimary,
410
+ fontWeight: 800,
411
+ fontSize: 22,
412
+ flexShrink: 0,
413
+ }}
414
+ >
415
+ {name.trim().charAt(0).toUpperCase()}
416
+ </span>
417
+ )}
418
+ <p style={{ margin: 0, fontSize: 14, color: t.muted }}>{children}</p>
419
+ </div>
420
+ );
421
+ }
422
+
423
+ function Benefit({ icon, children }: { icon: ReactNode; children: ReactNode }) {
424
+ const t = useThemeTokens();
425
+ return (
426
+ <li className="fg:flex fg:items-center fg:gap-3" style={{ fontSize: 15 }}>
427
+ <span
428
+ className="fg:flex fg:items-center fg:justify-center"
429
+ style={{ width: 32, height: 32, borderRadius: 10, background: t.surface, color: t.primary, flexShrink: 0 }}
430
+ >
431
+ {icon}
432
+ </span>
433
+ {children}
434
+ </li>
435
+ );
436
+ }
437
+
438
+ function IosStep({ n, icon, children }: { n: number; icon: ReactNode; children: ReactNode }) {
439
+ const t = useThemeTokens();
440
+ return (
441
+ <li
442
+ className="fg:flex fg:items-center fg:gap-3"
443
+ style={{ padding: 14, borderRadius: Math.max(t.cornerRadius, 10), background: t.surface, fontSize: 15 }}
444
+ >
445
+ <span
446
+ className="fg:flex fg:items-center fg:justify-center"
447
+ style={{
448
+ width: 26,
449
+ height: 26,
450
+ borderRadius: 13,
451
+ background: t.text,
452
+ color: t.background,
453
+ fontSize: 13,
454
+ fontWeight: 700,
455
+ flexShrink: 0,
456
+ }}
457
+ >
458
+ {n}
459
+ </span>
460
+ <span style={{ flex: 1 }}>{children}</span>
461
+ <span style={{ color: t.primary, display: "inline-flex" }}>{icon}</span>
462
+ </li>
463
+ );
464
+ }
@@ -0,0 +1,96 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
4
+ import type { ReactNode } from "react";
5
+ import { ForgeThemeProvider } from "../../../theme/ForgeThemeProvider";
6
+ import { AppBar } from "../AppBar";
7
+ import { account, get, installAdapter, restoreAdapter, wrapper } from "../../../headless/_tests/communityHarness";
8
+ import { siteApp } from "../../../headless/_tests/siteAppHarness";
9
+
10
+ /**
11
+ * The site app's bottom bar as drawn: Home first (the Home rule), the
12
+ * configured items, More last with everything else, and the height it
13
+ * publishes for whatever else is pinned to the bottom of the screen.
14
+ */
15
+
16
+ const theme = { colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 };
17
+
18
+ function mount(node: ReactNode, opts: Parameters<typeof wrapper>[0] = {}) {
19
+ const Wrapper = wrapper({ community: null, ...opts });
20
+ return render(
21
+ <Wrapper>
22
+ <ForgeThemeProvider theme={theme}>{node}</ForgeThemeProvider>
23
+ </Wrapper>,
24
+ );
25
+ }
26
+
27
+ const barIds = () =>
28
+ Array.from(screen.getByTestId("app-bar").children).map((el) => el.getAttribute("data-testid"));
29
+
30
+ const realMatchMedia = window.matchMedia;
31
+
32
+ beforeEach(() => {
33
+ // A phone: below `lg`.
34
+ window.matchMedia = vi.fn().mockImplementation((query: string) => ({
35
+ matches: query.includes("max-width"),
36
+ media: query,
37
+ addEventListener: vi.fn(),
38
+ removeEventListener: vi.fn(),
39
+ })) as never;
40
+ });
41
+
42
+ afterEach(() => {
43
+ restoreAdapter();
44
+ window.matchMedia = realMatchMedia;
45
+ });
46
+
47
+ describe("AppBar", () => {
48
+ it("is Home, the configured items, then More, with Home at the creator's start page", async () => {
49
+ installAdapter([get("/public/websites/app", () => siteApp({ barItems: ["events", "shop"], startUrl: "/members" }))]);
50
+ mount(<AppBar currentPath="/i/events" prompts={false} />);
51
+
52
+ await waitFor(() => expect(barIds()).toEqual(["app-bar-home", "app-bar-events", "app-bar-shop", "app-bar-more"]));
53
+ expect(screen.getByTestId("app-bar-home").getAttribute("href")).toBe("/members");
54
+ expect(screen.getByTestId("app-bar-events").getAttribute("aria-current")).toBe("page");
55
+
56
+ fireEvent.click(screen.getByTestId("app-bar-more"));
57
+ const sheet = await screen.findByTestId("app-more-sheet");
58
+ const rows = Array.from(sheet.querySelectorAll("a[data-testid^='app-more-']")).map((el) => el.getAttribute("data-testid"));
59
+ expect(rows).toEqual(["app-more-communities", "app-more-portal", "app-more-inbox", "app-more-courses"]);
60
+ expect(within(sheet).getByTestId("app-more-inbox").getAttribute("href")).toBe("/i/account/notifications");
61
+ });
62
+
63
+ it("draws the default bar, Home at /i/app, before and without the settings", () => {
64
+ installAdapter([
65
+ get("/public/websites/app", () => {
66
+ throw { status: 404 };
67
+ }),
68
+ ]);
69
+ mount(<AppBar currentPath="/i/app" prompts={false} />);
70
+ expect(barIds()).toEqual(["app-bar-home", "app-bar-communities", "app-bar-portal", "app-bar-inbox", "app-bar-more"]);
71
+ expect(screen.getByTestId("app-bar-home").getAttribute("href")).toBe("/i/app");
72
+ expect(screen.getByTestId("app-bar-home").getAttribute("aria-current")).toBe("page");
73
+ });
74
+
75
+ it("navigates in-app, and shows the member's unread count on Inbox", async () => {
76
+ installAdapter([
77
+ get("/public/websites/app", () => siteApp()),
78
+ get("/public/notifications/inbox", () => ({ data: [], unreadCount: 3 })),
79
+ ]);
80
+ const navigate = vi.fn();
81
+ mount(<AppBar currentPath="/i/app" navigate={navigate} prompts={false} />, { user: account() });
82
+ expect((await screen.findByTestId("app-bar-inbox-badge")).textContent).toBe("3");
83
+ fireEvent.click(screen.getByTestId("app-bar-communities"), { button: 0 });
84
+ expect(navigate).toHaveBeenCalledWith("/i/communities");
85
+ });
86
+
87
+ it("publishes its height on the root, as the community bar does, and takes it back on unmount", () => {
88
+ installAdapter([get("/public/websites/app", () => siteApp())]);
89
+ const view = mount(<AppBar currentPath="/i/app" prompts={false} />);
90
+ expect(document.documentElement.style.getPropertyValue("--forge-bottom-bar-height")).toBe("56px");
91
+ // The page ends above the bar.
92
+ expect(screen.getByTestId("app-bar-spacer")).toBeTruthy();
93
+ view.unmount();
94
+ expect(document.documentElement.style.getPropertyValue("--forge-bottom-bar-height")).toBe("");
95
+ });
96
+ });