@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
@@ -1,5 +1,5 @@
1
1
  import { useForge } from "../../provider/ForgeProvider";
2
- import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
+ import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3
3
  import type { PaginatedData } from "../../types/models";
4
4
 
5
5
  /**
@@ -313,3 +313,26 @@ export function useCancelMyTicket() {
313
313
  },
314
314
  });
315
315
  }
316
+
317
+ /**
318
+ * Every ticket, a page at a time, for a list with a "Show more" button.
319
+ *
320
+ * Under the same `my-tickets` key prefix as `useMyTickets`, so the cancel and
321
+ * transfer mutations that invalidate that prefix refresh this too.
322
+ */
323
+ export function useMyTicketsPaged(accountId?: string, limit = 20) {
324
+ const { client, profileId } = useForge();
325
+ return useInfiniteQuery({
326
+ queryKey: ["my-tickets", accountId, profileId, "paged", limit],
327
+ initialPageParam: 1,
328
+ queryFn: async ({ pageParam }) => {
329
+ const res = await client.get("/public/events/my-tickets", { params: { profileId, page: pageParam, limit } });
330
+ return res.data as PaginatedData<MyTicket>;
331
+ },
332
+ getNextPageParam: (last, pages) => {
333
+ const loaded = pages.reduce((sum, p) => sum + p.data.length, 0);
334
+ return loaded < last.total && last.data.length > 0 ? pages.length + 1 : undefined;
335
+ },
336
+ enabled: !!accountId && !!profileId && !!client,
337
+ });
338
+ }
@@ -3,7 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3
3
  import { useCallback, useEffect, useState } from "react";
4
4
 
5
5
  /**
6
- * Member notification headless hooks — the FROZEN public contract that the
6
+ * Member notification headless hooks: the FROZEN public contract that the
7
7
  * apps/client PWA and every generated custom-code site consume. Speaks ONLY the
8
8
  * `/public/notifications/*` API (see docs/archive/shipped/membership-notifications-initiative.md
9
9
  * "API surface"). Moved here from apps/client so custom-code sites get push
@@ -56,6 +56,10 @@ export interface NotificationInboxItem {
56
56
  url?: string;
57
57
  actorName?: string;
58
58
  actorAccountId?: string;
59
+ /** A `mod` notice about the member's own post or reply: what the moderator decided. */
60
+ outcome?: "approved" | "rejected";
61
+ /** The moderator's reason for a rejection, when one was given. */
62
+ reason?: string | null;
59
63
  } | null;
60
64
  }
61
65
 
@@ -218,7 +222,7 @@ export function useNotificationUnreadCount(enabled = true) {
218
222
  });
219
223
  }
220
224
 
