@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tribe-nest/forge",
3
- "version": "3.60.0",
3
+ "version": "3.61.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -35,6 +35,7 @@
35
35
  "@tribe-nest/media-protocol": "^1.0.0",
36
36
  "@tribe-nest/seating-core": "^1.0.0",
37
37
  "axios": "^1.7.9",
38
+ "cobe": "2.0.1",
38
39
  "libphonenumber-js": "^1.13.13",
39
40
  "lucide-react": "^0.510.0",
40
41
  "react-hook-form": "^7.60.0",
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
  import type { IBookingFee, ProductDeliveryType } from "../types/models";
3
- import { useCallback, useContext, useMemo, useState } from "react";
3
+ import { useCallback, useContext, useMemo, useRef, useState } from "react";
4
+ import { trackMeta } from "../utils/metaEvents";
4
5
  import { createSharedContext } from "../runtime/registry";
5
6
  import type { ReactNode } from "react";
6
7
  import { useEffect } from "react";
@@ -333,6 +334,15 @@ export function CartProvider({ children }: { children: ReactNode }) {
333
334
  return false;
334
335
  }
335
336
  setCartItems((prev) => [...prev, next]);
337
+ // Meta AddToCart, only for a line that is new: a duplicate updates the
338
+ // line and adds nothing.
339
+ trackMeta("AddToCart", {
340
+ content_ids: [item.productId],
341
+ content_name: item.title,
342
+ content_type: "product",
343
+ value: Number(item.price) * (item.quantity || 1),
344
+ num_items: item.quantity || 1,
345
+ });
336
346
  return true;
337
347
  },
338
348
  [cartItems],
@@ -392,7 +402,30 @@ export function CartProvider({ children }: { children: ReactNode }) {
392
402
  }
393
403
  }, []);
394
404
 
405
+ // The latest selection, read by `setTickets` to tell an increase (an add,
406
+ // for Meta) from a decrease, outside the state updater (which must stay pure).
407
+ const ticketItemsRef = useRef(ticketItems);
408
+ ticketItemsRef.current = ticketItems;
409
+
395
410
  const setTickets = useCallback((item: TicketCartItem) => {
411
+ const before = ticketItemsRef.current.find((t) => t.eventId === item.eventId);
412
+ let added = 0;
413
+ let addedValue = 0;
414
+ for (const [ticketId, qty] of Object.entries(item.tickets)) {
415
+ const increase = qty - (before?.tickets[ticketId] ?? 0);
416
+ if (increase <= 0) continue;
417
+ added += increase;
418
+ addedValue += increase * Number(item.ticketMeta[ticketId]?.price ?? 0);
419
+ }
420
+ if (added > 0) {
421
+ trackMeta("AddToCart", {
422
+ content_ids: [item.eventId],
423
+ content_name: item.eventTitle,
424
+ content_type: "product",
425
+ value: Number.isFinite(addedValue) ? addedValue : undefined,
426
+ num_items: added,
427
+ });
428
+ }
396
429
  setTicketItems((prev) => [...prev.filter((t) => t.eventId !== item.eventId), item]);
397
430
  setCartOpen(true);
398
431
  }, []);
@@ -31,7 +31,7 @@ import {
31
31
  storeTokenPair,
32
32
  } from "../client/tokenStorage";
33
33
 
34
- // Kept for compat — the canonical constants live in client/tokenStorage.ts.
34
+ // Kept for compat. The canonical constants live in client/tokenStorage.ts.
35
35
  export const ACCESS_TOKEN_KEY = PUBLIC_ACCESS_TOKEN_KEY;
36
36
  export { PUBLIC_ACCESS_TOKEN_KEY };
37
37
 
@@ -129,7 +129,7 @@ function PublicAuthProvider({ children }: AuthProviderProps) {
129
129
  }, []);
130
130
 
