@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,333 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { createContext, type ReactNode, useCallback, useContext, useEffect, useReducer, useState } from "react";
|
|
3
|
+
import {
|
|
4
|
+
type ApiError,
|
|
5
|
+
type PublicAuthUser,
|
|
6
|
+
type UpdateLocalUserPayload,
|
|
7
|
+
type ActionMap,
|
|
8
|
+
type PublicAuthState,
|
|
9
|
+
type PublicAuthContextType,
|
|
10
|
+
type LoginInput,
|
|
11
|
+
type PublicCreateAccountInput,
|
|
12
|
+
Currency,
|
|
13
|
+
} from "../types/models";
|
|
14
|
+
import { useForge } from "../provider/ForgeProvider";
|
|
15
|
+
import { useQuery } from "@tanstack/react-query";
|
|
16
|
+
|
|
17
|
+
enum Types {
|
|
18
|
+
Initial = "INITIALIZE",
|
|
19
|
+
Login = "LOGIN",
|
|
20
|
+
Logout = "LOGOUT",
|
|
21
|
+
Register = "REGISTER",
|
|
22
|
+
Loading = "LOADING",
|
|
23
|
+
UpdateUser = "UPDATE_USER",
|
|
24
|
+
UpdateErrorMessage = "UPDATE_ERROR_MESSAGE",
|
|
25
|
+
}
|
|
26
|
+
export const ACCESS_TOKEN_KEY = "public-access-token";
|
|
27
|
+
export const PUBLIC_ACCESS_TOKEN_KEY = "public-access-token";
|
|
28
|
+
|
|
29
|
+
type AuthPayload = {
|
|
30
|
+
[Types.Initial]: { isAuthenticated: boolean; user: PublicAuthUser | null };
|
|
31
|
+
[Types.Login]: { user: PublicAuthUser | null };
|
|
32
|
+
[Types.Logout]: undefined;
|
|
33
|
+
[Types.Register]: { user: PublicAuthUser | null };
|
|
34
|
+
[Types.Loading]: { isLoading: boolean };
|
|
35
|
+
[Types.UpdateUser]: { data: UpdateLocalUserPayload };
|
|
36
|
+
[Types.UpdateErrorMessage]: { errorMessage: string | null };
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type AuthActions = ActionMap<AuthPayload>[keyof ActionMap<AuthPayload>];
|
|
40
|
+
|
|
41
|
+
const initialState: PublicAuthState = {
|
|
42
|
+
isAuthenticated: false,
|
|
43
|
+
isInitialized: false,
|
|
44
|
+
user: null,
|
|
45
|
+
isLoading: false,
|
|
46
|
+
errorMessage: null,
|
|
47
|
+
initialize: () => Promise.resolve(false),
|
|
48
|
+
currencies: null,
|
|
49
|
+
userSelectedCurrency: null,
|
|
50
|
+
setUserSelectedCurrency: () => {},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const AuthReducer = (state: PublicAuthState, action: AuthActions): PublicAuthState => {
|
|
54
|
+
switch (action.type) {
|
|
55
|
+
case "INITIALIZE":
|
|
56
|
+
return {
|
|
57
|
+
...state,
|
|
58
|
+
isAuthenticated: action.payload.isAuthenticated,
|
|
59
|
+
isInitialized: true,
|
|
60
|
+
user: action.payload.user,
|
|
61
|
+
isLoading: false,
|
|
62
|
+
};
|
|
63
|
+
case "LOGIN":
|
|
64
|
+
return {
|
|
65
|
+
...state,
|
|
66
|
+
isAuthenticated: true,
|
|
67
|
+
user: action.payload.user,
|
|
68
|
+
isLoading: false,
|
|
69
|
+
errorMessage: null,
|
|
70
|
+
};
|
|
71
|
+
case "LOGOUT":
|
|
72
|
+
return {
|
|
73
|
+
...state,
|
|
74
|
+
isAuthenticated: false,
|
|
75
|
+
user: null,
|
|
76
|
+
isLoading: false,
|
|
77
|
+
errorMessage: null,
|
|
78
|
+
};
|
|
79
|
+
case "REGISTER":
|
|
80
|
+
return {
|
|
81
|
+
...state,
|
|
82
|
+
isAuthenticated: true,
|
|
83
|
+
user: action.payload.user,
|
|
84
|
+
isLoading: false,
|
|
85
|
+
errorMessage: null,
|
|
86
|
+
};
|
|
87
|
+
case Types.Loading:
|
|
88
|
+
return {
|
|
89
|
+
...state,
|
|
90
|
+
isLoading: action.payload.isLoading,
|
|
91
|
+
errorMessage: action.payload.isLoading ? null : state.errorMessage,
|
|
92
|
+
};
|
|
93
|
+
case Types.UpdateUser:
|
|
94
|
+
return { ...state, user: { ...state.user!, ...action.payload.data } };
|
|
95
|
+
case Types.UpdateErrorMessage:
|
|
96
|
+
return { ...state, errorMessage: action.payload.errorMessage };
|
|
97
|
+
default:
|
|
98
|
+
return state;
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const PublicAuthContext = createContext<PublicAuthContextType | null>(null);
|
|
103
|
+
|
|
104
|
+
// ----------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
type AuthProviderProps = {
|
|
107
|
+
children: ReactNode;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
function PublicAuthProvider({ children }: AuthProviderProps) {
|
|
111
|
+
// Root-level tenant/config comes from the single ForgeProvider (AppContext),
|
|
112
|
+
// not props or the Craft editor context.
|
|
113
|
+
const { client, profileId, setToken } = useForge();
|
|
114
|
+
const [state, dispatch] = useReducer(AuthReducer, initialState);
|
|
115
|
+
const [userSelectedCurrency, setUserSelectedCurrencyState] = useState<Currency | null>(null);
|
|
116
|
+
|
|
117
|
+
const setUserSelectedCurrency = useCallback((currency: Currency) => {
|
|
118
|
+
setUserSelectedCurrencyState(currency);
|
|
119
|
+
localStorage.setItem("userSelectedCurrency", currency);
|
|
120
|
+
}, []);
|
|
121
|
+
|
|
122
|
+
const { data: currencies } = useQuery({
|
|
123
|
+
// profileId scopes the cache — supportedCurrencies is narrowed to the tenant's
|
|
124
|
+
// connected account (must match useCurrencies' key so they share one fetch).
|
|
125
|
+
queryKey: ["public-currencies", profileId],
|
|
126
|
+
queryFn: async () => {
|
|
127
|
+
const response = await client.get("/public/currencies", { params: { profileId } });
|
|
128
|
+
return response.data;
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
enabled: !!client,
|
|
132
|
+
staleTime: 1000 * 60 * 60 * 2, // 2 hours
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
if (currencies) {
|
|
137
|
+
const localStorageUserSelectedCurrency = localStorage.getItem("userSelectedCurrency") as Currency | null;
|
|
138
|
+
if (
|
|
139
|
+
localStorageUserSelectedCurrency &&
|
|
140
|
+
currencies?.supportedCurrencies.includes(localStorageUserSelectedCurrency as Currency)
|
|
141
|
+
) {
|
|
142
|
+
setUserSelectedCurrencyState(localStorageUserSelectedCurrency as Currency);
|
|
143
|
+
} else {
|
|
144
|
+
const defaultCurrency = currencies?.supportedCurrencies.includes(currencies?.userCurrency ?? "")
|
|
145
|
+
? currencies?.userCurrency
|
|
146
|
+
: "USD";
|
|
147
|
+
setUserSelectedCurrencyState(defaultCurrency);
|
|
148
|
+
if (defaultCurrency) {
|
|
149
|
+
localStorage.setItem("userSelectedCurrency", defaultCurrency);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}, [currencies]);
|
|
154
|
+
|
|
155
|
+
const initialize = useCallback(async () => {
|
|
156
|
+
try {
|
|
157
|
+
const accessToken = localStorage.getItem(ACCESS_TOKEN_KEY);
|
|
158
|
+
if (accessToken) {
|
|
159
|
+
setToken(accessToken);
|
|
160
|
+
const response = await client.get("/public/accounts/me", {
|
|
161
|
+
params: {
|
|
162
|
+
profileId: profileId,
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
dispatch({
|
|
167
|
+
type: Types.Initial,
|
|
168
|
+
payload: { isAuthenticated: true, user: response.data },
|
|
169
|
+
});
|
|
170
|
+
return true;
|
|
171
|
+
} else {
|
|
172
|
+
dispatch({
|
|
173
|
+
type: Types.Initial,
|
|
174
|
+
payload: { isAuthenticated: false, user: null },
|
|
175
|
+
});
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
} catch (error) {
|
|
179
|
+
console.error(error);
|
|
180
|
+
dispatch({
|
|
181
|
+
type: Types.Initial,
|
|
182
|
+
payload: { isAuthenticated: false, user: null },
|
|
183
|
+
});
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
}, [client, setToken, profileId]);
|
|
187
|
+
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
initialize();
|
|
190
|
+
}, [initialize]);
|
|
191
|
+
|
|
192
|
+
// Context-aware public auth: login runs against /public/sessions (scoped to
|
|
193
|
+
// this profile via associations). The global /sessions surface is left for the
|
|
194
|
+
// dashboard; it's also backwards-compatible (association-aware when a profileId
|
|
195
|
+
// is sent), so old sites keep working during the transition.
|
|
196
|
+
const login = async (data: LoginInput & { profileId: string }) => {
|
|
197
|
+
dispatch({ type: Types.Loading, payload: { isLoading: true } });
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
const response = await client.post("/public/sessions", data);
|
|
201
|
+
const { token, account, smartLinkPath } = response.data;
|
|
202
|
+
localStorage.setItem(ACCESS_TOKEN_KEY, token);
|
|
203
|
+
setToken(token);
|
|
204
|
+
dispatch({ type: Types.Login, payload: { user: account } });
|
|
205
|
+
return { smartLinkPath };
|
|
206
|
+
} catch (err: unknown) {
|
|
207
|
+
const error = err as ApiError;
|
|
208
|
+
dispatch({ type: Types.Loading, payload: { isLoading: false } });
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const validateLoginCredentials = async (data: LoginInput & { profileId: string }) => {
|
|
214
|
+
const response = await client.post("/public/sessions", data);
|
|
215
|
+
return response.data.email as string;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const verifyLoginCode = async (email: string, code: string, profileId: string) => {
|
|
219
|
+
dispatch({ type: Types.Loading, payload: { isLoading: true } });
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
const response = await client.post("/public/sessions/verify", { email, code, profileId });
|
|
223
|
+
const { token, account, smartLinkPath } = response.data;
|
|
224
|
+
localStorage.setItem(ACCESS_TOKEN_KEY, token);
|
|
225
|
+
setToken(token);
|
|
226
|
+
dispatch({ type: Types.Login, payload: { user: account } });
|
|
227
|
+
return { smartLinkPath };
|
|
228
|
+
} catch (err: unknown) {
|
|
229
|
+
const error = err as ApiError;
|
|
230
|
+
dispatch({ type: Types.Loading, payload: { isLoading: false } });
|
|
231
|
+
throw error;
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const resendLoginCode = async (email: string) => {
|
|
236
|
+
// /public/sessions requires the profile context.
|
|
237
|
+
await client.post("/public/sessions/resend-code", { email, profileId });
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const refetchUser = async () => {
|
|
241
|
+
try {
|
|
242
|
+
const response = await client.get("/accounts/me", {
|
|
243
|
+
params: {
|
|
244
|
+
profileId: profileId,
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
dispatch({
|
|
248
|
+
type: Types.Initial,
|
|
249
|
+
payload: { isAuthenticated: true, user: response.data },
|
|
250
|
+
});
|
|
251
|
+
} catch (error: unknown) {
|
|
252
|
+
console.error(error);
|
|
253
|
+
dispatch({
|
|
254
|
+
type: Types.Initial,
|
|
255
|
+
payload: { isAuthenticated: false, user: null },
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const register = async (data: PublicCreateAccountInput) => {
|
|
261
|
+
dispatch({ type: Types.Loading, payload: { isLoading: true } });
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
const response = await client.post("/public/accounts", {
|
|
265
|
+
...data,
|
|
266
|
+
});
|
|
267
|
+
const { token, account, smartLinkPath } = response.data;
|
|
268
|
+
localStorage.setItem(ACCESS_TOKEN_KEY, token);
|
|
269
|
+
setToken(token);
|
|
270
|
+
|
|
271
|
+
dispatch({ type: Types.Register, payload: { user: account } });
|
|
272
|
+
return { smartLinkPath };
|
|
273
|
+
} catch (err: unknown) {
|
|
274
|
+
const error = err as ApiError;
|
|
275
|
+
dispatch({ type: Types.Loading, payload: { isLoading: false } });
|
|
276
|
+
throw error;
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const logout = async (persist = true) => {
|
|
281
|
+
try {
|
|
282
|
+
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
|
283
|
+
dispatch({ type: Types.Logout });
|
|
284
|
+
if (persist) await client.delete("/sessions");
|
|
285
|
+
} catch (error) {
|
|
286
|
+
console.error();
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const updateLocalUser = (data: UpdateLocalUserPayload) => {
|
|
291
|
+
dispatch({ type: Types.UpdateUser, payload: { data } });
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const clearErrorMessage = () => {
|
|
295
|
+
dispatch({
|
|
296
|
+
type: Types.UpdateErrorMessage,
|
|
297
|
+
payload: { errorMessage: null },
|
|
298
|
+
});
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
return (
|
|
302
|
+
<PublicAuthContext.Provider
|
|
303
|
+
value={{
|
|
304
|
+
...state,
|
|
305
|
+
login,
|
|
306
|
+
validateLoginCredentials,
|
|
307
|
+
verifyLoginCode,
|
|
308
|
+
resendLoginCode,
|
|
309
|
+
logout,
|
|
310
|
+
register,
|
|
311
|
+
updateLocalUser,
|
|
312
|
+
clearErrorMessage,
|
|
313
|
+
refetchUser,
|
|
314
|
+
initialize,
|
|
315
|
+
currencies,
|
|
316
|
+
userSelectedCurrency,
|
|
317
|
+
setUserSelectedCurrency,
|
|
318
|
+
}}
|
|
319
|
+
>
|
|
320
|
+
{children}
|
|
321
|
+
</PublicAuthContext.Provider>
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export const usePublicAuth = () => {
|
|
326
|
+
const context = useContext(PublicAuthContext);
|
|
327
|
+
if (!context) {
|
|
328
|
+
throw new Error("usePublicAuth must be used within a PublicAuthProvider");
|
|
329
|
+
}
|
|
330
|
+
return context;
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
export { PublicAuthContext, PublicAuthProvider };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useQuery } from "@tanstack/react-query";
|
|
3
|
+
import { useForge } from "../provider/ForgeProvider";
|
|
4
|
+
import { usePublicAuth } from "./PublicAuthContext";
|
|
5
|
+
|
|
6
|
+
// Guards a mini-app's /admin routes. App admins are NOT app users — they are the
|
|
7
|
+
// owner + profile team members with a Work grant on the app (see §19). They log
|
|
8
|
+
// into the app site with their normal TribeNest account (the profile public-auth
|
|
9
|
+
// flow, usePublicAuth), and this hook asks the backend whether that account may
|
|
10
|
+
// reach /admin: GET /public/apps/:appId/admin-access → { isAdmin, permission }.
|
|
11
|
+
//
|
|
12
|
+
// Usage in the app-starter's /admin layout:
|
|
13
|
+
// const { isAdmin, isLoading, isAuthenticated } = useAppAdminGuard();
|
|
14
|
+
// if (isLoading) return <Spinner/>;
|
|
15
|
+
// if (!isAuthenticated) return <Navigate to="/admin/login"/>;
|
|
16
|
+
// if (!isAdmin) return <NotAuthorized/>;
|
|
17
|
+
|
|
18
|
+
export interface AppAdminGuardResult {
|
|
19
|
+
/** True while auth is initializing or the access check is in flight. */
|
|
20
|
+
isLoading: boolean;
|
|
21
|
+
/** Whether a TribeNest account is logged in (via the profile public-auth flow). */
|
|
22
|
+
isAuthenticated: boolean;
|
|
23
|
+
/** Whether the logged-in account may access this app's /admin. */
|
|
24
|
+
isAdmin: boolean;
|
|
25
|
+
/** The highest permission the account holds on the app (or null). */
|
|
26
|
+
permission: string | null;
|
|
27
|
+
appName: string | null;
|
|
28
|
+
refetch: () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function useAppAdminGuard(): AppAdminGuardResult {
|
|
32
|
+
const { appId, client } = useForge();
|
|
33
|
+
const { isAuthenticated, isInitialized } = usePublicAuth();
|
|
34
|
+
|
|
35
|
+
const query = useQuery({
|
|
36
|
+
queryKey: ["app-admin-access", appId],
|
|
37
|
+
// Only ask once we know there's a logged-in account and we have an appId.
|
|
38
|
+
enabled: !!appId && isInitialized && isAuthenticated,
|
|
39
|
+
staleTime: 60_000,
|
|
40
|
+
queryFn: async () => {
|
|
41
|
+
const res = await client.get(`/public/apps/${appId}/admin-access`);
|
|
42
|
+
return res.data as { isAdmin: boolean; permission: string | null; appName: string | null };
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const isLoading = !isInitialized || (isAuthenticated && query.isPending);
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
isLoading,
|
|
50
|
+
isAuthenticated,
|
|
51
|
+
isAdmin: isAuthenticated ? !!query.data?.isAdmin : false,
|
|
52
|
+
permission: query.data?.permission ?? null,
|
|
53
|
+
appName: query.data?.appName ?? null,
|
|
54
|
+
refetch: query.refetch,
|
|
55
|
+
};
|
|
56
|
+
}
|
package/src/css.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Ambient declaration so Forge source can side-effect-import .css files
|
|
2
|
+
// (e.g. ui/styled/rich-text.css) under `tsc --noEmit` and vitest. Consumer apps
|
|
3
|
+
// (site-starter forks) already cover this via `vite/client` types; this file
|
|
4
|
+
// covers Forge's own type-check, which doesn't load Vite's ambient types.
|
|
5
|
+
declare module "*.css";
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { CollectionQueryParams, CollectionQuery } from "../types/models";
|
|
2
|
+
|
|
3
|
+
// Serialize the flat CollectionQueryParams into bracketed query keys that the
|
|
4
|
+
// backend's qs parser re-nests (filter[key]=a,b / range[key][gte]=5 / near=lat,lng).
|
|
5
|
+
// Used by the simple GET /entries reads (useCollectionEntries + SSR). React-free
|
|
6
|
+
// so the hook and the SSR server fetcher share it.
|
|
7
|
+
export function collectionParamsToQuery(
|
|
8
|
+
profileId: string | undefined,
|
|
9
|
+
params?: CollectionQueryParams,
|
|
10
|
+
): Record<string, string> {
|
|
11
|
+
const out: Record<string, string> = {};
|
|
12
|
+
if (profileId) out.profileId = profileId;
|
|
13
|
+
out.page = String(params?.page ?? 1);
|
|
14
|
+
out.limit = String(params?.limit ?? 20);
|
|
15
|
+
|
|
16
|
+
if (params?.q) out.q = params.q;
|
|
17
|
+
if (params?.sort) out.sort = params.sort;
|
|
18
|
+
if (params?.facets?.length) out.facets = params.facets.join(",");
|
|
19
|
+
|
|
20
|
+
if (params?.near) {
|
|
21
|
+
out.near = `${params.near.lat},${params.near.lng}`;
|
|
22
|
+
if (params.near.radius != null) out.radius = String(params.near.radius);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
for (const [key, val] of Object.entries(params?.filter ?? {})) {
|
|
26
|
+
const values = Array.isArray(val) ? val : [val];
|
|
27
|
+
const cleaned = values.filter((v) => v != null && v !== "");
|
|
28
|
+
if (cleaned.length) out[`filter[${key}]`] = cleaned.join(",");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
for (const [key, r] of Object.entries(params?.range ?? {})) {
|
|
32
|
+
if (r?.gte != null) out[`range[${key}][gte]`] = String(r.gte);
|
|
33
|
+
if (r?.lte != null) out[`range[${key}][lte]`] = String(r.lte);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Stable key for React Query caching (sorted so param order doesn't matter). */
|
|
40
|
+
export function collectionParamsKey(params?: CollectionQueryParams): string {
|
|
41
|
+
const q = collectionParamsToQuery(undefined, params);
|
|
42
|
+
return Object.keys(q)
|
|
43
|
+
.sort()
|
|
44
|
+
.map((k) => `${k}=${q[k]}`)
|
|
45
|
+
.join("&");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Split a rich CollectionQuery into the { scope, query } POST body shape. */
|
|
49
|
+
export function splitCollectionQuery(query?: CollectionQuery): { scope?: "public" | "mine"; query: Omit<CollectionQuery, "scope"> } {
|
|
50
|
+
const { scope, ...rest } = query ?? {};
|
|
51
|
+
return { scope, query: rest };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Stable cache key for a rich CollectionQuery. */
|
|
55
|
+
export function collectionQueryKey(query?: CollectionQuery): string {
|
|
56
|
+
return JSON.stringify(query ?? {});
|
|
57
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { IPublicOrder, IPublicPost, PaginatedData } from "../../types/models";
|
|
2
|
+
import { useForge } from "../../provider/ForgeProvider";
|
|
3
|
+
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
4
|
+
|
|
5
|
+
/** Orders placed by the signed-in member on this profile. */
|
|
6
|
+
export function useUserOrders(accountId?: string) {
|
|
7
|
+
const { client, profileId } = useForge();
|
|
8
|
+
|
|
9
|
+
return useQuery<IPublicOrder[]>({
|
|
10
|
+
queryKey: ["user-orders", accountId, profileId],
|
|
11
|
+
queryFn: async () => {
|
|
12
|
+
const res = await client.get("/public/orders", {
|
|
13
|
+
params: { profileId },
|
|
14
|
+
});
|
|
15
|
+
return res.data;
|
|
16
|
+
},
|
|
17
|
+
enabled: !!accountId && !!profileId && !!client,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Posts the signed-in member has saved/bookmarked. */
|
|
22
|
+
export function useSavedPosts(accountId?: string) {
|
|
23
|
+
const { client, profileId } = useForge();
|
|
24
|
+
|
|
25
|
+
return useQuery<PaginatedData<IPublicPost>>({
|
|
26
|
+
queryKey: ["saved-posts", accountId, profileId],
|
|
27
|
+
queryFn: async () => {
|
|
28
|
+
const res = await client.get("/public/posts/saved", {
|
|
29
|
+
params: { accountId, profileId },
|
|
30
|
+
});
|
|
31
|
+
return res.data;
|
|
32
|
+
},
|
|
33
|
+
enabled: !!accountId && !!profileId && !!client,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type NotificationPreferences = {
|
|
38
|
+
newPosts: boolean;
|
|
39
|
+
chatMessages: boolean;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** The member's notification-type preferences for this profile. */
|
|
43
|
+
export function useNotificationPreferences(enabled = true) {
|
|
44
|
+
const { client, profileId } = useForge();
|
|
45
|
+
|
|
46
|
+
return useQuery<{ preferences?: Partial<NotificationPreferences> }>({
|
|
47
|
+
queryKey: ["notification-preferences", profileId],
|
|
48
|
+
queryFn: async () => {
|
|
49
|
+
const res = await client.get(`/public/notifications/preferences`, {
|
|
50
|
+
params: { profileId },
|
|
51
|
+
});
|
|
52
|
+
return res.data;
|
|
53
|
+
},
|
|
54
|
+
enabled: !!profileId && !!client && enabled,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Update one notification-type preference. */
|
|
59
|
+
export function useUpdateNotificationPreference() {
|
|
60
|
+
const { client, profileId } = useForge();
|
|
61
|
+
|
|
62
|
+
return useMutation<unknown, unknown, { key: keyof NotificationPreferences; value: boolean }>({
|
|
63
|
+
mutationFn: async ({ key, value }) => {
|
|
64
|
+
const res = await client.put(`/public/notifications/preferences`, {
|
|
65
|
+
profileId,
|
|
66
|
+
preferences: { [key]: value },
|
|
67
|
+
});
|
|
68
|
+
return res.data;
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { useForge } from "../../provider/ForgeProvider";
|
|
2
|
+
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
3
|
+
|
|
4
|
+
// Self-service account management for the signed-in member.
|
|
5
|
+
|
|
6
|
+
export type AccountDeletionStatus = {
|
|
7
|
+
deletionRequest?: { scheduledDeletionAt: string } | null;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/** Whether the member has a pending account-deletion request. */
|
|
11
|
+
export function useAccountDeletionStatus(enabled = true) {
|
|
12
|
+
const { client } = useForge();
|
|
13
|
+
|
|
14
|
+
return useQuery<AccountDeletionStatus>({
|
|
15
|
+
queryKey: ["account-deletion-status"],
|
|
16
|
+
queryFn: async () => {
|
|
17
|
+
const res = await client.get("/public/accounts/me/deletion-status");
|
|
18
|
+
return res.data;
|
|
19
|
+
},
|
|
20
|
+
enabled: !!client && enabled,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Export the member's data (returns the JSON payload to download). */
|
|
25
|
+
export function useExportAccountData() {
|
|
26
|
+
const { client } = useForge();
|
|
27
|
+
|
|
28
|
+
return useMutation<unknown, unknown, void>({
|
|
29
|
+
mutationFn: async () => {
|
|
30
|
+
const res = await client.get("/public/accounts/me/export");
|
|
31
|
+
return res.data;
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Request account deletion (30-day grace). Returns the scheduled date. */
|
|
37
|
+
export function useRequestAccountDeletion() {
|
|
38
|
+
const { client } = useForge();
|
|
39
|
+
|
|
40
|
+
return useMutation<{ scheduledDeletionAt: string }, unknown, void>({
|
|
41
|
+
mutationFn: async () => {
|
|
42
|
+
const res = await client.post("/public/accounts/me/request-deletion");
|
|
43
|
+
return res.data;
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Cancel a pending account-deletion request. */
|
|
49
|
+
export function useCancelAccountDeletion() {
|
|
50
|
+
const { client } = useForge();
|
|
51
|
+
|
|
52
|
+
return useMutation<unknown, unknown, void>({
|
|
53
|
+
mutationFn: async () => {
|
|
54
|
+
const res = await client.post("/public/accounts/me/cancel-deletion");
|
|
55
|
+
return res.data;
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Update the member's profile (name). */
|
|
61
|
+
export function useUpdateAccount() {
|
|
62
|
+
const { client } = useForge();
|
|
63
|
+
|
|
64
|
+
return useMutation<unknown, unknown, { firstName: string; lastName: string }>({
|
|
65
|
+
mutationFn: async (body) => {
|
|
66
|
+
const res = await client.put("/public/accounts/me", body);
|
|
67
|
+
return res.data;
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Change the member's password. */
|
|
73
|
+
export function useChangePassword() {
|
|
74
|
+
const { client } = useForge();
|
|
75
|
+
|
|
76
|
+
return useMutation<unknown, unknown, { currentPassword: string; newPassword: string }>({
|
|
77
|
+
mutationFn: async (body) => {
|
|
78
|
+
const res = await client.put("/public/accounts/password", body);
|
|
79
|
+
return res.data;
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import { useForge } from "../../provider/ForgeProvider";
|
|
3
|
+
|
|
4
|
+
/** Generate an id for de-duping a browser-side Meta pixel event against its
|
|
5
|
+
* server-side CAPI twin, and for correlating first-party events. */
|
|
6
|
+
export function newEventId(): string {
|
|
7
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
|
|
8
|
+
// Fallback for older runtimes — not cryptographically strong, only needs to be
|
|
9
|
+
// unique enough to pair a pixel + CAPI event.
|
|
10
|
+
return `evt_${Date.now().toString(36)}_${Math.abs(hashString(String(performance?.now?.() ?? Date.now()))).toString(36)}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function hashString(s: string): number {
|
|
14
|
+
let h = 0;
|
|
15
|
+
for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
|
|
16
|
+
return h;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Read (creating if absent) the per-visitor session id. Scoped to the tenant
|
|
21
|
+
* subdomain and stored in `sessionStorage`, matching the platform's website
|
|
22
|
+
* analytics so a code site's visits aggregate alongside CMS sites in the same
|
|
23
|
+
* admin dashboard. Returns `null` on the server.
|
|
24
|
+
*/
|
|
25
|
+
export function getAnalyticsSessionId(subdomain: string): string | null {
|
|
26
|
+
if (typeof window === "undefined" || !subdomain) return null;
|
|
27
|
+
const key = `tribe_nest_website_${subdomain}_session_id`;
|
|
28
|
+
try {
|
|
29
|
+
let sessionId = window.sessionStorage.getItem(key);
|
|
30
|
+
if (!sessionId) {
|
|
31
|
+
sessionId = `${subdomain}_${Date.now()}`;
|
|
32
|
+
window.sessionStorage.setItem(key, sessionId);
|
|
33
|
+
}
|
|
34
|
+
return sessionId;
|
|
35
|
+
} catch {
|
|
36
|
+
// Private mode / storage disabled — fall back to an ephemeral per-load id.
|
|
37
|
+
return `${subdomain}_${Date.now()}`;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface TrackEventOptions {
|
|
42
|
+
/** Override the tenant subdomain (defaults to the one on ForgeProvider). */
|
|
43
|
+
subdomain?: string;
|
|
44
|
+
/**
|
|
45
|
+
* Store a session id in `sessionStorage` and send it with events. Default
|
|
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
|
|
48
|
+
* per-day visitor hash instead, so the "visits" metric still works without
|
|
49
|
+
* needing storage consent.
|
|
50
|
+
*/
|
|
51
|
+
storeSession?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The first-party website-analytics primitive. Returns a `track(eventType,
|
|
56
|
+
* eventData)` function that posts to `POST /public/websites/track-event` — the
|
|
57
|
+
* same feed the platform's admin analytics dashboard reads (visits, page views,
|
|
58
|
+
* clicks, geo). The backend enriches each event with geo-IP + user agent.
|
|
59
|
+
*
|
|
60
|
+
* This is the headless building block behind `<ForgeAnalytics />`; a creator (or
|
|
61
|
+
* the agent) can call it directly to record custom events, e.g.
|
|
62
|
+
* `const { track } = useTrackEvent(); track("cta_click", { id: "hero" })`.
|
|
63
|
+
*/
|
|
64
|
+
export function useTrackEvent(opts?: TrackEventOptions) {
|
|
65
|
+
const { client, subdomain: ctxSubdomain } = useForge();
|
|
66
|
+
const subdomain = opts?.subdomain ?? ctxSubdomain;
|
|
67
|
+
const storeSession = opts?.storeSession ?? true;
|
|
68
|
+
|
|
69
|
+
const track = useCallback(
|
|
70
|
+
(eventType: string, eventData: Record<string, unknown> = {}) => {
|
|
71
|
+
if (typeof window === "undefined" || !client || !subdomain) return;
|
|
72
|
+
// Cookieless mode omits the sessionId entirely — the backend derives a
|
|
73
|
+
// rotating per-day visitor hash so nothing touches the device.
|
|
74
|
+
const sessionId = storeSession ? getAnalyticsSessionId(subdomain) : undefined;
|
|
75
|
+
const payload: Record<string, unknown> = { ...eventData };
|
|
76
|
+
if (sessionId) payload.sessionId = sessionId;
|
|
77
|
+
// Fire-and-forget: analytics must never block the UI or surface errors.
|
|
78
|
+
client
|
|
79
|
+
.post("/public/websites/track-event", { subdomain, eventType, eventData: payload })
|
|
80
|
+
.catch(() => {});
|
|
81
|
+
},
|
|
82
|
+
[client, subdomain, storeSession],
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
return { track };
|
|
86
|
+
}
|