221
- /** Mark notifications read — omit ids to mark ALL unread as read. */
225
+ /** Mark notifications read. Omit ids to mark ALL unread as read. */
222
226
  export function useMarkNotificationsRead() {
223
227
  const { client, profileId } = useForge();
224
228
  const queryClient = useQueryClient();
@@ -1,4 +1,5 @@
1
1
  import { useForge } from "../../provider/ForgeProvider";
2
+ import { readMetaSignals } from "../../utils/metaEvents";
2
3
  import { useMutation } from "@tanstack/react-query";
3
4
 
4
5
  /**
@@ -74,7 +75,7 @@ export function useCreateOrder() {
74
75
 
75
76
  return useMutation<CreateOrderResult, unknown, CreateOrderInput>({
76
77
  mutationFn: async (input) => {
77
- const res = await client.post("/public/orders", { ...input, profileId });
78
+ const res = await client.post("/public/orders", { ...input, profileId, metaSignals: readMetaSignals() });
78
79
  return res.data;
79
80
  },
80
81
  });
@@ -1,7 +1,8 @@
1
1
  import { useCallback, useEffect, useState } from "react";
2
+ import { shareRuntimeValue } from "../../runtime/registry";
2
3
 
3
4
  /**
4
- * Headless PWA install-prompt hook — the versioned Forge primitive that powers
5
+ * Headless PWA install-prompt hook: the versioned Forge primitive that powers
5
6
  * `<InstallBanner/>` (forge/ui) and any custom install UI on a code site. Ported
6
7
  * from apps/client/src/lib/hooks/usePWA.ts, plus iOS + standalone detection
7
8
  * (Safari never fires `beforeinstallprompt`, so iOS needs the manual
@@ -22,7 +23,7 @@ export interface InstallPromptState {
22
23
  isInstallable: boolean;
23
24
  /** The app is running installed (standalone display-mode or iOS standalone). */
24
25
  isInstalled: boolean;
25
- /** iOS Safari — no `beforeinstallprompt`; show the Add-to-Home-Screen guide. */
26
+ /** iOS Safari has no `beforeinstallprompt`: show the Add-to-Home-Screen guide. */
26
27
  isIOS: boolean;
27
28
  /** Currently rendered in standalone (installed) display mode. */
28
29
  isStandalone: boolean;
@@ -47,8 +48,108 @@ function detectStandalone(): boolean {
47
48
  return !!mm || iosStandalone;
48
49
  }
49
50
 
51
+ // ---- The captured install prompt, shared ----------------------------------
52
+ //
53
+ // The browser fires `beforeinstallprompt` ONCE per page load, usually soon after
54
+ // it. A listener added later (a prompt mounted after the fan joins a community)
55
+ // would never hear it, so the event is captured the first time anything asks,
56
+ // and every reader shares the one captured event.
57
+ //
58
+ // Shared through the runtime registry, not a module variable: a site and the
59
+ // hosted `/i/` pages bundle each carry a copy of Forge, and the site's copy
60
+ // (`<PwaRegistration>`, at the root) is the one that hears the event.
61
+
62
+ interface InstallPromptStore {
63
+ prompt: BeforeInstallPromptEvent | null;
64
+ installed: boolean;
65
+ capturing: boolean;
66
+ listeners: Set<() => void>;
67
+ }
68
+
69
+ const store = (): InstallPromptStore =>
70
+ shareRuntimeValue<InstallPromptStore>("state:pwa-install-prompt", () => ({
71
+ prompt: null,
72
+ installed: false,
73
+ capturing: false,
74
+ listeners: new Set(),
75
+ }));
76
+
77
+ const notifyPrompt = () => store().listeners.forEach((listener) => listener());
78
+
79
+ /**
80
+ * Start listening for `beforeinstallprompt` (and `appinstalled`). Idempotent and
81
+ * SSR-safe. `<PwaRegistration enabled>` calls it at mount so the event is held
82
+ * from the first page a visitor sees.
83
+ */
84
+ export function captureInstallPrompt(): void {
85
+ const state = store();
86
+ if (state.capturing || typeof window === "undefined") return;
87
+ state.capturing = true;
88
+ window.addEventListener("beforeinstallprompt", (event: Event) => {
89
+ event.preventDefault();
90
+ store().prompt = event as BeforeInstallPromptEvent;
91
+ notifyPrompt();
92
+ });
93
+ window.addEventListener("appinstalled", () => {
94
+ store().prompt = null;
95
+ store().installed = true;
96
+ notifyPrompt();
97
+ });
98
+ }
99
+
100
+ /** True when a native install prompt is held and can be shown. */
101
+ export function hasStoredInstallPrompt(): boolean {
102
+ return !!store().prompt;
103
+ }
104
+
105
+ /** True once the app was installed during this page's life. */
106
+ export function wasInstalledThisSession(): boolean {
107
+ return store().installed;
108
+ }
109
+
110
+ /** Be told when the held prompt arrives, is used, or the app is installed. */
111
+ export function subscribeInstallPrompt(listener: () => void): () => void {
112
+ store().listeners.add(listener);
113
+ return () => {
114
+ store().listeners.delete(listener);
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Show the held native prompt. Resolves true when the visitor accepted. A
120
+ * prompt can be shown once, so it is released either way.
121
+ */
122
+ export async function promptStoredInstall(): Promise<boolean> {
123
+ const state = store();
124
+ const event = state.prompt;
125
+ if (!event) return false;
126
+ state.prompt = null;
127
+ notifyPrompt();
128
+ try {
129
+ await event.prompt();
130
+ const { outcome } = await event.userChoice;
131
+ if (outcome === "accepted") {
132
+ state.installed = true;
133
+ notifyPrompt();
134
+ return true;
135
+ }
136
+ return false;
137
+ } catch {
138
+ return false;
139
+ }
140
+ }
141
+
142
+ /** Test seam: forget whatever was captured, or hold `event` as if the browser had fired it. */
143
+ export function resetInstallPromptForTests(event: Event | null = null): void {
144
+ const state = store();
145
+ state.prompt = event as BeforeInstallPromptEvent | null;
146
+ state.installed = false;
147
+ notifyPrompt();
148
+ }
149
+
150
+ export { detectIOS as isIOSDevice, detectStandalone as isStandaloneDisplay };
151
+
50
152
  export function useInstallPrompt(): InstallPromptState {
51
- const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
52
153
  const [isInstallable, setIsInstallable] = useState(false);
53
154
  const [isInstalled, setIsInstalled] = useState(false);
54
155
  const [isIOS, setIsIOS] = useState(false);
@@ -61,41 +162,16 @@ export function useInstallPrompt(): InstallPromptState {
61
162
  setIsStandalone(standalone);
62
163
  if (standalone) setIsInstalled(true);
63
164
 
64
- const handleBeforeInstallPrompt = (e: Event) => {
65
- e.preventDefault();
66
- setDeferredPrompt(e as BeforeInstallPromptEvent);
67
- setIsInstallable(true);
68
- };
69
- const handleAppInstalled = () => {
70
- setIsInstalled(true);
71
- setIsInstallable(false);
72
- setDeferredPrompt(null);
73
- };
74
-
75
- window.addEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
76
- window.addEventListener("appinstalled", handleAppInstalled);
77
- return () => {
78
- window.removeEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
79
- window.removeEventListener("appinstalled", handleAppInstalled);
165
+ captureInstallPrompt();
166
+ const sync = () => {
167
+ setIsInstallable(hasStoredInstallPrompt());
168
+ if (wasInstalledThisSession()) setIsInstalled(true);
80
169
  };
170
+ sync();
171
+ return subscribeInstallPrompt(sync);
81
172
  }, []);
82
173
 
83
- const promptInstall = useCallback(async () => {
84
- if (!deferredPrompt) return false;
85
- try {
86
- await deferredPrompt.prompt();
87
- const { outcome } = await deferredPrompt.userChoice;
88
- if (outcome === "accepted") {
89
- setIsInstalled(true);
90
- setIsInstallable(false);
91
- setDeferredPrompt(null);
92
- return true;
93
- }
94
- return false;
95
- } catch {
96
- return false;
97
- }
98
- }, [deferredPrompt]);
174
+ const promptInstall = useCallback(() => promptStoredInstall(), []);
99
175
 
100
176
  return { isInstallable, isInstalled, isIOS, isStandalone, promptInstall };
101
177
  }
@@ -0,0 +1,130 @@
1
+ import { useEffect, useState } from "react";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { useForge } from "../../provider/ForgeProvider";
4
+ import { internalPath } from "../../runtime/pages";
5
+
6
+ /**
7
+ * The site app: the installable app every website is (one per origin).
8
+ *
9
+ * Speaks `GET /public/websites/app?host=<host>`, which answers for the website
10
+ * that owns the host the visitor is on. Contract:
11
+ * docs/proposals/site-app-and-website-menu.md, sections 2 and 3.
12
+ */
13
+
14
+ /** The built-in sections of the app home, in their default order. */
15
+ export const SITE_APP_SECTION_KEYS = [
16
+ "needs_you",
17
+ "portal",
18
+ "communities",
19
+ "coming_up",
20
+ "learning",
21
+ "explore_communities",
22
+ "events",
23
+ "courses",
24
+ "shop",
25
+ ] as const;
26
+
27
+ export type SiteAppSectionKey = (typeof SITE_APP_SECTION_KEYS)[number];
28
+
29
+ /** What may sit in the app bar between Home and More. */
30
+ export const SITE_APP_BAR_KEYS = ["communities", "portal", "inbox", "events", "courses", "shop"] as const;
31
+
32
+ export type SiteAppBarKey = (typeof SITE_APP_BAR_KEYS)[number];
33
+
34
+ /** How many configured items fit between Home and More. */
35
+ export const SITE_APP_BAR_MAX_ITEMS = 3;
36
+
37
+ export type SiteAppHomeSection =
38
+ | { type: "builtin"; key: SiteAppSectionKey; visible: boolean }
39
+ | { type: "link"; id: string; label: string; href: string; visible: boolean };
40
+
41
+ /** The public answer, exactly as section 3 of the contract shapes it. */
42
+ export interface SiteApp {
43
+ websiteId: string;
44
+ profileId: string;
45
+ appName: string;
46
+ shortName: string;
47
+ iconUrl: string | null;
48
+ themeColor: string | null;
49
+ backgroundColor: string | null;
50
+ /** `/i/app`, or the creator's own start page. Home everywhere. */
51
+ startUrl: string;
52
+ /** Only the VISIBLE sections, in the configured order. */
53
+ homeSections: SiteAppHomeSection[];
54
+ barItems: SiteAppBarKey[];
55
+ installPrompt: boolean;
56
+ pushPrompt: boolean;
57
+ activeVersionId: string | null;
58
+ }
59
+
60
+ /** Where the app starts when the creator has not chosen a page of their own. */
61
+ export const SITE_APP_DEFAULT_START = internalPath("app");
62
+
63
+ /** Visible by default; `events` and `courses` are off until the creator turns them on. */
64
+ const DEFAULT_HIDDEN: ReadonlySet<SiteAppSectionKey> = new Set(["events", "courses"]);
65
+
66
+ /** The visible home sections when no settings answer: the contract's defaults. */
67
+ export const SITE_APP_DEFAULT_SECTIONS: readonly SiteAppHomeSection[] = SITE_APP_SECTION_KEYS.filter(
68
+ (key) => !DEFAULT_HIDDEN.has(key),
69
+ ).map((key) => ({ type: "builtin", key, visible: true }) as const);
70
+
71
+ export const SITE_APP_DEFAULT_BAR: readonly SiteAppBarKey[] = ["communities", "portal", "inbox"];
72
+
73
+ export const siteAppKeys = {
74
+ byHost: (host: string | null) => ["site-app", host] as const,
75
+ };
76
+
77
+ /**
78
+ * The host the visitor is on, read after mount.
79
+ *
80
+ * Not during render: the server has no `window`, and reading it there would
81
+ * render one thing on the server and another on the first client pass.
82
+ */
83
+ export function useCurrentHost(): string | null {
84
+ const [host, setHost] = useState<string | null>(null);
85
+ useEffect(() => {
86
+ if (typeof window !== "undefined") setHost(window.location.host || null);
87
+ }, []);
88
+ return host;
89
+ }
90
+
91
+ /**
92
+ * This website's app settings, for the host the visitor is on.
93
+ *
94
+ * An unknown host (a preview, the edit sandbox) is a 404, which is an answer
95
+ * rather than a fault: it is not retried, and every reader falls back to the
96
+ * defaults (`useAppHomeHref` to `/i/app`).
97
+ */
98
+ export function useSiteApp(opts: { host?: string | null; enabled?: boolean } = {}) {
99
+ const { client } = useForge();
100
+ const currentHost = useCurrentHost();
101
+ const host = opts.host ?? currentHost;
102
+ return useQuery<SiteApp>({
103
+ queryKey: siteAppKeys.byHost(host),
104
+ queryFn: async () => (await client.get("/public/websites/app", { params: { host } })).data as SiteApp,
105
+ enabled: !!client && !!host && opts.enabled !== false,
106
+ retry: false,
107
+ // Settings change rarely, and every page with an app bar reads them.
108
+ staleTime: 5 * 60_000,
109
+ });
110
+ }
111
+
112
+ /**
113
+ * Where "Home" is: the creator's start page when they chose one, else `/i/app`.
114
+ *
115
+ * The Home rule (contract section 4): the app bar's Home, the community
116
+ * header's logo, and where sign-in lands by default all read this, so a creator
117
+ * who moves Home moves it everywhere at once.
118
+ */
119
+ export function useAppHomeHref(): string {
120
+ const { data } = useSiteApp();
121
+ return resolveAppHomeHref(data);
122
+ }
123
+
124
+ /** The pure half of `useAppHomeHref`, for a caller that already holds the settings. */
125
+ export function resolveAppHomeHref(app: Pick<SiteApp, "startUrl"> | null | undefined): string {
126
+ const start = app?.startUrl?.trim();
127
+ // Only a root-relative path is a Home: anything else would send the member off-site.
128
+ if (!start || !start.startsWith("/") || start.startsWith("//")) return SITE_APP_DEFAULT_START;
129
+ return start;
130
+ }
@@ -1,4 +1,5 @@
1
1
  import { useForge } from "../../provider/ForgeProvider";
2
+ import { readMetaSignals } from "../../utils/metaEvents";
2
3
  import { useOptionalCommunityContext } from "../../contexts/CommunityContext";
3
4
  import { useMutation } from "@tanstack/react-query";
4
5
  import { usePublicAuth } from "../../contexts/PublicAuthContext";
@@ -68,7 +69,11 @@ export function useCreateSubscription() {
68
69
 
69
70
  return useMutation<CreateSubscriptionResult, unknown, CreateSubscriptionInput>({
70
71
  mutationFn: async (body) => {
71
- const res = await client.post("/public/payments/subscriptions", { ...body, profileId });
72
+ const res = await client.post("/public/payments/subscriptions", {
73
+ ...body,
74
+ profileId,
75
+ metaSignals: readMetaSignals(),
76
+ });
72
77
  return res.data;
73
78
  },
74
79
  });