@tribe-nest/forge 3.59.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.
- package/package.json +2 -1
- package/src/_tests/testCommunity.tsx +28 -0
- package/src/contexts/CartContext.tsx +34 -1
- package/src/contexts/CommunityContext.tsx +192 -0
- package/src/contexts/PublicAuthContext.tsx +15 -3
- package/src/data/queries/_tests/metaSignals.spec.tsx +130 -0
- package/src/data/queries/useAccount.ts +33 -2
- package/src/data/queries/useAnalytics.ts +6 -6
- package/src/data/queries/useCallChat.ts +2 -2
- package/src/data/queries/useChat.ts +52 -40
- package/src/data/queries/useCheckouts.ts +2 -1
- package/src/data/queries/useCoachingAvailability.ts +10 -1
- package/src/data/queries/useCommunities.ts +345 -0
- package/src/data/queries/useCommunity.ts +450 -102
- package/src/data/queries/useCommunityAgent.ts +247 -0
- package/src/data/queries/useCommunityCalendar.ts +150 -0
- package/src/data/queries/useCommunityClassroom.ts +75 -0
- package/src/data/queries/useCommunityMembers.ts +108 -0
- package/src/data/queries/useCommunityModeration.ts +327 -0
- package/src/data/queries/useCommunityWiki.ts +137 -0
- package/src/data/queries/useCourses.ts +2 -0
- package/src/data/queries/useEmailSignIn.ts +79 -0
- package/src/data/queries/useEvents.ts +6 -1
- package/src/data/queries/useForms.ts +5 -1
- package/src/data/queries/useLeadMagnet.ts +6 -2
- package/src/data/queries/useMembership.ts +32 -7
- package/src/data/queries/useMembershipCalls.ts +116 -7
- package/src/data/queries/useMyBookings.ts +24 -1
- package/src/data/queries/useMyTickets.ts +24 -1
- package/src/data/queries/useNotifications.ts +6 -2
- package/src/data/queries/useOrders.ts +2 -1
- package/src/data/queries/usePWA.ts +111 -35
- package/src/data/queries/usePostCollections.ts +28 -16
- package/src/data/queries/usePosts.ts +16 -14
- package/src/data/queries/useSiteApp.ts +130 -0
- package/src/data/queries/useSubscriptions.ts +28 -9
- package/src/i18n/de.json +891 -2
- package/src/i18n/en.json +891 -2
- package/src/index.ts +36 -7
- package/src/runtime/pages.spec.ts +36 -0
- package/src/runtime/pages.ts +52 -27
- package/src/server/_tests/buildWebManifest.spec.ts +1 -1
- package/src/server/_tests/forgeServiceWorkerScript.spec.ts +4 -4
- package/src/types/models.ts +11 -0
- package/src/ui/analytics/ForgeAnalytics.tsx +64 -34
- package/src/ui/analytics/PageMetaPixel.tsx +23 -8
- package/src/ui/analytics/_tests/ForgeAnalytics.meta.spec.tsx +129 -0
- package/src/ui/analytics/_tests/metaCommerceEvents.spec.tsx +159 -0
- package/src/ui/analytics/useMetaEvents.ts +78 -0
- package/src/ui/format/_tests/membershipGate.spec.ts +6 -6
- package/src/ui/format/membershipGate.ts +19 -15
- package/src/ui/headless/_tests/communityAgent.spec.tsx +519 -0
- package/src/ui/headless/_tests/communityCalendar.spec.tsx +195 -0
- package/src/ui/headless/_tests/communityClassroom.spec.tsx +120 -0
- package/src/ui/headless/_tests/communityGate.spec.tsx +80 -0
- package/src/ui/headless/_tests/communityHarness.tsx +212 -0
- package/src/ui/headless/_tests/communityMembers.spec.tsx +316 -0
- package/src/ui/headless/_tests/communityPoll.spec.tsx +389 -0
- package/src/ui/headless/_tests/communityProvider.spec.tsx +151 -0
- package/src/ui/headless/_tests/memberHomeBar.spec.ts +84 -0
- package/src/ui/headless/_tests/siteAppBar.spec.ts +83 -0
- package/src/ui/headless/_tests/siteAppHarness.ts +19 -0
- package/src/ui/headless/_tests/siteAppHome.spec.ts +226 -0
- package/src/ui/headless/_tests/siteAppPrompts.spec.ts +185 -0
- package/src/ui/headless/_tests/useCommunityInvite.spec.tsx +104 -0
- package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +596 -0
- package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +179 -0
- package/src/ui/headless/_tests/useCommunityModeration.spec.tsx +420 -0
- package/src/ui/headless/_tests/useCommunityReads.spec.tsx +295 -0
- package/src/ui/headless/_tests/useCommunityUpgrade.spec.tsx +157 -0
- package/src/ui/headless/_tests/useCommunityWiki.spec.tsx +224 -0
- package/src/ui/headless/_tests/useSiteApp.spec.tsx +52 -0
- package/src/ui/headless/_tests/wikiMediaImage.spec.tsx +132 -0
- package/src/ui/headless/auth/useEmailCodeSignIn.ts +186 -0
- package/src/ui/headless/auth/useSignupForm.ts +30 -3
- package/src/ui/headless/chat/useChatAttachmentUpload.ts +9 -6
- package/src/ui/headless/chat/useChatRoom.ts +35 -1
- package/src/ui/headless/communities/CommunityGate.tsx +67 -0
- package/src/ui/headless/communities/index.ts +96 -0
- package/src/ui/headless/communities/paths.ts +36 -0
- package/src/ui/headless/communities/useCommunity.ts +140 -0
- package/src/ui/headless/communities/useCommunityAbout.ts +71 -0
- package/src/ui/headless/communities/useCommunityCalendar.ts +156 -0
- package/src/ui/headless/communities/useCommunityClassroom.ts +84 -0
- package/src/ui/headless/communities/useCommunityHome.ts +91 -0
- package/src/ui/headless/communities/useCommunityInvite.ts +116 -0
- package/src/ui/headless/communities/useCommunityJoin.ts +494 -0
- package/src/ui/headless/communities/useCommunityMembership.ts +182 -0
- package/src/ui/headless/communities/useCommunityUpgrade.ts +213 -0
- package/src/ui/headless/communities/useCommunityWiki.ts +266 -0
- package/src/ui/headless/communities/useMyMemberships.ts +59 -0
- package/src/ui/headless/community/communityPoll.ts +297 -0
- package/src/ui/headless/community/countries.ts +123 -0
- package/src/ui/headless/community/index.ts +81 -1
- package/src/ui/headless/community/linkPreview.ts +71 -0
- package/src/ui/headless/community/richLexical.ts +27 -2
- package/src/ui/headless/community/useCommunityAgent.ts +285 -0
- package/src/ui/headless/community/useCommunityAttachmentUpload.ts +7 -3
- package/src/ui/headless/community/useCommunityMembers.ts +175 -0
- package/src/ui/headless/community/useCommunityModeration.ts +300 -0
- package/src/ui/headless/community/useCommunitySpaceFeed.ts +22 -4
- package/src/ui/headless/community/useCommunityThread.ts +16 -3
- package/src/ui/headless/community/useMentionComposer.ts +17 -9
- package/src/ui/headless/community/useRichMentionComposer.ts +21 -5
- package/src/ui/headless/downloads/index.ts +8 -0
- package/src/ui/headless/downloads/useDownloadPage.ts +134 -0
- package/src/ui/headless/index.ts +21 -4
- package/src/ui/headless/memberHome/index.ts +11 -1
- package/src/ui/headless/memberHome/memberHomeBar.ts +91 -0
- package/src/ui/headless/memberHome/useMemberHomeNav.ts +84 -44
- package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +8 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +5 -4
- package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +1 -1
- package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +1 -1
- package/src/ui/headless/membership/useMembershipCheckout.ts +89 -22
- package/src/ui/headless/membership/useMembershipGateNotice.ts +25 -7
- package/src/ui/headless/portal/index.ts +57 -0
- package/src/ui/headless/portal/usePortal.ts +274 -0
- package/src/ui/headless/portal/usePortalFiles.ts +68 -0
- package/src/ui/headless/portal/usePortalMessages.ts +189 -0
- package/src/ui/headless/portal/usePortalPreview.ts +142 -0
- package/src/ui/headless/posts/upsell.ts +54 -8
- package/src/ui/headless/posts/useMemberPostsFeed.ts +6 -5
- package/src/ui/headless/posts/usePostCollectionDetail.ts +7 -4
- package/src/ui/headless/posts/usePostCollectionsList.ts +6 -5
- package/src/ui/headless/posts/usePostItem.ts +6 -5
- package/src/ui/headless/siteApp/appBar.ts +102 -0
- package/src/ui/headless/siteApp/appHome.ts +344 -0
- package/src/ui/headless/siteApp/appPrompts.ts +229 -0
- package/src/ui/headless/siteApp/index.ts +58 -0
- package/src/ui/headless/siteApp/useAppHome.ts +210 -0
- package/src/ui/headless/siteApp/useAppInstallRequest.ts +16 -0
- package/src/ui/headless/work/index.ts +2 -0
- package/src/ui/headless/work/useWorkPortal.ts +37 -26
- package/src/ui/index.ts +28 -12
- package/src/ui/styled/AccountDashboard.tsx +754 -602
- package/src/ui/styled/AiAgentWidget.tsx +13 -1
- package/src/ui/styled/ChatRoom.tsx +126 -16
- package/src/ui/styled/Checkout.tsx +8 -0
- package/src/ui/styled/CheckoutConfirmation.tsx +8 -0
- package/src/ui/styled/CoachingConfirmation.tsx +8 -0
- package/src/ui/styled/CoachingDetail.tsx +2 -0
- package/src/ui/styled/CourseConfirmation.tsx +5 -0
- package/src/ui/styled/CourseDetail.tsx +2 -0
- package/src/ui/styled/DownloadPage.tsx +263 -0
- package/src/ui/styled/EventConfirmation.tsx +10 -0
- package/src/ui/styled/EventDetail.tsx +10 -0
- package/src/ui/styled/FilmLibrary.tsx +22 -9
- package/src/ui/styled/MembershipCheckout.tsx +37 -17
- package/src/ui/styled/MembershipTierCallout.tsx +20 -1
- package/src/ui/styled/MembershipTiers.tsx +49 -11
- package/src/ui/styled/MusicLinkPage.tsx +5 -5
- package/src/ui/styled/Paywall.tsx +1 -1
- package/src/ui/styled/ProductDetail.tsx +12 -0
- package/src/ui/styled/PwaRegistration.tsx +5 -1
- package/src/ui/styled/SignupForm.tsx +18 -3
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +6 -6
- package/src/ui/styled/_tests/AccountDashboardMemberships.spec.tsx +151 -0
- package/src/ui/styled/_tests/AccountDashboardRentals.spec.tsx +1 -1
- package/src/ui/styled/_tests/AccountNotifications.spec.tsx +135 -0
- package/src/ui/styled/_tests/CourseCallsSection.spec.tsx +7 -1
- package/src/ui/styled/_tests/DownloadPage.spec.tsx +68 -0
- package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +54 -11
- package/src/ui/styled/_tests/membershipTierCallout.spec.tsx +1 -1
- package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +1 -1
- package/src/ui/styled/app/AppBar.tsx +298 -0
- package/src/ui/styled/app/AppHome.tsx +1139 -0
- package/src/ui/styled/app/AppHomeMark.tsx +66 -0
- package/src/ui/styled/app/AppPrompts.tsx +464 -0
- package/src/ui/styled/app/_tests/AppBar.spec.tsx +96 -0
- package/src/ui/styled/app/_tests/AppHome.spec.tsx +209 -0
- package/src/ui/styled/app/_tests/AppPrompts.spec.tsx +232 -0
- package/src/ui/styled/app/index.ts +7 -0
- package/src/ui/styled/chat/ChatThreadPanel.tsx +29 -4
- package/src/ui/styled/chat/MessageBubble.tsx +43 -6
- package/src/ui/styled/communities/CommunitiesList.tsx +88 -0
- package/src/ui/styled/communities/CommunityAbout.tsx +145 -0
- package/src/ui/styled/communities/CommunityCalendar.tsx +484 -0
- package/src/ui/styled/communities/CommunityClassroom.tsx +302 -0
- package/src/ui/styled/communities/CommunityHome.tsx +298 -0
- package/src/ui/styled/communities/CommunityJoinSheet.tsx +544 -0
- package/src/ui/styled/communities/CommunityMembershipMenu.tsx +249 -0
- package/src/ui/styled/communities/CommunityPending.tsx +49 -0
- package/src/ui/styled/communities/CommunityPublicWiki.tsx +75 -0
- package/src/ui/styled/communities/CommunityUpgradeSheet.tsx +137 -0
- package/src/ui/styled/communities/CommunityWiki.tsx +289 -0
- package/src/ui/styled/communities/MyMembershipsList.tsx +115 -0
- package/src/ui/styled/communities/_tests/CommunityAppHome.spec.tsx +92 -0
- package/src/ui/styled/communities/_tests/CommunityClassroomCalendar.spec.tsx +453 -0
- package/src/ui/styled/communities/_tests/CommunityMobile.spec.tsx +399 -0
- package/src/ui/styled/communities/_tests/CommunityPages.spec.tsx +102 -0
- package/src/ui/styled/communities/_tests/CommunityWiki.spec.tsx +223 -0
- package/src/ui/styled/communities/index.ts +19 -0
- package/src/ui/styled/communities/shared.tsx +177 -0
- package/src/ui/styled/community/CommunityAgent.tsx +286 -0
- package/src/ui/styled/community/CommunityComposer.tsx +266 -15
- package/src/ui/styled/community/CommunityFeed.tsx +50 -1
- package/src/ui/styled/community/CommunityLinkPreview.tsx +159 -0
- package/src/ui/styled/community/CommunityMemberGlobe.tsx +171 -0
- package/src/ui/styled/community/CommunityMemberProfile.tsx +137 -4
- package/src/ui/styled/community/CommunityMembers.tsx +387 -0
- package/src/ui/styled/community/CommunityModeration.tsx +587 -0
- package/src/ui/styled/community/CommunityModerationState.tsx +127 -0
- package/src/ui/styled/community/CommunityModeratorMenu.tsx +317 -0
- package/src/ui/styled/community/CommunityNotifications.tsx +52 -6
- package/src/ui/styled/community/CommunityPollCard.tsx +250 -0
- package/src/ui/styled/community/CommunityPostCard.tsx +110 -34
- package/src/ui/styled/community/CommunityPostDetail.tsx +157 -71
- package/src/ui/styled/community/CommunityRichText.tsx +177 -32
- package/src/ui/styled/community/CommunitySpaces.tsx +49 -14
- package/src/ui/styled/community/_tests/CommunityAgent.spec.tsx +571 -0
- package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +4 -1
- package/src/ui/styled/community/_tests/CommunityLinkPreview.spec.tsx +167 -0
- package/src/ui/styled/community/_tests/CommunityMembers.spec.tsx +137 -0
- package/src/ui/styled/community/_tests/CommunityModeration.spec.tsx +291 -0
- package/src/ui/styled/community/_tests/CommunityPoll.spec.tsx +386 -0
- package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +4 -1
- package/src/ui/styled/community/index.ts +37 -2
- package/src/ui/styled/forge-utilities.css +253 -0
- package/src/ui/styled/members/CallRecordingActions.tsx +182 -0
- package/src/ui/styled/members/CallTranscriptViewer.tsx +424 -0
- package/src/ui/styled/members/MemberCallsCalendar.tsx +41 -54
- package/src/ui/styled/members/MemberHomeLayout.tsx +327 -29
- package/src/ui/styled/members/MemberPostCard.tsx +9 -5
- package/src/ui/styled/members/MemberPostsFeed.tsx +3 -2
- package/src/ui/styled/members/PostCollectionDetail.tsx +18 -10
- package/src/ui/styled/members/PostCollectionsGrid.tsx +24 -10
- package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +167 -8
- package/src/ui/styled/members/callTranscriptText.ts +67 -0
- package/src/ui/styled/members/index.ts +8 -0
- package/src/ui/styled/portal/PortalAccountSection.tsx +61 -0
- package/src/ui/styled/portal/PortalActivity.tsx +167 -0
- package/src/ui/styled/portal/PortalBilling.tsx +197 -0
- package/src/ui/styled/portal/PortalFiles.tsx +279 -0
- package/src/ui/styled/portal/PortalHome.tsx +401 -0
- package/src/ui/styled/portal/PortalMessages.tsx +300 -0
- package/src/ui/styled/portal/PortalPreviewBanner.tsx +75 -0
- package/src/ui/styled/portal/PortalProjects.tsx +407 -0
- package/src/ui/styled/portal/PortalShell.tsx +397 -0
- package/src/ui/styled/portal/_tests/PortalAppHome.spec.tsx +90 -0
- package/src/ui/styled/portal/_tests/PortalFiles.spec.tsx +159 -0
- package/src/ui/styled/portal/_tests/PortalHome.spec.tsx +148 -0
- package/src/ui/styled/portal/_tests/PortalMessages.spec.tsx +168 -0
- package/src/ui/styled/portal/_tests/PortalPreviewBanner.spec.tsx +132 -0
- package/src/ui/styled/portal/_tests/portalNav.spec.ts +123 -0
- package/src/ui/styled/portal/index.ts +13 -0
- package/src/ui/styled/portal/portalNav.ts +260 -0
- package/src/ui/styled/portal/shared.tsx +206 -0
- package/src/ui/styled/work/WorkInviteAccept.tsx +228 -168
- package/src/ui/styled/work/WorkPortalProjects.tsx +68 -120
- package/src/ui/styled/work/WorkProjectDocuments.tsx +99 -128
- package/src/ui/styled/work/WorkProjectInvoices.tsx +91 -86
- package/src/ui/styled/work/WorkProjectReport.tsx +11 -17
- package/src/ui/styled/work/WorkReportBody.tsx +223 -248
- package/src/ui/styled/work/WorkTaskAttachments.tsx +23 -29
- package/src/ui/styled/work/WorkTaskComments.tsx +75 -102
- package/src/ui/styled/work/WorkTaskDetail.tsx +48 -59
- package/src/ui/styled/work/WorkTokenReport.tsx +8 -16
- package/src/ui/styled/work/index.ts +1 -1
- package/src/utils/_tests/metaEvents.spec.ts +264 -0
- package/src/utils/_tests/safeRedirect.spec.ts +2 -2
- package/src/utils/landing.ts +19 -6
- package/src/utils/metaEvents.ts +302 -0
- package/src/ui/headless/membership/MembershipGate.tsx +0 -44
- package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +0 -149
- package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +0 -105
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
3
|
+
import { render, act, cleanup } from "@testing-library/react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The page view's Meta pair: the profile pixel's PageView and its server copy
|
|
7
|
+
* (POST /public/websites/meta-event) carry the id the first-party page view
|
|
8
|
+
* already has, so Meta keeps one. Consent loads AFTER mount, which used to
|
|
9
|
+
* mean the first page never sent a Meta PageView at all.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const tracked: { eventType: string; data: Record<string, unknown> }[] = [];
|
|
13
|
+
let seq = 0;
|
|
14
|
+
|
|
15
|
+
vi.mock("../../../data/queries/useAnalytics", () => ({
|
|
16
|
+
newEventId: () => `evt-${++seq}`,
|
|
17
|
+
useTrackEvent: () => ({
|
|
18
|
+
track: (eventType: string, data: Record<string, unknown>) => tracked.push({ eventType, data }),
|
|
19
|
+
trackBeacon: () => {},
|
|
20
|
+
apiUrl: "https://api.test",
|
|
21
|
+
subdomain: "artist",
|
|
22
|
+
}),
|
|
23
|
+
}));
|
|
24
|
+
vi.mock("../../../data/queries/useWebsite", () => ({
|
|
25
|
+
useSiteConfig: () => ({ data: { metaPixelId: "999", currency: "USD" }, isLoading: false }),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
import { ForgeAnalytics } from "../ForgeAnalytics";
|
|
29
|
+
import { resetMetaPolicy } from "../../../utils/metaEvents";
|
|
30
|
+
import { saveCookieConsent, GRANT_ALL, DENY_ALL } from "../../../utils/cookieConsent";
|
|
31
|
+
import { suppressProfilePixel, unsuppressProfilePixel } from "../../../utils/analyticsBus";
|
|
32
|
+
|
|
33
|
+
let fetchSpy: ReturnType<typeof vi.fn>;
|
|
34
|
+
let fbq: ReturnType<typeof vi.fn>;
|
|
35
|
+
|
|
36
|
+
const metaPosts = () =>
|
|
37
|
+
fetchSpy.mock.calls
|
|
38
|
+
.filter((c) => String(c[0]).endsWith("/public/websites/meta-event"))
|
|
39
|
+
.map((c) => JSON.parse((c[1] as RequestInit).body as string));
|
|
40
|
+
|
|
41
|
+
const flush = () => act(async () => {});
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
tracked.length = 0;
|
|
45
|
+
seq = 0;
|
|
46
|
+
resetMetaPolicy();
|
|
47
|
+
window.localStorage.clear();
|
|
48
|
+
fetchSpy = vi.fn().mockResolvedValue({ ok: true });
|
|
49
|
+
vi.stubGlobal("fetch", fetchSpy);
|
|
50
|
+
fbq = vi.fn();
|
|
51
|
+
(window as unknown as { fbq: unknown }).fbq = fbq;
|
|
52
|
+
window.history.replaceState({}, "", "/first");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
cleanup();
|
|
57
|
+
vi.unstubAllGlobals();
|
|
58
|
+
resetMetaPolicy();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("<ForgeAnalytics /> Meta PageView", () => {
|
|
62
|
+
it("sends the pixel PageView and its server copy with the page view's id, once consent loads", async () => {
|
|
63
|
+
saveCookieConsent(GRANT_ALL);
|
|
64
|
+
render(<ForgeAnalytics trackClicks={false} firstPartyMode="cookieless" />);
|
|
65
|
+
await flush();
|
|
66
|
+
|
|
67
|
+
const pageView = tracked.find((t) => t.eventType === "page_view");
|
|
68
|
+
expect(pageView?.data.eventId).toBe("evt-1");
|
|
69
|
+
expect(metaPosts()).toEqual([
|
|
70
|
+
{
|
|
71
|
+
subdomain: "artist",
|
|
72
|
+
pixel: "profile",
|
|
73
|
+
eventName: "PageView",
|
|
74
|
+
eventId: "evt-1",
|
|
75
|
+
eventSourceUrl: "http://localhost:3000/first",
|
|
76
|
+
metaSignals: { pageUrl: "http://localhost:3000/first", marketingConsent: true },
|
|
77
|
+
},
|
|
78
|
+
]);
|
|
79
|
+
expect(fbq).toHaveBeenCalledWith("trackSingle", "999", "PageView", {}, { eventID: "evt-1" });
|
|
80
|
+
// One first-party page view, not a second one when consent arrived.
|
|
81
|
+
expect(tracked.filter((t) => t.eventType === "page_view")).toHaveLength(1);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("sends a fresh pair on an SPA navigation", async () => {
|
|
85
|
+
saveCookieConsent(GRANT_ALL);
|
|
86
|
+
render(<ForgeAnalytics trackClicks={false} firstPartyMode="cookieless" />);
|
|
87
|
+
await flush();
|
|
88
|
+
act(() => window.history.pushState({}, "", "/second"));
|
|
89
|
+
await flush();
|
|
90
|
+
|
|
91
|
+
expect(metaPosts().map((b) => [b.eventId, b.eventSourceUrl])).toEqual([
|
|
92
|
+
["evt-1", "http://localhost:3000/first"],
|
|
93
|
+
["evt-2", "http://localhost:3000/second"],
|
|
94
|
+
]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("sends no server copy where a campaign pixel took the page over", async () => {
|
|
98
|
+
saveCookieConsent(GRANT_ALL);
|
|
99
|
+
suppressProfilePixel("/first");
|
|
100
|
+
render(<ForgeAnalytics trackClicks={false} firstPartyMode="cookieless" />);
|
|
101
|
+
await flush();
|
|
102
|
+
unsuppressProfilePixel("/first");
|
|
103
|
+
expect(metaPosts()).toEqual([]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("sends nothing to Meta when marketing was declined", async () => {
|
|
107
|
+
saveCookieConsent(DENY_ALL);
|
|
108
|
+
render(<ForgeAnalytics trackClicks={false} firstPartyMode="cookieless" />);
|
|
109
|
+
await flush();
|
|
110
|
+
expect(metaPosts()).toEqual([]);
|
|
111
|
+
expect(tracked.filter((t) => t.eventType === "page_view")).toHaveLength(1);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("sends a plain click first-party only", async () => {
|
|
115
|
+
saveCookieConsent(GRANT_ALL);
|
|
116
|
+
const { container } = render(
|
|
117
|
+
<>
|
|
118
|
+
<ForgeAnalytics firstPartyMode="cookieless" />
|
|
119
|
+
<button type="button">Buy</button>
|
|
120
|
+
</>,
|
|
121
|
+
);
|
|
122
|
+
await flush();
|
|
123
|
+
const before = metaPosts().length;
|
|
124
|
+
act(() => container.querySelector("button")!.click());
|
|
125
|
+
await flush();
|
|
126
|
+
expect(tracked.some((t) => t.eventType === "click")).toBe(true);
|
|
127
|
+
expect(metaPosts()).toHaveLength(before);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
3
|
+
import { act, renderHook } from "@testing-library/react";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
import { useMetaInitiateCheckout, useMetaPurchase, useMetaViewContent } from "../useMetaEvents";
|
|
6
|
+
import { resetMetaPolicy, setMetaPolicy } from "../../../utils/metaEvents";
|
|
7
|
+
import { saveCookieConsent, GRANT_ALL } from "../../../utils/cookieConsent";
|
|
8
|
+
import { CartProvider, useCart, type CartItem, type TicketCartItem } from "../../../contexts/CartContext";
|
|
9
|
+
import { ProductDeliveryType } from "../../../types/models";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The commerce funnel's Meta events fire once per act: a re-render, a cart
|
|
13
|
+
* that refreshes, or a confirmation page reloaded in the same tab must not
|
|
14
|
+
* count a second ViewContent, InitiateCheckout or Purchase.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
let fetchSpy: ReturnType<typeof vi.fn>;
|
|
18
|
+
let fbq: ReturnType<typeof vi.fn>;
|
|
19
|
+
|
|
20
|
+
const posted = () => fetchSpy.mock.calls.map((c) => JSON.parse((c[1] as RequestInit).body as string));
|
|
21
|
+
const pixel = () => fbq.mock.calls.filter((c) => c[0] === "trackSingle");
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
resetMetaPolicy();
|
|
25
|
+
window.localStorage.clear();
|
|
26
|
+
window.sessionStorage.clear();
|
|
27
|
+
fetchSpy = vi.fn().mockResolvedValue({ ok: true });
|
|
28
|
+
vi.stubGlobal("fetch", fetchSpy);
|
|
29
|
+
fbq = vi.fn();
|
|
30
|
+
(window as unknown as { fbq: unknown }).fbq = fbq;
|
|
31
|
+
saveCookieConsent(GRANT_ALL);
|
|
32
|
+
setMetaPolicy({
|
|
33
|
+
pixelId: "777",
|
|
34
|
+
enabled: true,
|
|
35
|
+
requireConsent: true,
|
|
36
|
+
apiUrl: "https://api.test",
|
|
37
|
+
subdomain: "artist",
|
|
38
|
+
currency: "GBP",
|
|
39
|
+
resolved: true,
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
vi.unstubAllGlobals();
|
|
45
|
+
resetMetaPolicy();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("InitiateCheckout", () => {
|
|
49
|
+
const line = { productId: "p1", quantity: 2 };
|
|
50
|
+
|
|
51
|
+
it("fires once per mount, after the cart is ready and not empty", () => {
|
|
52
|
+
const { rerender } = renderHook((props: Parameters<typeof useMetaInitiateCheckout>[0]) => useMetaInitiateCheckout(props), {
|
|
53
|
+
initialProps: { ready: false, cartItems: [], ticketItems: [], subTotal: 0 } as Parameters<typeof useMetaInitiateCheckout>[0],
|
|
54
|
+
});
|
|
55
|
+
expect(posted()).toEqual([]);
|
|
56
|
+
|
|
57
|
+
rerender({ ready: true, cartItems: [line], ticketItems: [{ eventId: "e1", tickets: { t1: 3 } }], subTotal: 80 });
|
|
58
|
+
rerender({ ready: true, cartItems: [line], ticketItems: [], subTotal: 50 });
|
|
59
|
+
|
|
60
|
+
expect(posted()).toHaveLength(1);
|
|
61
|
+
expect(posted()[0]).toMatchObject({
|
|
62
|
+
pixel: "profile",
|
|
63
|
+
eventName: "InitiateCheckout",
|
|
64
|
+
customData: { value: 80, currency: "GBP", num_items: 5, content_ids: ["p1", "e1"], content_type: "product" },
|
|
65
|
+
});
|
|
66
|
+
expect(pixel()[0][4]).toEqual({ eventID: posted()[0].eventId });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("does not fire for an empty cart", () => {
|
|
70
|
+
renderHook(() => useMetaInitiateCheckout({ ready: true, cartItems: [], ticketItems: [], subTotal: 0 }));
|
|
71
|
+
expect(posted()).toEqual([]);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("Purchase", () => {
|
|
76
|
+
it("fires once with the record id, and not again after a reload", () => {
|
|
77
|
+
const props = { recordId: "order-9", paid: true, value: 30, currency: "usd" };
|
|
78
|
+
const first = renderHook(() => useMetaPurchase(props));
|
|
79
|
+
first.rerender();
|
|
80
|
+
first.unmount();
|
|
81
|
+
// A reload: a fresh mount in the same tab.
|
|
82
|
+
renderHook(() => useMetaPurchase(props));
|
|
83
|
+
|
|
84
|
+
expect(pixel()).toEqual([["trackSingle", "777", "Purchase", { value: 30, currency: "USD" }, { eventID: "order-9" }]]);
|
|
85
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("waits for the record to be paid", () => {
|
|
89
|
+
const { rerender } = renderHook((p: { paid: boolean }) => useMetaPurchase({ recordId: "order-10", value: 30, ...p }), {
|
|
90
|
+
initialProps: { paid: false },
|
|
91
|
+
});
|
|
92
|
+
expect(pixel()).toEqual([]);
|
|
93
|
+
rerender({ paid: true });
|
|
94
|
+
expect(pixel()).toHaveLength(1);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("ViewContent", () => {
|
|
99
|
+
it("fires once per entity and again for a different one", () => {
|
|
100
|
+
const { rerender } = renderHook((e: { id: string; name: string; value: number }) => useMetaViewContent(e), {
|
|
101
|
+
initialProps: { id: "c1", name: "Course", value: 99 },
|
|
102
|
+
});
|
|
103
|
+
rerender({ id: "c1", name: "Course", value: 99 });
|
|
104
|
+
rerender({ id: "c2", name: "Other", value: 10 });
|
|
105
|
+
|
|
106
|
+
expect(posted().map((b) => [b.eventName, b.customData])).toEqual([
|
|
107
|
+
["ViewContent", { content_ids: ["c1"], content_name: "Course", content_type: "product", value: 99, currency: "GBP" }],
|
|
108
|
+
["ViewContent", { content_ids: ["c2"], content_name: "Other", content_type: "product", value: 10, currency: "GBP" }],
|
|
109
|
+
]);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("AddToCart", () => {
|
|
114
|
+
const wrapper = ({ children }: { children: ReactNode }) => <CartProvider>{children}</CartProvider>;
|
|
115
|
+
const item: CartItem = {
|
|
116
|
+
productId: "prod-1",
|
|
117
|
+
productVariantId: "var-1",
|
|
118
|
+
title: "Tour Tee",
|
|
119
|
+
price: 25,
|
|
120
|
+
isGift: false,
|
|
121
|
+
canIncreaseQuantity: true,
|
|
122
|
+
quantity: 2,
|
|
123
|
+
payWhatYouWant: false,
|
|
124
|
+
deliveryType: ProductDeliveryType.Physical,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
it("fires for a new line and not for a duplicate", () => {
|
|
128
|
+
const { result } = renderHook(() => useCart(), { wrapper });
|
|
129
|
+
act(() => void result.current.addToCart(item));
|
|
130
|
+
act(() => void result.current.addToCart(item));
|
|
131
|
+
|
|
132
|
+
expect(posted().map((b) => [b.eventName, b.customData])).toEqual([
|
|
133
|
+
[
|
|
134
|
+
"AddToCart",
|
|
135
|
+
{ content_ids: ["prod-1"], content_name: "Tour Tee", content_type: "product", value: 50, num_items: 2, currency: "GBP" },
|
|
136
|
+
],
|
|
137
|
+
]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("fires for tickets only when the selection grows", () => {
|
|
141
|
+
const { result } = renderHook(() => useCart(), { wrapper });
|
|
142
|
+
const selection = (qty: number): TicketCartItem => ({
|
|
143
|
+
eventId: "event-1",
|
|
144
|
+
eventSlug: "tour",
|
|
145
|
+
eventTitle: "Spring Tour",
|
|
146
|
+
tickets: { ga: qty },
|
|
147
|
+
ticketMeta: { ga: { title: "GA", price: 15 } },
|
|
148
|
+
});
|
|
149
|
+
act(() => result.current.setTickets(selection(2)));
|
|
150
|
+
act(() => result.current.setTickets(selection(1)));
|
|
151
|
+
act(() => result.current.setTickets(selection(3)));
|
|
152
|
+
|
|
153
|
+
expect(posted().map((b) => [b.customData.num_items, b.customData.value])).toEqual([
|
|
154
|
+
[2, 30],
|
|
155
|
+
[2, 30],
|
|
156
|
+
]);
|
|
157
|
+
expect(posted()[0].customData.content_ids).toEqual(["event-1"]);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import { trackMeta, trackPurchase } from "../../utils/metaEvents";
|
|
3
|
+
|
|
4
|
+
export type MetaViewContentInput = {
|
|
5
|
+
id: string;
|
|
6
|
+
name?: string;
|
|
7
|
+
/** The lowest (or selected) price, major units. Ignored unless a finite number. */
|
|
8
|
+
value?: number | string | null;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Fire `ViewContent` once per loaded entity: a new id (navigating to another
|
|
13
|
+
* product) fires again, a re-render does not. Pass `null` while loading.
|
|
14
|
+
*/
|
|
15
|
+
export function useMetaViewContent(entity: MetaViewContentInput | null | undefined): void {
|
|
16
|
+
const firedFor = useRef<string | null>(null);
|
|
17
|
+
const id = entity?.id;
|
|
18
|
+
const name = entity?.name;
|
|
19
|
+
const value = entity?.value;
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (!id || firedFor.current === id) return;
|
|
22
|
+
firedFor.current = id;
|
|
23
|
+
const n = value === null || value === undefined || value === "" ? NaN : Number(value);
|
|
24
|
+
trackMeta("ViewContent", {
|
|
25
|
+
content_ids: [id],
|
|
26
|
+
content_name: name,
|
|
27
|
+
content_type: "product",
|
|
28
|
+
value: Number.isFinite(n) ? n : undefined,
|
|
29
|
+
});
|
|
30
|
+
}, [id, name, value]);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Fire `InitiateCheckout` once per mount, as soon as the saved cart has loaded
|
|
35
|
+
* and holds something. Value is the cart's own estimate, before shipping and
|
|
36
|
+
* coupons.
|
|
37
|
+
*/
|
|
38
|
+
export function useMetaInitiateCheckout(input: {
|
|
39
|
+
ready: boolean;
|
|
40
|
+
cartItems: { productId: string; quantity: number }[];
|
|
41
|
+
ticketItems: { eventId: string; tickets: Record<string, number> }[];
|
|
42
|
+
subTotal: number;
|
|
43
|
+
}): void {
|
|
44
|
+
const fired = useRef(false);
|
|
45
|
+
const { ready, cartItems, ticketItems, subTotal } = input;
|
|
46
|
+
const hasLines = cartItems.length > 0 || ticketItems.length > 0;
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (fired.current || !ready || !hasLines) return;
|
|
49
|
+
fired.current = true;
|
|
50
|
+
const numItems =
|
|
51
|
+
cartItems.reduce((sum, item) => sum + (item.quantity || 0), 0) +
|
|
52
|
+
ticketItems.reduce((sum, t) => sum + Object.values(t.tickets).reduce((a, b) => a + b, 0), 0);
|
|
53
|
+
trackMeta("InitiateCheckout", {
|
|
54
|
+
value: subTotal,
|
|
55
|
+
num_items: numItems,
|
|
56
|
+
content_ids: [...cartItems.map((item) => item.productId), ...ticketItems.map((t) => t.eventId)],
|
|
57
|
+
content_type: "product",
|
|
58
|
+
});
|
|
59
|
+
}, [ready, hasLines, cartItems, ticketItems, subTotal]);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Fire the browser copy of `Purchase` on a confirmation page once the record
|
|
64
|
+
* is paid. The id is the record id the server bridge used, so the two dedupe;
|
|
65
|
+
* a reload in the same tab does not fire it again.
|
|
66
|
+
*/
|
|
67
|
+
export function useMetaPurchase(input: {
|
|
68
|
+
recordId?: string | null;
|
|
69
|
+
paid: boolean;
|
|
70
|
+
value?: number | string | null;
|
|
71
|
+
currency?: string | null;
|
|
72
|
+
}): void {
|
|
73
|
+
const { recordId, paid, value, currency } = input;
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (!paid || !recordId) return;
|
|
76
|
+
trackPurchase({ recordId, value, currency });
|
|
77
|
+
}, [paid, recordId, value, currency]);
|
|
78
|
+
}
|
|
@@ -72,7 +72,7 @@ describe("parseMembershipGateError", () => {
|
|
|
72
72
|
});
|
|
73
73
|
});
|
|
74
74
|
|
|
75
|
-
it("matches on CODE, not on status
|
|
75
|
+
it("matches on CODE, not on status: a read refusal is 401/403, a checkout refusal 400", () => {
|
|
76
76
|
// Nothing here reads a status at all; the same payload parses whichever
|
|
77
77
|
// status carried it. A client switching on 401 would render this as a
|
|
78
78
|
// session timeout.
|
|
@@ -113,7 +113,7 @@ describe("joinTierNames", () => {
|
|
|
113
113
|
expect(joinTierNames(["Gold", "Silver", "Bronze"])).toBe("Gold, Silver or Bronze");
|
|
114
114
|
});
|
|
115
115
|
|
|
116
|
-
it("drops blanks
|
|
116
|
+
it("drops blanks: an unresolved tier must not become an empty slot", () => {
|
|
117
117
|
expect(joinTierNames(["", " ", "Gold"])).toBe("Gold");
|
|
118
118
|
expect(joinTierNames([])).toBe("");
|
|
119
119
|
});
|
|
@@ -160,18 +160,18 @@ describe("buildMembershipGateNotice", () => {
|
|
|
160
160
|
expect(notice!.body).toBe("This ticket is for Gold members. Already one? Sign in and it unlocks.");
|
|
161
161
|
});
|
|
162
162
|
|
|
163
|
-
it("offers the TIER to someone already signed in
|
|
163
|
+
it("offers the TIER to someone already signed in: never sign-in again", () => {
|
|
164
164
|
const notice = buildMembershipGateNotice({
|
|
165
165
|
gate: gate({ reason: "membership_required", requiredTierIds: ["gold", "plat"] }),
|
|
166
166
|
tierNames: ["Gold", "Platinum"],
|
|
167
167
|
itemLabel: "This course",
|
|
168
|
-
membershipPath: "/i/
|
|
168
|
+
membershipPath: "/i/communities/fan-club?tier=gold",
|
|
169
169
|
});
|
|
170
170
|
|
|
171
171
|
expect(notice).toMatchObject({
|
|
172
172
|
reason: "membership_required",
|
|
173
173
|
actionLabel: "View Gold or Platinum",
|
|
174
|
-
actionHref: "/i/
|
|
174
|
+
actionHref: "/i/communities/fan-club?tier=gold",
|
|
175
175
|
});
|
|
176
176
|
expect(notice!.body).toBe("This course is for Gold or Platinum members. Your current plan doesn't include it.");
|
|
177
177
|
// Telling a signed-in member to sign in is a loop with no exit.
|
|
@@ -214,7 +214,7 @@ describe("buildMembershipGateNotice", () => {
|
|
|
214
214
|
});
|
|
215
215
|
|
|
216
216
|
it("uses default paths when the host names none", () => {
|
|
217
|
-
expect(buildMembershipGateNotice({ gate: gate() })!.actionHref).toBe("/
|
|
217
|
+
expect(buildMembershipGateNotice({ gate: gate() })!.actionHref).toBe("/i/communities");
|
|
218
218
|
expect(buildMembershipGateNotice({ gate: gate({ reason: "sign_in_required" }) })!.actionHref).toBe("/login");
|
|
219
219
|
});
|
|
220
220
|
});
|
|
@@ -8,22 +8,22 @@ import type { MembershipGateReason, PublicMembershipGate } from "../../types/mod
|
|
|
8
8
|
*
|
|
9
9
|
* The gate shipped ENFORCED and UNANNOUNCED. A logged-out buyer saw an ordinary
|
|
10
10
|
* ticket, chose a seat, typed their name and their email twice, and was refused
|
|
11
|
-
* on the last screen with a `MEMBERSHIP_GATE` 400
|
|
11
|
+
* on the last screen with a `MEMBERSHIP_GATE` 400: the restriction was
|
|
12
12
|
* discoverable only by failing it. And the "members save X, sign in" funnel the
|
|
13
13
|
* feature was justified by existed on no surface at all, because nothing drew a
|
|
14
14
|
* way OUT of the refusal.
|
|
15
15
|
*
|
|
16
16
|
* ## Why it is pure, and why it lives in `format/`
|
|
17
17
|
*
|
|
18
|
-
* There are two rendering stacks
|
|
19
|
-
* websites build against
|
|
18
|
+
* There are two rendering stacks (`apps/client` and the Forge SDK that code
|
|
19
|
+
* websites build against), and a members-only badge that says one thing on an
|
|
20
20
|
* artist's PWA and another on their website is the same bug twice. So the words
|
|
21
21
|
* and the resolving ACTION are decided once, here, with no React and no client;
|
|
22
22
|
* each stack owns only how it looks. `apps/client` imports this from
|
|
23
23
|
* `@tribe-nest/forge/ui`, exactly as it already does for the PWYW arithmetic.
|
|
24
24
|
*
|
|
25
25
|
* Pure and total also means the whole mapping is testable without a DOM or a
|
|
26
|
-
* session
|
|
26
|
+
* session, and getting the two reasons backwards is the failure mode that
|
|
27
27
|
* matters most: telling someone who is already signed in to "sign in" is a loop
|
|
28
28
|
* with no exit, and telling a logged-out visitor to "upgrade" asks them to buy a
|
|
29
29
|
* membership they may already hold.
|
|
@@ -94,7 +94,7 @@ export function parseMembershipGateError(error: unknown): MembershipGateRefusal
|
|
|
94
94
|
/**
|
|
95
95
|
* Is this thing refused to the caller looking at it?
|
|
96
96
|
*
|
|
97
|
-
* `null` means "nothing gates it"
|
|
97
|
+
* `null` means "nothing gates it", which is also what every read returns while
|
|
98
98
|
* the `membership_entitlement_gates` switch is off. Treating an absent gate as
|
|
99
99
|
* "gated and permitted" would badge every item on every site the day the field
|
|
100
100
|
* shipped, so absent must read as false here and nowhere else.
|
|
@@ -110,7 +110,7 @@ export type MembershipGateNotice = {
|
|
|
110
110
|
badge: string;
|
|
111
111
|
title: string;
|
|
112
112
|
body: string;
|
|
113
|
-
/** Primary CTA label
|
|
113
|
+
/** Primary CTA label: the way OUT of the refusal, which is the whole point. */
|
|
114
114
|
actionLabel: string;
|
|
115
115
|
/**
|
|
116
116
|
* Where the CTA goes. `null` only when the caller passed no path to send them
|
|
@@ -120,12 +120,16 @@ export type MembershipGateNotice = {
|
|
|
120
120
|
actionHref: string | null;
|
|
121
121
|
};
|
|
122
122
|
|
|
123
|
-
/** Default sign-in path. Overridden per site
|
|
123
|
+
/** Default sign-in path. Overridden per site: code websites mount theirs under `/i`. */
|
|
124
124
|
export const MEMBERSHIP_GATE_LOGIN_PATH = "/login";
|
|
125
|
-
/**
|
|
126
|
-
|
|
125
|
+
/**
|
|
126
|
+
* Default "become a member" path when the tier's community is not known: the
|
|
127
|
+
* communities list. `useMembershipGateNotice` sends the buyer to the About page
|
|
128
|
+
* of the community the required tier belongs to, with that tier preselected.
|
|
129
|
+
*/
|
|
130
|
+
export const MEMBERSHIP_GATE_MEMBERSHIP_PATH = "/i/communities";
|
|
127
131
|
|
|
128
|
-
/** "Gold", "Gold or Platinum", "Gold, Silver or Bronze"
|
|
132
|
+
/** "Gold", "Gold or Platinum", "Gold, Silver or Bronze". ANY one unlocks, hence "or". */
|
|
129
133
|
export function joinTierNames(names: string[]): string {
|
|
130
134
|
const clean = names.map((name) => name.trim()).filter(Boolean);
|
|
131
135
|
if (clean.length === 0) return "";
|
|
@@ -133,7 +137,7 @@ export function joinTierNames(names: string[]): string {
|
|
|
133
137
|
return `${clean.slice(0, -1).join(", ")} or ${clean[clean.length - 1]}`;
|
|
134
138
|
}
|
|
135
139
|
|
|
136
|
-
/** `path?redirect=<here
|
|
140
|
+
/** `path?redirect=<here>`: a sign-in that lands back on the thing they wanted. */
|
|
137
141
|
export function membershipSignInHref(loginPath: string, currentPath: string): string {
|
|
138
142
|
const target = safeRedirectPath(currentPath, "");
|
|
139
143
|
return target ? `${loginPath}?redirect=${encodeURIComponent(target)}` : loginPath;
|
|
@@ -142,7 +146,7 @@ export function membershipSignInHref(loginPath: string, currentPath: string): st
|
|
|
142
146
|
export interface BuildMembershipGateNoticeInput {
|
|
143
147
|
/** The announced gate from a listing/detail read. */
|
|
144
148
|
gate?: PublicMembershipGate | null;
|
|
145
|
-
/** A `MEMBERSHIP_GATE` refusal from a failed purchase. Wins over `gate
|
|
149
|
+
/** A `MEMBERSHIP_GATE` refusal from a failed purchase. Wins over `gate`: it is the newer fact. */
|
|
146
150
|
refusal?: MembershipGateRefusal | null;
|
|
147
151
|
/**
|
|
148
152
|
* Names for `requiredTierIds`, already resolved by the caller (both stacks
|
|
@@ -150,7 +154,7 @@ export interface BuildMembershipGateNoticeInput {
|
|
|
150
154
|
* 8f3c-…" is not a sentence anyone can act on.
|
|
151
155
|
*/
|
|
152
156
|
tierNames?: string[];
|
|
153
|
-
/** What the thing IS, in the buyer's words
|
|
157
|
+
/** What the thing IS, in the buyer's words: "This ticket", "This course". */
|
|
154
158
|
itemLabel?: string;
|
|
155
159
|
loginPath?: string;
|
|
156
160
|
membershipPath?: string;
|
|
@@ -185,7 +189,7 @@ export function buildMembershipGateNotice(
|
|
|
185
189
|
// The API's own sentence is deliberately NOT used for this half. It is
|
|
186
190
|
// written for the refusal ("You must be a member to buy this"), and what
|
|
187
191
|
// an anonymous visitor needs first is the possibility that they already
|
|
188
|
-
// are one
|
|
192
|
+
// are one, which is the difference between a dead end and a funnel.
|
|
189
193
|
body: tiers
|
|
190
194
|
? `${item} is for ${tiers} members. Already one? Sign in and it unlocks.`
|
|
191
195
|
: `${item} is for members. Already one? Sign in and it unlocks.`,
|
|
@@ -201,7 +205,7 @@ export function buildMembershipGateNotice(
|
|
|
201
205
|
body: tiers
|
|
202
206
|
? `${item} is for ${tiers} members. Your current plan doesn't include it.`
|
|
203
207
|
: `${item} is for members. Your current plan doesn't include it.`,
|
|
204
|
-
// NOT "sign in"
|
|
208
|
+
// NOT "sign in": they already are, and offering it again is a loop with no
|
|
205
209
|
// exit. The only move that changes anything is joining the tier.
|
|
206
210
|
actionLabel: tiers ? `View ${tiers}` : "View membership",
|
|
207
211
|
actionHref: membershipPath,
|