131
131
  const { data: currencies } = useQuery({
132
- // profileId scopes the cache — supportedCurrencies is narrowed to the tenant's
132
+ // profileId scopes the cache: supportedCurrencies is narrowed to the tenant's
133
133
  // connected account (must match useCurrencies' key so they share one fetch).
134
134
  queryKey: ["public-currencies", profileId],
135
135
  queryFn: async () => {
@@ -241,6 +241,17 @@ function PublicAuthProvider({ children }: AuthProviderProps) {
241
241
  }
242
242
  };
243
243
 
244
+ /**
245
+ * Sign in with a session another call already minted (the email-first flow's
246
+ * `signed_in` answers). The same three steps every sign-in here ends with:
247
+ * store the token pair, hand the access token to the client, record the user.
248
+ */
249
+ const adoptSession = (session: { token: string; refreshToken?: string; account: PublicAuthUser }) => {
250
+ storeTokenPair(PUBLIC_ACCESS_TOKEN_KEY, PUBLIC_REFRESH_TOKEN_KEY, session.token, session.refreshToken);
251
+ setToken(session.token);
252
+ dispatch({ type: Types.Login, payload: { user: session.account } });
253
+ };
254
+
244
255
  const resendLoginCode = async (email: string) => {
245
256
  // /public/sessions requires the profile context.
246
257
  await client.post("/public/sessions/resend-code", { email, profileId });
@@ -303,7 +314,7 @@ function PublicAuthProvider({ children }: AuthProviderProps) {
303
314
  ...data,
304
315
  });
305
316
  // No token: signup issues no session until the emailed code is redeemed
306
- // (see PublicAuthChallenge). Keep the caller's data — `verifyRegistration`
317
+ // (see PublicAuthChallenge). Keep the caller's data: `verifyRegistration`
307
318
  // needs the coupon/consent flags again to finish the job.
308
319
  const { email } = response.data as { status: "verify_email"; email: string };
309
320
  dispatch({ type: Types.Loading, payload: { isLoading: false } });
@@ -372,6 +383,7 @@ function PublicAuthProvider({ children }: AuthProviderProps) {
372
383
  validateLoginCredentials,
373
384
  verifyLoginCode,
374
385
  resendLoginCode,
386
+ adoptSession,
375
387
  logout,
376
388
  register,
377
389
  verifyRegistration,
@@ -0,0 +1,130 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3
+ import { act, renderHook } from "@testing-library/react";
4
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
5
+ import type { ReactNode } from "react";
6
+
7
+ /**
8
+ * Every request that creates a visitor record carries `metaSignals`, which is
9
+ * what lets a server event sent later (from a worker, with no request) still
10
+ * match the visitor. A create response that carries `metaEvent` means the
11
+ * server queued that event: the browser fires only its pixel twin.
12
+ */
13
+
14
+ const post = vi.fn();
15
+
16
+ vi.mock("../../../provider/ForgeProvider", () => ({
17
+ useForge: () => ({ client: { post }, profileId: "profile-1" }),
18
+ }));
19
+
20
+ import { useSubmitPublicForm } from "../useForms";
21
+ import { useJoinEmailList } from "../useLeadMagnet";
22
+ import { useCreateOrder } from "../useOrders";
23
+ import { useUpdateCoachingBooking } from "../useCoachingAvailability";
24
+ import { resetMetaPolicy, setMetaPolicy } from "../../../utils/metaEvents";
25
+ import { saveCookieConsent, GRANT_ALL, DENY_ALL } from "../../../utils/cookieConsent";
26
+
27
+ const wrapper = ({ children }: { children: ReactNode }) => {
28
+ const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } });
29
+ return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
30
+ };
31
+
32
+ let fbq: ReturnType<typeof vi.fn>;
33
+ let fetchSpy: ReturnType<typeof vi.fn>;
34
+ const pixel = () => fbq.mock.calls.filter((c) => c[0] === "trackSingle");
35
+
36
+ beforeEach(() => {
37
+ post.mockReset();
38
+ resetMetaPolicy();
39
+ window.localStorage.clear();
40
+ document.cookie = "_fbp=fb.1.1.2; path=/";
41
+ fbq = vi.fn();
42
+ (window as unknown as { fbq: unknown }).fbq = fbq;
43
+ fetchSpy = vi.fn().mockResolvedValue({ ok: true });
44
+ vi.stubGlobal("fetch", fetchSpy);
45
+ window.history.replaceState({}, "", "/contact");
46
+ setMetaPolicy({
47
+ pixelId: "555",
48
+ enabled: true,
49
+ requireConsent: true,
50
+ apiUrl: "https://api.test",
51
+ subdomain: "artist",
52
+ currency: "USD",
53
+ resolved: true,
54
+ });
55
+ });
56
+
57
+ afterEach(() => {
58
+ vi.unstubAllGlobals();
59
+ resetMetaPolicy();
60
+ document.cookie = "_fbp=; path=/; max-age=0";
61
+ });
62
+
63
+ describe("useSubmitPublicForm", () => {
64
+ it("attaches metaSignals and fires the pixel copy of the server's Lead", async () => {
65
+ saveCookieConsent(GRANT_ALL);
66
+ post.mockResolvedValue({ data: { id: "sub-1", metaEvent: { name: "Lead", eventId: "sub-1", customData: { content_name: "Contact" } } } });
67
+ const { result } = renderHook(() => useSubmitPublicForm("form-1"), { wrapper });
68
+
69
+ await act(async () => {
70
+ await result.current.mutateAsync({ answers: [], respondentEmail: "a@b.test" });
71
+ });
72
+
73
+ expect(post).toHaveBeenCalledWith("/public/forms/form-1/submit", {
74
+ answers: [],
75
+ respondentEmail: "a@b.test",
76
+ idempotencyKey: expect.any(String),
77
+ metaSignals: { pageUrl: "http://localhost:3000/contact", marketingConsent: true, fbp: "fb.1.1.2" },
78
+ });
79
+ expect(pixel()).toEqual([["trackSingle", "555", "Lead", { content_name: "Contact" }, { eventID: "sub-1" }]]);
80
+ // The server already queued its copy.
81
+ expect(fetchSpy).not.toHaveBeenCalled();
82
+ });
83
+
84
+ it("sends consent false and no Meta ids when marketing was declined, and fires no pixel", async () => {
85
+ saveCookieConsent(DENY_ALL);
86
+ post.mockResolvedValue({ data: { id: "sub-2", metaEvent: { name: "Lead", eventId: "sub-2" } } });
87
+ const { result } = renderHook(() => useSubmitPublicForm("form-1"), { wrapper });
88
+
89
+ await act(async () => {
90
+ await result.current.mutateAsync([]);
91
+ });
92
+
93
+ expect(post.mock.calls[0][1].metaSignals).toEqual({ pageUrl: "http://localhost:3000/contact", marketingConsent: false });
94
+ expect(pixel()).toEqual([]);
95
+ });
96
+ });
97
+
98
+ describe("other create requests", () => {
99
+ beforeEach(() => saveCookieConsent(GRANT_ALL));
100
+
101
+ it("useJoinEmailList attaches metaSignals and fires CompleteRegistration from the response", async () => {
102
+ post.mockResolvedValue({ data: { metaEvent: { name: "CompleteRegistration", eventId: "subscriber-1" } } });
103
+ const { result } = renderHook(() => useJoinEmailList(), { wrapper });
104
+ await act(async () => {
105
+ await result.current.mutateAsync({ email: "a@b.test", emailListId: "list-1" });
106
+ });
107
+ expect(post.mock.calls[0][1]).toMatchObject({ email: "a@b.test", profileId: "profile-1", metaSignals: { marketingConsent: true } });
108
+ expect(pixel()).toEqual([["trackSingle", "555", "CompleteRegistration", {}, { eventID: "subscriber-1" }]]);
109
+ });
110
+
111
+ it("useUpdateCoachingBooking fires Schedule from the response", async () => {
112
+ post.mockResolvedValue({ data: { bookingId: "b-1", isConfirmed: true, metaEvent: { name: "Schedule", eventId: "b-1" } } });
113
+ const { result } = renderHook(() => useUpdateCoachingBooking("coach-1"), { wrapper });
114
+ await act(async () => {
115
+ await result.current.mutateAsync({ bookingId: "b-1", email: "a@b.test", firstName: "A", lastName: "B", confirmIfFree: true });
116
+ });
117
+ expect(post.mock.calls[0][1].metaSignals).toMatchObject({ marketingConsent: true, fbp: "fb.1.1.2" });
118
+ expect(pixel()).toEqual([["trackSingle", "555", "Schedule", {}, { eventID: "b-1" }]]);
119
+ });
120
+
121
+ it("useCreateOrder attaches metaSignals and fires nothing itself", async () => {
122
+ post.mockResolvedValue({ data: { orderId: "o-1", totalAmount: 10, subTotal: 10 } });
123
+ const { result } = renderHook(() => useCreateOrder(), { wrapper });
124
+ await act(async () => {
125
+ await result.current.mutateAsync({ amount: 10, email: "a@b.test", cartItems: [] });
126
+ });
127
+ expect(post.mock.calls[0][1]).toMatchObject({ amount: 10, profileId: "profile-1", metaSignals: { marketingConsent: true } });
128
+ expect(pixel()).toEqual([]);
129
+ });
130
+ });
@@ -1,6 +1,6 @@
1
1
  import type { IPublicOrder, IPublicPost, PaginatedData } from "../../types/models";
