@tribe-nest/forge 1.13.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 +42 -0
- package/src/client/createForgeClient.ts +54 -0
- package/src/content/ForgeContentProvider.tsx +118 -0
- package/src/content/ForgeEditBridge.tsx +134 -0
- package/src/content/editFlag.ts +23 -0
- package/src/content/index.ts +28 -0
- package/src/content/resolve.ts +67 -0
- package/src/content/types.ts +97 -0
- package/src/content/useField.ts +64 -0
- package/src/contexts/AppAuthContext.tsx +185 -0
- package/src/contexts/AudioPlayerContext.tsx +389 -0
- package/src/contexts/CartContext.tsx +131 -0
- package/src/contexts/PublicAuthContext.tsx +333 -0
- package/src/contexts/useAppAdminGuard.ts +56 -0
- package/src/css.d.ts +5 -0
- package/src/data/collectionParams.ts +57 -0
- package/src/data/queries/useAccount.ts +71 -0
- package/src/data/queries/useAccountSettings.ts +82 -0
- package/src/data/queries/useAnalytics.ts +86 -0
- package/src/data/queries/useAuthActions.ts +28 -0
- package/src/data/queries/useBlog.ts +103 -0
- package/src/data/queries/useBlogComments.ts +96 -0
- package/src/data/queries/useBroadcasts.ts +31 -0
- package/src/data/queries/useCertificates.ts +58 -0
- package/src/data/queries/useChat.ts +267 -0
- package/src/data/queries/useCoachingAvailability.ts +69 -0
- package/src/data/queries/useCoachingProducts.ts +67 -0
- package/src/data/queries/useCohortPage.ts +17 -0
- package/src/data/queries/useCollections.ts +211 -0
- package/src/data/queries/useCommunity.ts +794 -0
- package/src/data/queries/useCoupons.ts +20 -0
- package/src/data/queries/useCourseAccess.ts +50 -0
- package/src/data/queries/useCourses.ts +91 -0
- package/src/data/queries/useCurrencies.ts +33 -0
- package/src/data/queries/useDocuments.ts +208 -0
- package/src/data/queries/useDonations.ts +29 -0
- package/src/data/queries/useEngagement.ts +115 -0
- package/src/data/queries/useEvents.ts +69 -0
- package/src/data/queries/useFinalize.ts +109 -0
- package/src/data/queries/useForms.ts +31 -0
- package/src/data/queries/useInvoice.ts +65 -0
- package/src/data/queries/useLeadMagnet.ts +96 -0
- package/src/data/queries/useMembership.ts +20 -0
- package/src/data/queries/useNotifications.ts +267 -0
- package/src/data/queries/useOffers.ts +56 -0
- package/src/data/queries/useOrders.ts +71 -0
- package/src/data/queries/usePWA.ts +101 -0
- package/src/data/queries/usePageActions.ts +36 -0
- package/src/data/queries/usePaymentFlow.ts +80 -0
- package/src/data/queries/usePaymentLink.ts +47 -0
- package/src/data/queries/usePodcast.ts +85 -0
- package/src/data/queries/usePostCollections.ts +48 -0
- package/src/data/queries/usePosts.ts +94 -0
- package/src/data/queries/useProducts.ts +108 -0
- package/src/data/queries/useReplay.ts +19 -0
- package/src/data/queries/useReviews.ts +122 -0
- package/src/data/queries/useShipping.ts +109 -0
- package/src/data/queries/useSubscriptions.ts +66 -0
- package/src/data/queries/useWebsite.ts +128 -0
- package/src/data/queries/useWebsiteAgent.ts +100 -0
- package/src/index.ts +98 -0
- package/src/provider/ForgeAppProvider.tsx +149 -0
- package/src/provider/ForgeProvider.tsx +91 -0
- package/src/provider/SiteConfigProvider.tsx +27 -0
- package/src/server/_tests/buildPwaHead.spec.ts +182 -0
- package/src/server/_tests/buildWebManifest.spec.ts +313 -0
- package/src/server/_tests/forgeServiceWorkerScript.spec.ts +251 -0
- package/src/server/index.ts +260 -0
- package/src/server/jobs.ts +104 -0
- package/src/server/pwa.ts +332 -0
- package/src/types/index.ts +74 -0
- package/src/types/models.ts +1313 -0
- package/src/ui/analytics/ForgeAnalytics.tsx +208 -0
- package/src/ui/analytics/PageMetaPixel.tsx +92 -0
- package/src/ui/content/Editable.tsx +90 -0
- package/src/ui/format/PriceDisplay.tsx +142 -0
- package/src/ui/format/useFormatCurrency.ts +92 -0
- package/src/ui/headless/agent/useAiAgent.ts +106 -0
- package/src/ui/headless/auth/useLoginFlow.ts +72 -0
- package/src/ui/headless/auth/useSignupForm.ts +85 -0
- package/src/ui/headless/broadcast/useBroadcastWatch.ts +59 -0
- package/src/ui/headless/chat/attachmentKind.ts +27 -0
- package/src/ui/headless/chat/useChatAttachmentUpload.ts +132 -0
- package/src/ui/headless/chat/useChatRoom.ts +223 -0
- package/src/ui/headless/chat/useMessageThread.ts +60 -0
- package/src/ui/headless/checkout/useCheckout.ts +438 -0
- package/src/ui/headless/coaching/useCoachingBooking.ts +223 -0
- package/src/ui/headless/community/index.ts +20 -0
- package/src/ui/headless/community/lexical.ts +131 -0
- package/src/ui/headless/community/replyTree.ts +51 -0
- package/src/ui/headless/community/richLexical.ts +314 -0
- package/src/ui/headless/community/useCommunityAttachmentUpload.ts +137 -0
- package/src/ui/headless/community/useCommunitySpaceFeed.ts +122 -0
- package/src/ui/headless/community/useCommunityThread.ts +95 -0
- package/src/ui/headless/community/useMentionComposer.ts +105 -0
- package/src/ui/headless/community/useRichMentionComposer.ts +193 -0
- package/src/ui/headless/consent/useCookieConsent.ts +81 -0
- package/src/ui/headless/course/useCourseCheckout.ts +99 -0
- package/src/ui/headless/dialog.tsx +14 -0
- package/src/ui/headless/document/index.ts +7 -0
- package/src/ui/headless/document/useContractSignature.ts +68 -0
- package/src/ui/headless/document/useDocumentSigningFlow.ts +92 -0
- package/src/ui/headless/document/useOtpCodeInput.ts +86 -0
- package/src/ui/headless/document/useSignaturePad.ts +180 -0
- package/src/ui/headless/donation/Donation.tsx +115 -0
- package/src/ui/headless/donation/DonationContext.tsx +223 -0
- package/src/ui/headless/donation/fees.ts +15 -0
- package/src/ui/headless/donation/index.ts +10 -0
- package/src/ui/headless/engagement/usePostEngagement.ts +119 -0
- package/src/ui/headless/event/useEventCheckout.ts +141 -0
- package/src/ui/headless/forms/useContactForm.ts +58 -0
- package/src/ui/headless/forms/useEmailListForm.ts +75 -0
- package/src/ui/headless/forms/useSectionedForm.ts +256 -0
- package/src/ui/headless/index.ts +60 -0
- package/src/ui/headless/invoice/useInvoicePayment.ts +48 -0
- package/src/ui/headless/memberHome/index.ts +7 -0
- package/src/ui/headless/memberHome/useMemberHomeNav.ts +82 -0
- package/src/ui/headless/membership/MembershipGate.tsx +39 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +121 -0
- package/src/ui/headless/offer/Offer.tsx +88 -0
- package/src/ui/headless/offer/OfferContext.tsx +162 -0
- package/src/ui/headless/offer/index.ts +8 -0
- package/src/ui/headless/paymentLink/usePaymentLinkPayment.ts +46 -0
- package/src/ui/headless/podcast/usePodcastPlayer.ts +96 -0
- package/src/ui/headless/posts/index.ts +13 -0
- package/src/ui/headless/posts/upsell.ts +41 -0
- package/src/ui/headless/posts/useMemberPostsFeed.ts +110 -0
- package/src/ui/headless/posts/usePostCollectionDetail.ts +56 -0
- package/src/ui/headless/posts/usePostCollectionsList.ts +90 -0
- package/src/ui/headless/posts/usePostItem.ts +81 -0
- package/src/ui/headless/reviews/index.ts +6 -0
- package/src/ui/headless/reviews/useHelpfulVote.ts +45 -0
- package/src/ui/headless/reviews/useReviewForm.ts +108 -0
- package/src/ui/headless/reviews/useReviewsList.ts +79 -0
- package/src/ui/headless/work/index.ts +25 -0
- package/src/ui/headless/work/useWorkPortal.ts +349 -0
- package/src/ui/index.ts +144 -0
- package/src/ui/payment/ForgePaymentProvider.tsx +45 -0
- package/src/ui/payment/ForgeStripePayment.tsx +171 -0
- package/src/ui/shell/TribeNestApp.tsx +121 -0
- package/src/ui/shell/shellGating.spec.ts +28 -0
- package/src/ui/shell/shellGating.ts +11 -0
- package/src/ui/styled/AccountDashboard.tsx +575 -0
- package/src/ui/styled/AiAgentWidget.tsx +152 -0
- package/src/ui/styled/AudioPlayer.tsx +123 -0
- package/src/ui/styled/BlogCategory.tsx +79 -0
- package/src/ui/styled/BlogComments.tsx +236 -0
- package/src/ui/styled/BlogList.tsx +101 -0
- package/src/ui/styled/BlogPost.tsx +160 -0
- package/src/ui/styled/BlogPostFooter.tsx +33 -0
- package/src/ui/styled/BotProtection.tsx +101 -0
- package/src/ui/styled/Button.tsx +88 -0
- package/src/ui/styled/Cart.tsx +276 -0
- package/src/ui/styled/ChatRoom.tsx +252 -0
- package/src/ui/styled/Checkout.tsx +988 -0
- package/src/ui/styled/CheckoutConfirmation.tsx +452 -0
- package/src/ui/styled/CoachingBooking.tsx +605 -0
- package/src/ui/styled/CoachingConfirmation.tsx +151 -0
- package/src/ui/styled/CoachingDetail.tsx +99 -0
- package/src/ui/styled/CohortPage.tsx +82 -0
- package/src/ui/styled/ConfirmSubscription.tsx +134 -0
- package/src/ui/styled/Confirmation.tsx +171 -0
- package/src/ui/styled/ContactForm.tsx +81 -0
- package/src/ui/styled/CookieConsent.tsx +239 -0
- package/src/ui/styled/CourseAccess.tsx +169 -0
- package/src/ui/styled/CourseCheckout.tsx +425 -0
- package/src/ui/styled/CourseConfirmation.tsx +191 -0
- package/src/ui/styled/CourseDetail.tsx +140 -0
- package/src/ui/styled/CoursesGrid.tsx +85 -0
- package/src/ui/styled/CurrencySwitcher.tsx +51 -0
- package/src/ui/styled/DonationButton.tsx +247 -0
- package/src/ui/styled/DonationPage.tsx +29 -0
- package/src/ui/styled/EmailListForm.tsx +88 -0
- package/src/ui/styled/EventConfirmation.tsx +228 -0
- package/src/ui/styled/EventCountdown.tsx +98 -0
- package/src/ui/styled/EventDetail.tsx +141 -0
- package/src/ui/styled/EventTickets.tsx +548 -0
- package/src/ui/styled/EventsList.tsx +83 -0
- package/src/ui/styled/ForgotPasswordForm.tsx +105 -0
- package/src/ui/styled/FormRenderer.tsx +249 -0
- package/src/ui/styled/InstallBanner.tsx +114 -0
- package/src/ui/styled/InvoiceConfirmation.tsx +50 -0
- package/src/ui/styled/InvoicePayment.tsx +141 -0
- package/src/ui/styled/LeadMagnet.tsx +93 -0
- package/src/ui/styled/Loading.tsx +56 -0
- package/src/ui/styled/LoginForm.tsx +134 -0
- package/src/ui/styled/Markdown.tsx +103 -0
- package/src/ui/styled/MembershipCheckout.tsx +197 -0
- package/src/ui/styled/MembershipTiers.tsx +115 -0
- package/src/ui/styled/OfferButton.tsx +181 -0
- package/src/ui/styled/PageActions.tsx +124 -0
- package/src/ui/styled/PaymentLinkConfirmation.tsx +50 -0
- package/src/ui/styled/PaymentLinkPayment.tsx +85 -0
- package/src/ui/styled/Paywall.tsx +54 -0
- package/src/ui/styled/PlayButton.tsx +48 -0
- package/src/ui/styled/PodcastEpisode.tsx +137 -0
- package/src/ui/styled/PodcastList.tsx +58 -0
- package/src/ui/styled/PodcastShow.tsx +164 -0
- package/src/ui/styled/PostsFeed.tsx +63 -0
- package/src/ui/styled/ProductDetail.tsx +616 -0
- package/src/ui/styled/ProductGrid.tsx +82 -0
- package/src/ui/styled/PushOptIn.tsx +65 -0
- package/src/ui/styled/PwaRegistration.tsx +58 -0
- package/src/ui/styled/ReactionBar.tsx +93 -0
- package/src/ui/styled/ReplayList.tsx +134 -0
- package/src/ui/styled/ResetPasswordForm.tsx +127 -0
- package/src/ui/styled/ReviewForm.tsx +292 -0
- package/src/ui/styled/ReviewsSection.tsx +260 -0
- package/src/ui/styled/SectionedFormRenderer.tsx +146 -0
- package/src/ui/styled/SignupForm.tsx +109 -0
- package/src/ui/styled/UserMenu.tsx +166 -0
- package/src/ui/styled/chat/ChatAttachments.tsx +172 -0
- package/src/ui/styled/chat/ChatComposer.tsx +153 -0
- package/src/ui/styled/chat/ChatThreadPanel.tsx +94 -0
- package/src/ui/styled/chat/MessageBubble.tsx +66 -0
- package/src/ui/styled/chat/NewDmDialog.tsx +78 -0
- package/src/ui/styled/community/CommunityComposer.tsx +358 -0
- package/src/ui/styled/community/CommunityFeed.tsx +483 -0
- package/src/ui/styled/community/CommunityLeaderboard.tsx +109 -0
- package/src/ui/styled/community/CommunityMediaGallery.tsx +116 -0
- package/src/ui/styled/community/CommunityMemberProfile.tsx +285 -0
- package/src/ui/styled/community/CommunityNotifications.tsx +222 -0
- package/src/ui/styled/community/CommunityPostCard.tsx +165 -0
- package/src/ui/styled/community/CommunityPostDetail.tsx +337 -0
- package/src/ui/styled/community/CommunityReactionBar.tsx +107 -0
- package/src/ui/styled/community/CommunityRichText.tsx +157 -0
- package/src/ui/styled/community/CommunitySpaces.tsx +174 -0
- package/src/ui/styled/community/MemberAvatar.tsx +74 -0
- package/src/ui/styled/community/index.ts +23 -0
- package/src/ui/styled/community/util.ts +15 -0
- package/src/ui/styled/members/MemberHomeLayout.tsx +98 -0
- package/src/ui/styled/members/MemberPostCard.tsx +392 -0
- package/src/ui/styled/members/MemberPostsFeed.tsx +219 -0
- package/src/ui/styled/members/PostCollectionDetail.tsx +227 -0
- package/src/ui/styled/members/PostCollectionsGrid.tsx +287 -0
- package/src/ui/styled/members/index.ts +7 -0
- package/src/ui/styled/rich-text.css +150 -0
- package/src/ui/styled/work/WorkInviteAccept.tsx +222 -0
- package/src/ui/styled/work/WorkPortalProjects.tsx +163 -0
- package/src/ui/styled/work/WorkProjectInvoices.tsx +113 -0
- package/src/ui/styled/work/WorkProjectReport.tsx +66 -0
- package/src/ui/styled/work/WorkReportBody.tsx +327 -0
- package/src/ui/styled/work/WorkTaskAttachments.tsx +67 -0
- package/src/ui/styled/work/WorkTaskComments.tsx +151 -0
- package/src/ui/styled/work/WorkTaskDetail.tsx +92 -0
- package/src/ui/styled/work/WorkTokenReport.tsx +49 -0
- package/src/ui/styled/work/index.ts +10 -0
- package/src/ui/theme/ForgeThemeProvider.tsx +121 -0
- package/src/ui/theme/contrast.ts +22 -0
- package/src/utils/analyticsBus.ts +31 -0
- package/src/utils/attribution.ts +68 -0
- package/src/utils/cookieConsent.ts +83 -0
- package/src/utils/landing.ts +153 -0
- package/src/utils/metaPixel.ts +122 -0
- package/src/utils/structuredData.ts +126 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { createContext, useContext, type ReactNode } from "react";
|
|
2
|
+
import { readableTextOn } from "./contrast";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The theme contract the styled (Tier-2) blocks read. Forge owns this so
|
|
6
|
+
* `forge/ui` never depends on the Craft editor's `useEditorContext` (which lives
|
|
7
|
+
* in frontend-shared → would be a cycle). The Craft editor feeds its
|
|
8
|
+
* `themeSettings` in; a headless developer sets their own.
|
|
9
|
+
*
|
|
10
|
+
* Only `text` / `background` / `primary` / `cornerRadius` are required. Every
|
|
11
|
+
* other token is optional and derives a sensible fallback (see
|
|
12
|
+
* {@link resolveThemeTokens}), so richer branding is opt-in and never breaks an
|
|
13
|
+
* existing theme.
|
|
14
|
+
*/
|
|
15
|
+
export type ForgeTheme = {
|
|
16
|
+
colors: {
|
|
17
|
+
text: string;
|
|
18
|
+
background: string;
|
|
19
|
+
primary: string;
|
|
20
|
+
/** Text/icon color to place ON the primary (buttons etc.). The craft theme's
|
|
21
|
+
* `textPrimary`. When unset, falls back to `readableTextOn(primary)`. */
|
|
22
|
+
textPrimary?: string;
|
|
23
|
+
/** Secondary/accent color (secondary buttons, highlights). Falls back to `primary`. */
|
|
24
|
+
secondary?: string;
|
|
25
|
+
/** Card/section surface, distinct from the page `background`. Falls back to `background`. */
|
|
26
|
+
surface?: string;
|
|
27
|
+
/** Hairline/border color. Falls back to a translucent `primary`. */
|
|
28
|
+
border?: string;
|
|
29
|
+
/** Muted/subtle text (captions, meta). Falls back to a translucent `text`. */
|
|
30
|
+
muted?: string;
|
|
31
|
+
};
|
|
32
|
+
cornerRadius: number;
|
|
33
|
+
/** Body font family. */
|
|
34
|
+
fontFamily?: string;
|
|
35
|
+
/** Display/heading font family. Falls back to `fontFamily`. */
|
|
36
|
+
headingFontFamily?: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const defaultForgeTheme: ForgeTheme = {
|
|
40
|
+
colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" },
|
|
41
|
+
cornerRadius: 8,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Append an alpha (0..1) to a 6-digit hex color; passes non-hex through unchanged. */
|
|
45
|
+
const withAlpha = (hex: string, a: number) =>
|
|
46
|
+
/^#[0-9a-fA-F]{6}$/.test(hex) ? hex + Math.round(a * 255).toString(16).padStart(2, "0") : hex;
|
|
47
|
+
|
|
48
|
+
export type ResolvedThemeTokens = {
|
|
49
|
+
text: string;
|
|
50
|
+
background: string;
|
|
51
|
+
primary: string;
|
|
52
|
+
textPrimary: string;
|
|
53
|
+
secondary: string;
|
|
54
|
+
surface: string;
|
|
55
|
+
border: string;
|
|
56
|
+
muted: string;
|
|
57
|
+
cornerRadius: number;
|
|
58
|
+
fontFamily?: string;
|
|
59
|
+
headingFontFamily?: string;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Resolve every theme token to a concrete value, deriving fallbacks for the
|
|
64
|
+
* optional ones. Blocks call this instead of hand-rolling `|| primary` / alpha
|
|
65
|
+
* math so the fallback rules stay in one place.
|
|
66
|
+
*/
|
|
67
|
+
export function resolveThemeTokens(t: ForgeTheme): ResolvedThemeTokens {
|
|
68
|
+
const c = t.colors;
|
|
69
|
+
return {
|
|
70
|
+
text: c.text,
|
|
71
|
+
background: c.background,
|
|
72
|
+
primary: c.primary,
|
|
73
|
+
textPrimary: c.textPrimary || readableTextOn(c.primary),
|
|
74
|
+
secondary: c.secondary || c.primary,
|
|
75
|
+
surface: c.surface || c.background,
|
|
76
|
+
border: c.border || withAlpha(c.primary, 0.14),
|
|
77
|
+
muted: c.muted || withAlpha(c.text, 0.6),
|
|
78
|
+
cornerRadius: t.cornerRadius,
|
|
79
|
+
fontFamily: t.fontFamily,
|
|
80
|
+
headingFontFamily: t.headingFontFamily || t.fontFamily,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const ForgeThemeContext = createContext<ForgeTheme | null>(null);
|
|
85
|
+
|
|
86
|
+
export function ForgeThemeProvider({ theme, children }: { theme: ForgeTheme; children: ReactNode }) {
|
|
87
|
+
const r = resolveThemeTokens(theme);
|
|
88
|
+
// Publish the theme as :root CSS variables too, so plain markup + Tailwind
|
|
89
|
+
// tokens (bg-primary, text-text, bg-background) resolve to the SAME values the
|
|
90
|
+
// styled blocks read from context — one theme, everything in sync, runtime-live.
|
|
91
|
+
const css =
|
|
92
|
+
":root{" +
|
|
93
|
+
`--forge-primary:${r.primary};` +
|
|
94
|
+
`--forge-secondary:${r.secondary};` +
|
|
95
|
+
`--forge-background:${r.background};` +
|
|
96
|
+
`--forge-surface:${r.surface};` +
|
|
97
|
+
`--forge-text:${r.text};` +
|
|
98
|
+
`--forge-text-primary:${r.textPrimary};` +
|
|
99
|
+
`--forge-muted:${r.muted};` +
|
|
100
|
+
`--forge-border:${r.border};` +
|
|
101
|
+
`--forge-radius:${r.cornerRadius}px;` +
|
|
102
|
+
(r.fontFamily ? `--forge-font:${r.fontFamily};` : "") +
|
|
103
|
+
(r.headingFontFamily ? `--forge-heading-font:${r.headingFontFamily};` : "") +
|
|
104
|
+
"}";
|
|
105
|
+
return (
|
|
106
|
+
<ForgeThemeContext.Provider value={theme}>
|
|
107
|
+
<style dangerouslySetInnerHTML={{ __html: css }} />
|
|
108
|
+
{children}
|
|
109
|
+
</ForgeThemeContext.Provider>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The active theme tokens (falls back to defaults outside a provider). */
|
|
114
|
+
export function useForgeTheme(): ForgeTheme {
|
|
115
|
+
return useContext(ForgeThemeContext) ?? defaultForgeTheme;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The active theme with all optional tokens resolved to concrete values. */
|
|
119
|
+
export function useThemeTokens(): ResolvedThemeTokens {
|
|
120
|
+
return resolveThemeTokens(useForgeTheme());
|
|
121
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Contrast helpers so themed surfaces never hardcode "#fff" on top of an
|
|
2
|
+
// arbitrary tenant color (a light primary made white text unreadable).
|
|
3
|
+
|
|
4
|
+
const parseHex = (hex: string): [number, number, number] | null => {
|
|
5
|
+
let h = hex.trim().replace(/^#/, "");
|
|
6
|
+
if (h.length === 3) h = h.split("").map((c) => c + c).join("");
|
|
7
|
+
if (h.length !== 6 || /[^0-9a-fA-F]/.test(h)) return null;
|
|
8
|
+
return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16)) as [number, number, number];
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Returns a readable text color ("#111111" or "#ffffff") to place ON `bg`,
|
|
13
|
+
* chosen by perceived brightness (YIQ). Falls back to white for non-hex input.
|
|
14
|
+
* Use for any text/icon sitting on a themed fill (buttons on the primary, etc.).
|
|
15
|
+
*/
|
|
16
|
+
export const readableTextOn = (bg: string): string => {
|
|
17
|
+
const rgb = parseHex(bg);
|
|
18
|
+
if (!rgb) return "#ffffff";
|
|
19
|
+
const [r, g, b] = rgb;
|
|
20
|
+
const yiq = (r * 299 + g * 587 + b * 114) / 1000;
|
|
21
|
+
return yiq >= 150 ? "#111111" : "#ffffff";
|
|
22
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// A tiny framework-agnostic bus so the global <ForgeAnalytics/> and any per-page
|
|
2
|
+
// <PageMetaPixel/> can coordinate without prop-drilling through the router:
|
|
3
|
+
//
|
|
4
|
+
// - `currentPageEventId` — the event id ForgeAnalytics generated for the current
|
|
5
|
+
// page view. A page pixel reuses it so its browser event and the server-side
|
|
6
|
+
// Custom Page CAPI event dedupe on Meta (they share one event_id).
|
|
7
|
+
// - suppressed paths — routes where a campaign pixel has taken over, so the
|
|
8
|
+
// global profile pixel skips its PageView there (see PageMetaPixel override).
|
|
9
|
+
|
|
10
|
+
let currentPageEventId: string | null = null;
|
|
11
|
+
const suppressedPaths = new Set<string>();
|
|
12
|
+
|
|
13
|
+
export function setCurrentPageEventId(id: string): void {
|
|
14
|
+
currentPageEventId = id;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function getCurrentPageEventId(): string | null {
|
|
18
|
+
return currentPageEventId;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function suppressProfilePixel(path: string): void {
|
|
22
|
+
suppressedPaths.add(path);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function unsuppressProfilePixel(path: string): void {
|
|
26
|
+
suppressedPaths.delete(path);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function isProfilePixelSuppressed(path: string): boolean {
|
|
30
|
+
return suppressedPaths.has(path);
|
|
31
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Local helper for the link-attribution flow. The redirect handler stamps
|
|
2
|
+
// `?tn_ref=<uuid>` onto the destination URL when a recipient clicks a
|
|
3
|
+
// tracked link in a social DM. The root effect captures it into
|
|
4
|
+
// localStorage; checkout reads it back and sends it with the order.
|
|
5
|
+
//
|
|
6
|
+
// 30-day TTL keeps stale refs from credit-stealing months later — covers
|
|
7
|
+
// the realistic window between "user saw the DM" and "user actually buys".
|
|
8
|
+
|
|
9
|
+
const STORAGE_KEY = "tn:attributionRef";
|
|
10
|
+
const TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
11
|
+
|
|
12
|
+
type StoredRef = { id: string; at: number };
|
|
13
|
+
|
|
14
|
+
function isStoredRef(value: unknown): value is StoredRef {
|
|
15
|
+
if (typeof value !== "object" || value === null) return false;
|
|
16
|
+
const v = value as Record<string, unknown>;
|
|
17
|
+
return typeof v.id === "string" && typeof v.at === "number";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function persistAttributionRef(id: string): void {
|
|
21
|
+
if (typeof window === "undefined") return;
|
|
22
|
+
try {
|
|
23
|
+
const payload: StoredRef = { id, at: Date.now() };
|
|
24
|
+
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
|
|
25
|
+
} catch {
|
|
26
|
+
// Quota / disabled storage — quietly drop. Click is still tracked
|
|
27
|
+
// server-side; only the order-attribution join is lost.
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function readAttributionRef(): string | null {
|
|
32
|
+
if (typeof window === "undefined") return null;
|
|
33
|
+
try {
|
|
34
|
+
const raw = window.localStorage.getItem(STORAGE_KEY);
|
|
35
|
+
if (!raw) return null;
|
|
36
|
+
const parsed: unknown = JSON.parse(raw);
|
|
37
|
+
if (!isStoredRef(parsed)) return null;
|
|
38
|
+
if (Date.now() - parsed.at > TTL_MS) {
|
|
39
|
+
// Expired — clear so we stop attaching it to future orders.
|
|
40
|
+
window.localStorage.removeItem(STORAGE_KEY);
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
return parsed.id;
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Captures `?tn_ref=<uuid>` from the current URL into localStorage and strips
|
|
51
|
+
* it from the visible URL so it doesn't leak through copy-paste or referer.
|
|
52
|
+
* Idempotent — safe to call on every render. Returns true when a ref was
|
|
53
|
+
* captured.
|
|
54
|
+
*/
|
|
55
|
+
export function captureAttributionRefFromUrl(): boolean {
|
|
56
|
+
if (typeof window === "undefined") return false;
|
|
57
|
+
try {
|
|
58
|
+
const url = new URL(window.location.href);
|
|
59
|
+
const ref = url.searchParams.get("tn_ref");
|
|
60
|
+
if (!ref) return false;
|
|
61
|
+
persistAttributionRef(ref);
|
|
62
|
+
url.searchParams.delete("tn_ref");
|
|
63
|
+
window.history.replaceState({}, "", `${url.pathname}${url.search}${url.hash}`);
|
|
64
|
+
return true;
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Cookie-consent storage + a tiny pub/sub so analytics can react the moment the
|
|
2
|
+
// visitor makes a choice. Deliberately shares the platform's existing keys —
|
|
3
|
+
// `cookieSettings` (localStorage) + the `cookies-consent` cookie — so a visitor's
|
|
4
|
+
// decision is consistent between code sites and the CMS/Craft.js sites.
|
|
5
|
+
|
|
6
|
+
export type CookieConsent = {
|
|
7
|
+
/** Strictly-necessary cookies. Always on — cannot be rejected. */
|
|
8
|
+
functional: boolean;
|
|
9
|
+
/** Analytics / performance (first-party page views + clicks). */
|
|
10
|
+
performance: boolean;
|
|
11
|
+
/** Marketing / advertising (e.g. the Meta Pixel). */
|
|
12
|
+
marketing: boolean;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const COOKIE_CONSENT_KEY = "cookieSettings";
|
|
16
|
+
const COOKIE_NAME = "cookies-consent";
|
|
17
|
+
/** Fired on the window whenever consent is written, so live tabs re-read it. */
|
|
18
|
+
export const COOKIE_CONSENT_EVENT = "forge:cookieconsent";
|
|
19
|
+
|
|
20
|
+
/** The decision for a visitor who has accepted nothing yet. */
|
|
21
|
+
export const DENY_ALL: CookieConsent = { functional: true, performance: false, marketing: false };
|
|
22
|
+
export const GRANT_ALL: CookieConsent = { functional: true, performance: true, marketing: true };
|
|
23
|
+
|
|
24
|
+
/** Read the stored consent, or `null` if the visitor hasn't decided yet. */
|
|
25
|
+
export function getCookieConsent(): CookieConsent | null {
|
|
26
|
+
if (typeof window === "undefined") return null;
|
|
27
|
+
try {
|
|
28
|
+
const raw = window.localStorage.getItem(COOKIE_CONSENT_KEY);
|
|
29
|
+
if (!raw) return null;
|
|
30
|
+
const parsed = JSON.parse(raw) as Partial<CookieConsent>;
|
|
31
|
+
return {
|
|
32
|
+
functional: true, // always on regardless of what was stored
|
|
33
|
+
performance: !!parsed.performance,
|
|
34
|
+
marketing: !!parsed.marketing,
|
|
35
|
+
};
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Persist consent to localStorage + a 1-year cookie, then notify listeners. */
|
|
42
|
+
export function saveCookieConsent(consent: CookieConsent): void {
|
|
43
|
+
if (typeof window === "undefined") return;
|
|
44
|
+
const value: CookieConsent = { ...consent, functional: true };
|
|
45
|
+
try {
|
|
46
|
+
window.localStorage.setItem(COOKIE_CONSENT_KEY, JSON.stringify(value));
|
|
47
|
+
const secure = window.location.protocol === "https:" ? "; Secure" : "";
|
|
48
|
+
document.cookie = `${COOKIE_NAME}=${encodeURIComponent(JSON.stringify(value))}; path=/; max-age=31536000; SameSite=Lax${secure}`;
|
|
49
|
+
} catch {
|
|
50
|
+
// storage disabled — the event still fires so in-memory listeners update
|
|
51
|
+
}
|
|
52
|
+
window.dispatchEvent(new Event(COOKIE_CONSENT_EVENT));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Wipe the stored decision so the banner shows again, then notify listeners —
|
|
56
|
+
* so a "Cookie settings" trigger in one component re-opens a `<CookieConsent/>`
|
|
57
|
+
* rendered elsewhere (e.g. in TribeNestApp). Unlike a bare removeItem, this broadcasts. */
|
|
58
|
+
export function clearCookieConsent(): void {
|
|
59
|
+
if (typeof window === "undefined") return;
|
|
60
|
+
try {
|
|
61
|
+
window.localStorage.removeItem(COOKIE_CONSENT_KEY);
|
|
62
|
+
const secure = window.location.protocol === "https:" ? "; Secure" : "";
|
|
63
|
+
document.cookie = `${COOKIE_NAME}=; path=/; max-age=0; SameSite=Lax${secure}`;
|
|
64
|
+
} catch {
|
|
65
|
+
// storage disabled — the event still fires so in-memory listeners update
|
|
66
|
+
}
|
|
67
|
+
window.dispatchEvent(new Event(COOKIE_CONSENT_EVENT));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Subscribe to consent changes (this tab via the custom event, other tabs via
|
|
71
|
+
* the native `storage` event). Returns an unsubscribe fn. */
|
|
72
|
+
export function onCookieConsentChange(cb: () => void): () => void {
|
|
73
|
+
if (typeof window === "undefined") return () => {};
|
|
74
|
+
const onStorage = (e: StorageEvent) => {
|
|
75
|
+
if (e.key === COOKIE_CONSENT_KEY) cb();
|
|
76
|
+
};
|
|
77
|
+
window.addEventListener(COOKIE_CONSENT_EVENT, cb);
|
|
78
|
+
window.addEventListener("storage", onStorage);
|
|
79
|
+
return () => {
|
|
80
|
+
window.removeEventListener(COOKIE_CONSENT_EVENT, cb);
|
|
81
|
+
window.removeEventListener("storage", onStorage);
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// First-touch landing attribution (Shopify-style).
|
|
2
|
+
//
|
|
3
|
+
// The FIRST time a visitor lands on the artist's site we snapshot the full
|
|
4
|
+
// query-param bag (every `utm_*`, ad click-id like `fbclid`/`gclid`/`ttclid`,
|
|
5
|
+
// and any other param — verbatim) plus `document.referrer` and the landing
|
|
6
|
+
// URL. Checkout reads it back and sends `landingUtm` + `referrer` with every
|
|
7
|
+
// purchase, so later attribution/ROAS can tie a sale to the ad that drove it —
|
|
8
|
+
// regardless of whether the ad was created on TribeNest or elsewhere.
|
|
9
|
+
//
|
|
10
|
+
// First-touch wins with a 30-day TTL: the very first landing is preserved for
|
|
11
|
+
// 30 days, matching the tracked-link `tn:attributionRef` semantics so the two
|
|
12
|
+
// attribution signals expire together. See docs/ads-attribution-initiative.md.
|
|
13
|
+
|
|
14
|
+
const STORAGE_KEY = "tn:landing";
|
|
15
|
+
const TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
16
|
+
|
|
17
|
+
export type LandingSnapshot = {
|
|
18
|
+
/** Full query-param bag from the first landing URL (verbatim keys). */
|
|
19
|
+
landingUtm: Record<string, string>;
|
|
20
|
+
/** document.referrer at first landing ("" when opened directly). */
|
|
21
|
+
referrer: string;
|
|
22
|
+
/** The full first-landing href, for forensics. */
|
|
23
|
+
landingUrl: string;
|
|
24
|
+
/** Capture timestamp (ms) for the TTL check. */
|
|
25
|
+
at: number;
|
|
26
|
+
/** Stable per-visitor session id — dedupes repeat beacons server-side. */
|
|
27
|
+
sessionId?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function randomSessionId(): string {
|
|
31
|
+
try {
|
|
32
|
+
if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
|
|
33
|
+
} catch {
|
|
34
|
+
// fall through
|
|
35
|
+
}
|
|
36
|
+
return `s_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isLandingSnapshot(value: unknown): value is LandingSnapshot {
|
|
40
|
+
if (typeof value !== "object" || value === null) return false;
|
|
41
|
+
const v = value as Record<string, unknown>;
|
|
42
|
+
return (
|
|
43
|
+
typeof v.landingUtm === "object" &&
|
|
44
|
+
v.landingUtm !== null &&
|
|
45
|
+
typeof v.referrer === "string" &&
|
|
46
|
+
typeof v.at === "number"
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Reads the stored first-touch snapshot if it's still within the 30-day TTL.
|
|
52
|
+
* Returns null (and clears the expired entry) otherwise.
|
|
53
|
+
*/
|
|
54
|
+
function readSnapshot(): LandingSnapshot | null {
|
|
55
|
+
if (typeof window === "undefined") return null;
|
|
56
|
+
try {
|
|
57
|
+
const raw = window.localStorage.getItem(STORAGE_KEY);
|
|
58
|
+
if (!raw) return null;
|
|
59
|
+
const parsed: unknown = JSON.parse(raw);
|
|
60
|
+
if (!isLandingSnapshot(parsed)) return null;
|
|
61
|
+
if (Date.now() - parsed.at > TTL_MS) {
|
|
62
|
+
window.localStorage.removeItem(STORAGE_KEY);
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
return parsed;
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Snapshots the current URL's params + referrer into localStorage on the
|
|
73
|
+
* FIRST landing only (idempotent — safe to call on every render). Does NOT
|
|
74
|
+
* strip the params from the URL: the page's own ad pixels / analytics may
|
|
75
|
+
* still need them. Returns true when a fresh snapshot was written.
|
|
76
|
+
*/
|
|
77
|
+
export function captureLandingFromUrl(): boolean {
|
|
78
|
+
if (typeof window === "undefined") return false;
|
|
79
|
+
try {
|
|
80
|
+
// First-touch wins: never overwrite a live snapshot.
|
|
81
|
+
if (readSnapshot()) return false;
|
|
82
|
+
|
|
83
|
+
const url = new URL(window.location.href);
|
|
84
|
+
const landingUtm: Record<string, string> = {};
|
|
85
|
+
url.searchParams.forEach((value, key) => {
|
|
86
|
+
// Last value wins on duplicate keys; cap key/value length to avoid abuse.
|
|
87
|
+
if (key.length <= 128 && value.length <= 512) landingUtm[key] = value;
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const snapshot: LandingSnapshot = {
|
|
91
|
+
landingUtm,
|
|
92
|
+
referrer: typeof document !== "undefined" ? document.referrer || "" : "",
|
|
93
|
+
landingUrl: window.location.href,
|
|
94
|
+
at: Date.now(),
|
|
95
|
+
sessionId: randomSessionId(),
|
|
96
|
+
};
|
|
97
|
+
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
|
|
98
|
+
return true;
|
|
99
|
+
} catch {
|
|
100
|
+
// Quota / disabled storage — quietly drop. The purchase still succeeds,
|
|
101
|
+
// only the landing attribution is lost.
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Returns the first-touch `landingUtm` bag + `referrer` for the checkout
|
|
108
|
+
* payload, or null when nothing is stored / it expired. `landingUtm` is
|
|
109
|
+
* undefined when the bag is empty so we don't ship `{}`.
|
|
110
|
+
*/
|
|
111
|
+
export function readLanding(): { landingUtm?: Record<string, string>; referrer?: string } | null {
|
|
112
|
+
const snapshot = readSnapshot();
|
|
113
|
+
if (!snapshot) return null;
|
|
114
|
+
const hasParams = Object.keys(snapshot.landingUtm).length > 0;
|
|
115
|
+
return {
|
|
116
|
+
landingUtm: hasParams ? snapshot.landingUtm : undefined,
|
|
117
|
+
referrer: snapshot.referrer || undefined,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Fire-and-forget POST of the stored first-touch snapshot to the visit-level
|
|
123
|
+
* landing beacon. Records a landing_session server-side even when the visitor
|
|
124
|
+
* never buys — powering per-campaign visit counts + external click-id matching.
|
|
125
|
+
* Call ONCE on fresh capture (when captureLandingFromUrl returned true), so a
|
|
126
|
+
* visit is counted a single time within the 30-day window.
|
|
127
|
+
*/
|
|
128
|
+
export function postLandingBeacon(apiUrl: string, profileId: string, attributionRefId?: string): void {
|
|
129
|
+
if (typeof window === "undefined" || !apiUrl || !profileId) return;
|
|
130
|
+
const snapshot = readSnapshot();
|
|
131
|
+
if (!snapshot) return;
|
|
132
|
+
const hasParams = Object.keys(snapshot.landingUtm).length > 0;
|
|
133
|
+
// Nothing worth recording for a truly organic visit (no params, no referrer).
|
|
134
|
+
if (!hasParams && !snapshot.referrer) return;
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
void fetch(`${apiUrl.replace(/\/$/, "")}/public/marketing/landing`, {
|
|
138
|
+
method: "POST",
|
|
139
|
+
headers: { "Content-Type": "application/json" },
|
|
140
|
+
keepalive: true,
|
|
141
|
+
body: JSON.stringify({
|
|
142
|
+
profileId,
|
|
143
|
+
attributionRefId,
|
|
144
|
+
sessionId: snapshot.sessionId,
|
|
145
|
+
landingUrl: snapshot.landingUrl,
|
|
146
|
+
landingUtm: hasParams ? snapshot.landingUtm : undefined,
|
|
147
|
+
referrer: snapshot.referrer || undefined,
|
|
148
|
+
}),
|
|
149
|
+
}).catch(() => {});
|
|
150
|
+
} catch {
|
|
151
|
+
// Beacon is best-effort; never disrupt the page.
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Client-side Meta pixel firing — mirror of the server-side CAPI events,
|
|
2
|
+
// matched on `event_id` so Meta dedupes the pair. Backend CAPI is the source
|
|
3
|
+
// of truth; this script-side firing exists to keep Meta's pixel inspector
|
|
4
|
+
// happy and to capture the pre-purchase funnel events the backend doesn't see.
|
|
5
|
+
//
|
|
6
|
+
// Supports MULTIPLE pixels on one page (a profile-wide pixel + a per-campaign
|
|
7
|
+
// pixel on a landing page). Each pixel is fired individually with `trackSingle`
|
|
8
|
+
// so a PageView on one doesn't leak into the others.
|
|
9
|
+
|
|
10
|
+
type MetaEventName = "PageView" | "ViewContent" | "AddToCart" | "InitiateCheckout" | "Purchase" | "Lead";
|
|
11
|
+
|
|
12
|
+
// fbq is a variadic queue shim (init / track / trackSingle / …). We access it via
|
|
13
|
+
// a local cast rather than a `declare global`, so Forge never conflicts with a
|
|
14
|
+
// host app's own `Window.fbq` typing.
|
|
15
|
+
type Fbq = (...args: any[]) => void;
|
|
16
|
+
function getFbq(): Fbq | undefined {
|
|
17
|
+
return typeof window !== "undefined" ? ((window as any).fbq as Fbq | undefined) : undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let scriptLoaded = false;
|
|
21
|
+
const initedPixels = new Set<string>();
|
|
22
|
+
|
|
23
|
+
// Meta pixel ids are numeric — strip anything else defensively before it reaches
|
|
24
|
+
// an `fbq` call or a template.
|
|
25
|
+
function cleanId(pixelId: string): string {
|
|
26
|
+
return String(pixelId).replace(/[^0-9]/g, "");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Inject the Meta base shim once (defines `window.fbq` as a queue + loads
|
|
30
|
+
// fbevents.js). Deliberately does NOT init a pixel or fire PageView — callers
|
|
31
|
+
// do that explicitly so multi-pixel pages stay isolated.
|
|
32
|
+
function loadFbevents(): void {
|
|
33
|
+
if (scriptLoaded || typeof window === "undefined") return;
|
|
34
|
+
if (getFbq()) {
|
|
35
|
+
scriptLoaded = true;
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const script = document.createElement("script");
|
|
39
|
+
script.async = true;
|
|
40
|
+
script.innerHTML = `
|
|
41
|
+
!function(f,b,e,v,n,t,s){
|
|
42
|
+
if(f.fbq)return;n=f.fbq=function(){n.callMethod?
|
|
43
|
+
n.callMethod.apply(n,arguments):n.queue.push(arguments)};
|
|
44
|
+
if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
|
|
45
|
+
n.queue=[];t=b.createElement(e);t.async=!0;
|
|
46
|
+
t.src=v;s=b.getElementsByTagName(e)[0];
|
|
47
|
+
s.parentNode.insertBefore(t,s)
|
|
48
|
+
}(window, document,'script','https://connect.facebook.net/en_US/fbevents.js');
|
|
49
|
+
`;
|
|
50
|
+
document.head.appendChild(script);
|
|
51
|
+
scriptLoaded = true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Ensure a pixel is initialized exactly once. Returns the cleaned id (or "").
|
|
55
|
+
function ensurePixel(pixelId: string): string {
|
|
56
|
+
const id = cleanId(pixelId);
|
|
57
|
+
if (!id || typeof window === "undefined") return id;
|
|
58
|
+
loadFbevents();
|
|
59
|
+
if (!initedPixels.has(id)) {
|
|
60
|
+
getFbq()?.("init", id);
|
|
61
|
+
initedPixels.add(id);
|
|
62
|
+
}
|
|
63
|
+
return id;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Backward-compatible init: loads the pixel and fires a PageView (to every
|
|
68
|
+
* initialized pixel, via `track`). Existing single-pixel callers rely on this.
|
|
69
|
+
* For multi-pixel pages prefer `initMetaPixelScoped` + `fireMetaEventSingle`.
|
|
70
|
+
*/
|
|
71
|
+
export function initMetaPixel(pixelId: string): void {
|
|
72
|
+
if (typeof window === "undefined") return;
|
|
73
|
+
const id = ensurePixel(pixelId);
|
|
74
|
+
if (id) getFbq()?.("track", "PageView");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Init a pixel WITHOUT auto-firing PageView — for multi-pixel pages where each
|
|
78
|
+
* pixel fires its own events via `fireMetaEventSingle`. */
|
|
79
|
+
export function initMetaPixelScoped(pixelId: string): void {
|
|
80
|
+
if (typeof window === "undefined") return;
|
|
81
|
+
ensurePixel(pixelId);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Fire an event to ALL initialized pixels (legacy single-pixel behavior). */
|
|
85
|
+
export function fireMetaEvent(
|
|
86
|
+
eventName: MetaEventName,
|
|
87
|
+
customData: Record<string, unknown> = {},
|
|
88
|
+
eventID?: string,
|
|
89
|
+
): void {
|
|
90
|
+
const fbq = getFbq();
|
|
91
|
+
if (!fbq) return;
|
|
92
|
+
if (eventID) fbq("track", eventName, customData, { eventID });
|
|
93
|
+
else fbq("track", eventName, customData);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Fire an event to ONE specific pixel (`trackSingle`) — keeps a campaign pixel's
|
|
97
|
+
* events out of the profile pixel and vice-versa. */
|
|
98
|
+
export function fireMetaEventSingle(
|
|
99
|
+
pixelId: string,
|
|
100
|
+
eventName: MetaEventName | string,
|
|
101
|
+
customData: Record<string, unknown> = {},
|
|
102
|
+
eventID?: string,
|
|
103
|
+
): void {
|
|
104
|
+
const fbq = getFbq();
|
|
105
|
+
if (!fbq) return;
|
|
106
|
+
const id = cleanId(pixelId);
|
|
107
|
+
if (!id) return;
|
|
108
|
+
if (eventID) fbq("trackSingle", id, eventName, customData, { eventID });
|
|
109
|
+
else fbq("trackSingle", id, eventName, customData);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Reads the Meta browser-set cookies so the backend CAPI request can pass them
|
|
113
|
+
// through in `user_data` — that's how Meta matches browser-side and
|
|
114
|
+
// server-side events to a single user.
|
|
115
|
+
export function readMetaCookies(): { fbp?: string; fbc?: string } {
|
|
116
|
+
if (typeof document === "undefined") return {};
|
|
117
|
+
const match = (name: string) => {
|
|
118
|
+
const m = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
|
|
119
|
+
return m ? decodeURIComponent(m[1]) : undefined;
|
|
120
|
+
};
|
|
121
|
+
return { fbp: match("_fbp"), fbc: match("_fbc") };
|
|
122
|
+
}
|