2
2
  import { useForge } from "../../provider/ForgeProvider";
3
- import { useMutation, useQuery } from "@tanstack/react-query";
3
+ import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query";
4
4
 
5
5
  /** Orders placed by the signed-in member on this profile. */
6
6
  export function useUserOrders(accountId?: string) {
@@ -40,6 +40,11 @@ export type NotificationPreferences = {
40
40
  };
41
41
 
42
42
  /** The member's notification-type preferences for this profile. */
43
+ /**
44
+ * @deprecated Reads `newPosts` / `chatMessages`, which the server never sends:
45
+ * the preferences endpoint speaks the `"<type>.<channel>"` matrix. Use
46
+ * `useNotificationMatrix` and `useUpdateNotificationMatrix`.
47
+ */
43
48
  export function useNotificationPreferences(enabled = true) {
44
49
  const { client, profileId } = useForge();
45
50
 
@@ -55,7 +60,11 @@ export function useNotificationPreferences(enabled = true) {
55
60
  });
56
61
  }
57
62
 
58
- /** Update one notification-type preference. */
63
+ /**
64
+ * @deprecated Sends `{ newPosts: false }`, which the server drops without an
65
+ * error (it accepts only `"<type>.<channel>"` keys), so nothing is ever saved.
66
+ * Use `useUpdateNotificationMatrix`.
67
+ */
59
68
  export function useUpdateNotificationPreference() {
60
69
  const { client, profileId } = useForge();
61
70
 
@@ -69,3 +78,25 @@ export function useUpdateNotificationPreference() {
69
78
  },
70
79
  });
71
80
  }
81
+
82
+ /**
83
+ * Every order, a page at a time, for a list with a "Show more" button.
84
+ *
85
+ * `useUserOrders` sends no page, so the server's default of 10 applies and a
86
+ * buyer with more orders than that never saw the older ones. `/public/orders`
87
+ * returns a bare array with no total, so a FULL page is the only sign that
88
+ * another may follow; an exact multiple costs one extra, empty request.
89
+ */
90
+ export function useUserOrdersPaged(accountId?: string, limit = 20) {
91
+ const { client, profileId } = useForge();
92
+ return useInfiniteQuery({
93
+ queryKey: ["user-orders", accountId, profileId, "paged", limit],
94
+ initialPageParam: 1,
95
+ queryFn: async ({ pageParam }) => {
96
+ const res = await client.get("/public/orders", { params: { profileId, page: pageParam, limit } });
97
+ return res.data as IPublicOrder[];
98
+ },
99
+ getNextPageParam: (last, pages) => (last.length === limit ? pages.length + 1 : undefined),
100
+ enabled: !!accountId && !!profileId && !!client,
101
+ });
102
+ }
@@ -5,7 +5,7 @@ import { useForge } from "../../provider/ForgeProvider";
5
5
  * server-side CAPI twin, and for correlating first-party events. */
6
6
  export function newEventId(): string {
7
7
  if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
8
- // Fallback for older runtimes — not cryptographically strong, only needs to be
8
+ // Fallback for older runtimes: not cryptographically strong, only needs to be
9
9
  // unique enough to pair a pixel + CAPI event.
10
10
  return `evt_${Date.now().toString(36)}_${Math.abs(hashString(String(performance?.now?.() ?? Date.now()))).toString(36)}`;
11
11
  }
@@ -33,7 +33,7 @@ export function getAnalyticsSessionId(subdomain: string): string | null {
33
33
  }
34
34
  return sessionId;
35
35
  } catch {
36
- // Private mode / storage disabled — fall back to an ephemeral per-load id.
36
+ // Private mode / storage disabled: fall back to an ephemeral per-load id.
37
37
  return `${subdomain}_${Date.now()}`;
38
38
  }
39
39
  }
@@ -44,7 +44,7 @@ export interface TrackEventOptions {
44
44
  /**
45
45
  * Store a session id in `sessionStorage` and send it with events. Default
46
46
  * `true`. Set `false` for cookieless, Plausible-style analytics: nothing is
47
- * written to the device and no id is sent — the backend derives a rotating
47
+ * written to the device and no id is sent; the backend derives a rotating
48
48
  * per-day visitor hash instead, so the "visits" metric still works without
49
49
  * needing storage consent.
50
50
  */
@@ -53,7 +53,7 @@ export interface TrackEventOptions {
53
53
 
54
54
  /**
55
55
  * The first-party website-analytics primitive. Returns a `track(eventType,
56
- * eventData)` function that posts to `POST /public/websites/track-event` — the
56
+ * eventData)` function that posts to `POST /public/websites/track-event`, the
57
57
  * same feed the platform's admin analytics dashboard reads (visits, page views,
58
58
  * clicks, geo). The backend enriches each event with geo-IP + user agent.
59
59
  *
@@ -68,7 +68,7 @@ export function useTrackEvent(opts?: TrackEventOptions) {
68
68
 
69
69
  const buildPayload = useCallback(
70
70
  (eventData: Record<string, unknown>) => {
71
- // Cookieless mode omits the sessionId entirely — the backend derives a
71
+ // Cookieless mode omits the sessionId entirely; the backend derives a
72
72
  // rotating per-day visitor hash so nothing touches the device.
73
73
  const sessionId = storeSession && subdomain ? getAnalyticsSessionId(subdomain) : undefined;
74
74
  const payload: Record<string, unknown> = { ...eventData };
@@ -133,5 +133,5 @@ export function useTrackEvent(opts?: TrackEventOptions) {
133
133
  [apiUrl, subdomain, buildPayload],
134
134
  );
135
135
 
136
- return { track, trackBeacon };
136
+ return { track, trackBeacon, apiUrl, subdomain };
137
137
  }
@@ -22,7 +22,7 @@ type ReactionRow = { emoji: string; count: number; reactedByMe: boolean };
22
22
  type MessageWithReactions = ChatMessage & { reactions?: ReactionRow[] };
23
23
 
24
24
  const senderName = (message: MessageWithReactions): string =>
25
- [message.senderFirstName, message.senderLastName].filter(Boolean).join(" ").trim() || message.senderAccountId;
25
+ [message.senderFirstName, message.senderLastName].filter(Boolean).join(" ").trim() || (message.senderAccountId ?? "");
26
26
 
27
27
  export function useCallChat(callId: string | undefined): CallStageChat | undefined {
28
28
  const { client, profileId } = useForge();
@@ -145,7 +145,7 @@ export function useCallChat(callId: string | undefined): CallStageChat | undefin
145
145
  id: message.id,
146
146
  content: message.content,
147
147
  senderName: senderName(message),
148
- senderKey: message.senderAccountId,
148
+ senderKey: message.senderAccountId ?? message.senderAgentId ?? "",
149
149
  at: message.createdAt,
150
150
  reactions: message.reactions ?? [],
151
151
  }));
@@ -14,6 +14,11 @@ export interface ChatChannel {
14
14
  description: string | null;
15
15
  /** For DM channels, the counterpart participant (backend-enriched). */
16
16
  otherParticipant?: ChatDmPeer | null;
17
+ /**
18
+ * A DM's pair key. Null for the member and creator DM; `agent:...` for the
19
+ * member's DM with the community AI (see `isCommunityAgentDm`).
20
+ */
21
+ dmKey?: string | null;
17
22
  }
18
23
 
19
24
  export interface ChatAttachment {
@@ -27,7 +32,11 @@ export interface ChatMessage {
27
32
  id: string;
28
33
  channelId: string;
29
34
  content: string | null;
30
- senderAccountId: string;
35
+ /** Null when the community AI sent it (`senderAgentId` is then set). */
36
+ senderAccountId: string | null;
37
+ /** The community AI, when it sent this message. `senderFirstName` is then its name. */
38
+ senderAgentId?: string | null;
39
+ senderAgentAvatarUrl?: string | null;
31
40
  senderFirstName: string;
32
41
  senderLastName: string;
33
42
  isPinned: boolean;
@@ -1,4 +1,5 @@
1
1
  import { useMutation } from "@tanstack/react-query";
2
+ import { readMetaSignals } from "../../utils/metaEvents";
2
3
  import { useForge } from "../../provider/ForgeProvider";
3
4
  import { bundleCouponRequestBody } from "../../ui/headless/checkout/bundleCoupon";
4
5
  import type { CartItem, TicketCartItem } from "../../contexts/CartContext";
@@ -133,7 +134,7 @@ export function useCreateCheckout() {
133
134
 
134
135
  return useMutation<CreateCheckoutResult, unknown, Record<string, unknown>>({
135
136
  mutationFn: async (body) => {
136
- const res = await client.post("/public/checkouts", { profileId, ...body });
137
+ const res = await client.post("/public/checkouts", { profileId, ...body, metaSignals: readMetaSignals() });
137
138
  return res.data;
138
139
  },
139
140
  });
@@ -1,4 +1,5 @@
1
1
  import type { BookingSlot, CancellationTermsView, PillarDiscountQuote } from "../../types/models";
2
+ import { fireServerMetaEvent, readMetaSignals, type ServerMetaEvent } from "../../utils/metaEvents";
2
3
  import { useForge } from "../../provider/ForgeProvider";
3
4
  import { useMutation, useQuery } from "@tanstack/react-query";
4
5
 
@@ -44,6 +45,7 @@ export function useReserveCoachingBooking(productId?: string) {
44
45
  mutationFn: async ({ slotId }) => {
45
46
  const res = await client.post(`/public/coaching/products/${productId}/booking/reserve`, {
46
47
  slotId,
48
+ metaSignals: readMetaSignals(),
47
49
  });
48
50
  return res.data;
49
51
  },
@@ -71,6 +73,8 @@ export type UpdateCoachingBookingInput = {
71
73
  export type UpdateCoachingBookingResult = PillarDiscountQuote & {
72
74
  bookingId: string;
73
75
  isConfirmed: boolean;
76
+ /** Set when this call newly confirmed a free booking and the server queued Schedule. */
77
+ metaEvent?: ServerMetaEvent;
74
78
  };
75
79
 
76
80
  /**
@@ -87,9 +91,14 @@ export function useUpdateCoachingBooking(productId?: string) {
87
91
 
88
92
  return useMutation<UpdateCoachingBookingResult, unknown, UpdateCoachingBookingInput>({
89
93
  mutationFn: async (body) => {
90
- const res = await client.post(`/public/coaching/products/${productId}/booking/update`, body);
94
+ const res = await client.post(`/public/coaching/products/${productId}/booking/update`, {
95
+ ...body,
96
+ metaSignals: readMetaSignals(),
97
+ });
91
98
  return res.data;
92
99
  },
100
+ // A free booking newly confirmed: the server queued Schedule; fire its pixel twin.
101
+ onSuccess: (data) => fireServerMetaEvent(data),
93
102
  });
94
103
  }
95
104
 
@@ -29,6 +29,8 @@ export interface PublicCommunity {
29
29
  category: string | null;
30
30
  tags: string[];
31
31
  memberCount: number;
32
+ /** How a visitor gets in. Absent on older servers: treat as open. */
33
+ joinMode?: "open" | "application" | "invite";
32
34
  }
33
35
 
34
36
  /** A tier the viewer may join: the public tier shape plus the community it belongs to. */