@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,106 @@
|
|
|
1
|
+
import { useCallback, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { useForge } from "../../../provider/ForgeProvider";
|
|
3
|
+
import {
|
|
4
|
+
createAgentConversation,
|
|
5
|
+
streamAgentMessage,
|
|
6
|
+
useWebsiteAgentConfig,
|
|
7
|
+
} from "../../../data/queries/useWebsiteAgent";
|
|
8
|
+
|
|
9
|
+
export interface AiAgentMessage {
|
|
10
|
+
role: "user" | "assistant";
|
|
11
|
+
text: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const VISITOR_KEY = "forge_agent_visitor_id";
|
|
15
|
+
|
|
16
|
+
function getVisitorId(): string {
|
|
17
|
+
if (typeof window === "undefined") return "ssr";
|
|
18
|
+
try {
|
|
19
|
+
let id = window.localStorage.getItem(VISITOR_KEY);
|
|
20
|
+
if (!id) {
|
|
21
|
+
id = (window.crypto?.randomUUID?.() ?? `v_${Date.now()}_${Math.random().toString(36).slice(2)}`);
|
|
22
|
+
window.localStorage.setItem(VISITOR_KEY, id);
|
|
23
|
+
}
|
|
24
|
+
return id;
|
|
25
|
+
} catch {
|
|
26
|
+
return `v_${Date.now()}`;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Headless controller for the website AI agent widget. Owns visitor identity,
|
|
32
|
+
* the conversation (created lazily on first send), the message list, and the
|
|
33
|
+
* streaming turn. A site can build any UI on top of this.
|
|
34
|
+
*/
|
|
35
|
+
export function useAiAgent() {
|
|
36
|
+
const { apiUrl, profileId, token } = useForge();
|
|
37
|
+
const config = useWebsiteAgentConfig();
|
|
38
|
+
|
|
39
|
+
const [messages, setMessages] = useState<AiAgentMessage[]>([]);
|
|
40
|
+
const [sending, setSending] = useState(false);
|
|
41
|
+
const [error, setError] = useState<string | null>(null);
|
|
42
|
+
const conversationIdRef = useRef<string | null>(null);
|
|
43
|
+
const visitorId = useMemo(getVisitorId, []);
|
|
44
|
+
|
|
45
|
+
const ensureConversation = useCallback(async (): Promise<string | null> => {
|
|
46
|
+
if (conversationIdRef.current) return conversationIdRef.current;
|
|
47
|
+
if (!apiUrl || !profileId) return null;
|
|
48
|
+
const res = await createAgentConversation({ apiUrl, token, profileId, visitorId });
|
|
49
|
+
conversationIdRef.current = res.id;
|
|
50
|
+
return res.id;
|
|
51
|
+
}, [apiUrl, profileId, token, visitorId]);
|
|
52
|
+
|
|
53
|
+
const send = useCallback(
|
|
54
|
+
async (text: string) => {
|
|
55
|
+
const trimmed = text.trim();
|
|
56
|
+
if (!trimmed || sending || !apiUrl || !profileId) return;
|
|
57
|
+
setError(null);
|
|
58
|
+
setSending(true);
|
|
59
|
+
setMessages((prev) => [...prev, { role: "user", text: trimmed }, { role: "assistant", text: "" }]);
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const conversationId = await ensureConversation();
|
|
63
|
+
if (!conversationId) {
|
|
64
|
+
setError("The assistant is unavailable.");
|
|
65
|
+
setSending(false);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
await streamAgentMessage({
|
|
69
|
+
apiUrl,
|
|
70
|
+
token,
|
|
71
|
+
profileId,
|
|
72
|
+
visitorId,
|
|
73
|
+
conversationId,
|
|
74
|
+
message: trimmed,
|
|
75
|
+
onEvent: (ev) => {
|
|
76
|
+
if (ev.type === "text_delta") {
|
|
77
|
+
setMessages((prev) => {
|
|
78
|
+
const next = [...prev];
|
|
79
|
+
const last = next[next.length - 1];
|
|
80
|
+
if (last && last.role === "assistant") next[next.length - 1] = { ...last, text: last.text + ev.delta };
|
|
81
|
+
return next;
|
|
82
|
+
});
|
|
83
|
+
} else if (ev.type === "error") {
|
|
84
|
+
setError(ev.message);
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
} catch {
|
|
89
|
+
setError("Sorry, something went wrong. Please try again.");
|
|
90
|
+
} finally {
|
|
91
|
+
setSending(false);
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
[apiUrl, profileId, token, visitorId, sending, ensureConversation],
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
config: config.data,
|
|
99
|
+
isEnabled: !!config.data?.enabled,
|
|
100
|
+
loading: config.isLoading,
|
|
101
|
+
messages,
|
|
102
|
+
sending,
|
|
103
|
+
error,
|
|
104
|
+
send,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { useForge } from "../../../provider/ForgeProvider";
|
|
3
|
+
import { usePublicAuth } from "../../../contexts/PublicAuthContext";
|
|
4
|
+
|
|
5
|
+
export type LoginStep = "credentials" | "code";
|
|
6
|
+
|
|
7
|
+
const msg = (e: unknown) => (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Headless 2FA login: credentials → emailed code → verify. Bring your own UI
|
|
11
|
+
* (or use the styled <LoginForm>).
|
|
12
|
+
*/
|
|
13
|
+
export function useLoginFlow(opts: { onSuccess?: (smartLinkPath?: string) => void } = {}) {
|
|
14
|
+
const profileId = useForge().profileId ?? "";
|
|
15
|
+
const { validateLoginCredentials, verifyLoginCode, resendLoginCode } = usePublicAuth();
|
|
16
|
+
|
|
17
|
+
const [step, setStep] = useState<LoginStep>("credentials");
|
|
18
|
+
const [email, setEmail] = useState("");
|
|
19
|
+
const [password, setPassword] = useState("");
|
|
20
|
+
const [code, setCode] = useState("");
|
|
21
|
+
const [error, setError] = useState<string | null>(null);
|
|
22
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
23
|
+
|
|
24
|
+
const submitCredentials = async () => {
|
|
25
|
+
setError(null);
|
|
26
|
+
setIsSubmitting(true);
|
|
27
|
+
try {
|
|
28
|
+
await validateLoginCredentials({ email, password, profileId });
|
|
29
|
+
setStep("code");
|
|
30
|
+
} catch (e) {
|
|
31
|
+
setError(msg(e) || "Invalid email or password.");
|
|
32
|
+
} finally {
|
|
33
|
+
setIsSubmitting(false);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const submitCode = async () => {
|
|
38
|
+
setError(null);
|
|
39
|
+
setIsSubmitting(true);
|
|
40
|
+
try {
|
|
41
|
+
const { smartLinkPath } = await verifyLoginCode(email, code, profileId);
|
|
42
|
+
opts.onSuccess?.(smartLinkPath);
|
|
43
|
+
} catch (e) {
|
|
44
|
+
setError(msg(e) || "Invalid or expired code.");
|
|
45
|
+
} finally {
|
|
46
|
+
setIsSubmitting(false);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const resend = () => resendLoginCode(email).catch(() => {});
|
|
51
|
+
const back = () => {
|
|
52
|
+
setStep("credentials");
|
|
53
|
+
setCode("");
|
|
54
|
+
setError(null);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
step,
|
|
59
|
+
email,
|
|
60
|
+
setEmail,
|
|
61
|
+
password,
|
|
62
|
+
setPassword,
|
|
63
|
+
code,
|
|
64
|
+
setCode,
|
|
65
|
+
error,
|
|
66
|
+
isSubmitting,
|
|
67
|
+
submitCredentials,
|
|
68
|
+
submitCode,
|
|
69
|
+
resend,
|
|
70
|
+
back,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { useForge } from "../../../provider/ForgeProvider";
|
|
3
|
+
import { usePublicAuth } from "../../../contexts/PublicAuthContext";
|
|
4
|
+
|
|
5
|
+
const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
6
|
+
const msg = (e: unknown) => (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
|
7
|
+
|
|
8
|
+
export interface UseSignupFormOptions {
|
|
9
|
+
onSuccess?: (smartLinkPath?: string) => void;
|
|
10
|
+
membershipTierId?: string;
|
|
11
|
+
couponCode?: string;
|
|
12
|
+
/** Require the terms/privacy checkbox before submit (default true). */
|
|
13
|
+
requireAcceptance?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Headless account signup. Bring your own UI (or use the styled <SignupForm>). */
|
|
17
|
+
export function useSignupForm(opts: UseSignupFormOptions = {}) {
|
|
18
|
+
const profileId = useForge().profileId ?? "";
|
|
19
|
+
const { register } = usePublicAuth();
|
|
20
|
+
|
|
21
|
+
const [firstName, setFirstName] = useState("");
|
|
22
|
+
const [lastName, setLastName] = useState("");
|
|
23
|
+
const [email, setEmail] = useState("");
|
|
24
|
+
const [password, setPassword] = useState("");
|
|
25
|
+
const [acceptedTerms, setAcceptedTerms] = useState(false);
|
|
26
|
+
const [error, setError] = useState<string | null>(null);
|
|
27
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
28
|
+
|
|
29
|
+
const requireAcceptance = opts.requireAcceptance ?? true;
|
|
30
|
+
|
|
31
|
+
const submit = async () => {
|
|
32
|
+
setError(null);
|
|
33
|
+
if (!firstName.trim() || !lastName.trim()) {
|
|
34
|
+
setError("Please enter your name.");
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (!emailRe.test(email)) {
|
|
38
|
+
setError("Please enter a valid email.");
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (password.length < 8) {
|
|
42
|
+
setError("Password must be at least 8 characters.");
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (requireAcceptance && !acceptedTerms) {
|
|
46
|
+
setError("You must accept the Terms and Privacy Policy.");
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
setIsSubmitting(true);
|
|
50
|
+
try {
|
|
51
|
+
const { smartLinkPath } = await register({
|
|
52
|
+
email: email.trim(),
|
|
53
|
+
password,
|
|
54
|
+
firstName: firstName.trim(),
|
|
55
|
+
lastName: lastName.trim(),
|
|
56
|
+
profileId,
|
|
57
|
+
membershipTierId: opts.membershipTierId,
|
|
58
|
+
couponCode: opts.couponCode,
|
|
59
|
+
acceptedTerms: true,
|
|
60
|
+
acceptedPrivacy: true,
|
|
61
|
+
});
|
|
62
|
+
opts.onSuccess?.(smartLinkPath);
|
|
63
|
+
} catch (e) {
|
|
64
|
+
setError(msg(e) || "Could not create your account.");
|
|
65
|
+
} finally {
|
|
66
|
+
setIsSubmitting(false);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
firstName,
|
|
72
|
+
setFirstName,
|
|
73
|
+
lastName,
|
|
74
|
+
setLastName,
|
|
75
|
+
email,
|
|
76
|
+
setEmail,
|
|
77
|
+
password,
|
|
78
|
+
setPassword,
|
|
79
|
+
acceptedTerms,
|
|
80
|
+
setAcceptedTerms,
|
|
81
|
+
error,
|
|
82
|
+
isSubmitting,
|
|
83
|
+
submit,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { useMemo, useState } from "react";
|
|
2
|
+
import type { IBroadcastPass } from "../../../types/models";
|
|
3
|
+
import { useLiveBroadcasts, useValidateBroadcastPass } from "../../../data/queries/useBroadcasts";
|
|
4
|
+
|
|
5
|
+
const errMessage = (e: unknown) =>
|
|
6
|
+
(e as { response?: { data?: { message?: string } } })?.response?.data?.message || "Invalid pass.";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Headless broadcast watch: resolves a broadcast from the live list and gates it
|
|
10
|
+
* behind pass validation. Composes `useLiveBroadcasts` + `useValidateBroadcastPass`.
|
|
11
|
+
* Once `pass` is set the caller can mount its player with the returned playback info.
|
|
12
|
+
*/
|
|
13
|
+
export function useBroadcastWatch(broadcastId?: string) {
|
|
14
|
+
const broadcasts = useLiveBroadcasts();
|
|
15
|
+
const validatePass = useValidateBroadcastPass();
|
|
16
|
+
const [pass, setPass] = useState<IBroadcastPass | null>(null);
|
|
17
|
+
const [error, setError] = useState<string | null>(null);
|
|
18
|
+
|
|
19
|
+
const broadcast = useMemo(
|
|
20
|
+
() => broadcasts.data?.find((b) => b.id === broadcastId),
|
|
21
|
+
[broadcasts.data, broadcastId],
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
// Derive lifecycle from the broadcast timestamps (no explicit status field).
|
|
25
|
+
const isEnded = !!broadcast?.endedAt;
|
|
26
|
+
const isLive = !!broadcast?.startedAt && !broadcast?.endedAt;
|
|
27
|
+
const status: "scheduled" | "live" | "ended" | undefined = !broadcast
|
|
28
|
+
? undefined
|
|
29
|
+
: isEnded
|
|
30
|
+
? "ended"
|
|
31
|
+
: isLive
|
|
32
|
+
? "live"
|
|
33
|
+
: "scheduled";
|
|
34
|
+
|
|
35
|
+
const validate = async (eventPassId: string, sessionId?: string) => {
|
|
36
|
+
setError(null);
|
|
37
|
+
if (!broadcastId) return;
|
|
38
|
+
try {
|
|
39
|
+
const result = await validatePass.mutateAsync({ broadcastId, eventPassId, sessionId });
|
|
40
|
+
setPass(result);
|
|
41
|
+
return result;
|
|
42
|
+
} catch (e) {
|
|
43
|
+
setError(errMessage(e));
|
|
44
|
+
throw e;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
broadcast,
|
|
50
|
+
isLoading: broadcasts.isLoading,
|
|
51
|
+
status,
|
|
52
|
+
isLive,
|
|
53
|
+
isEnded,
|
|
54
|
+
pass,
|
|
55
|
+
validate,
|
|
56
|
+
isValidating: validatePass.isPending,
|
|
57
|
+
error,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ChatAttachment } from "../../../data/queries/useChat";
|
|
2
|
+
|
|
3
|
+
export type AttachmentKind = "image" | "video" | "audio" | "pdf" | "file";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Classify a chat attachment so the renderer can pick the right preview.
|
|
7
|
+
* Pure + dependency-free so every surface (forge / client / admin) shares one
|
|
8
|
+
* source of truth and can't drift.
|
|
9
|
+
*/
|
|
10
|
+
export function attachmentKind(a: Pick<ChatAttachment, "fileType" | "fileName">): AttachmentKind {
|
|
11
|
+
const type = (a.fileType || "").toLowerCase();
|
|
12
|
+
const name = (a.fileName || "").toLowerCase();
|
|
13
|
+
if (type.startsWith("image/")) return "image";
|
|
14
|
+
if (type.startsWith("video/")) return "video";
|
|
15
|
+
if (type.startsWith("audio/")) return "audio";
|
|
16
|
+
if (type === "application/pdf" || name.endsWith(".pdf")) return "pdf";
|
|
17
|
+
return "file";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Human-readable byte size, e.g. 2.4 MB. */
|
|
21
|
+
export function formatBytes(bytes: number): string {
|
|
22
|
+
if (!bytes || bytes < 0) return "0 B";
|
|
23
|
+
const units = ["B", "KB", "MB", "GB"];
|
|
24
|
+
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
|
25
|
+
const value = bytes / Math.pow(1024, i);
|
|
26
|
+
return `${value >= 10 || i === 0 ? Math.round(value) : value.toFixed(1)} ${units[i]}`;
|
|
27
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
import { useCallback, useRef, useState } from "react";
|
|
3
|
+
import { useForge } from "../../../provider/ForgeProvider";
|
|
4
|
+
import type { ChatAttachment } from "../../../data/queries/useChat";
|
|
5
|
+
import { attachmentKind, type AttachmentKind } from "./attachmentKind";
|
|
6
|
+
|
|
7
|
+
export interface PendingAttachment {
|
|
8
|
+
id: string;
|
|
9
|
+
file: File;
|
|
10
|
+
kind: AttachmentKind;
|
|
11
|
+
/** objectURL for image/video/audio local preview, revoked on clear/remove. */
|
|
12
|
+
previewUrl?: string;
|
|
13
|
+
progress: number;
|
|
14
|
+
status: "pending" | "uploading" | "done" | "error";
|
|
15
|
+
uploaded?: ChatAttachment;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const MAX_ATTACHMENTS = 10;
|
|
19
|
+
/** Max size for a single chat attachment (100MB). Mirrors the backend cap. */
|
|
20
|
+
export const MAX_ATTACHMENT_BYTES = 100 * 1024 * 1024;
|
|
21
|
+
let counter = 0;
|
|
22
|
+
const nextId = () => `att-${Date.now()}-${counter++}`;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Headless attachment composer for chat: stage local files (with previews),
|
|
26
|
+
* upload them to R2 via presigned URLs, and hand back `ChatAttachment[]` to send.
|
|
27
|
+
* Mirrors the admin `useUploadFiles` flow but Forge-native (uses the tenant client).
|
|
28
|
+
*/
|
|
29
|
+
export function useChatAttachmentUpload() {
|
|
30
|
+
const { client, profileId } = useForge();
|
|
31
|
+
const [pending, setPending] = useState<PendingAttachment[]>([]);
|
|
32
|
+
const [error, setError] = useState<string | null>(null);
|
|
33
|
+
const pendingRef = useRef<PendingAttachment[]>([]);
|
|
34
|
+
pendingRef.current = pending;
|
|
35
|
+
|
|
36
|
+
const patch = useCallback((id: string, updates: Partial<PendingAttachment>) => {
|
|
37
|
+
setPending((prev) => prev.map((p) => (p.id === id ? { ...p, ...updates } : p)));
|
|
38
|
+
}, []);
|
|
39
|
+
|
|
40
|
+
const addFiles = useCallback((files: FileList | File[]) => {
|
|
41
|
+
const incoming = Array.from(files);
|
|
42
|
+
const oversized = incoming.filter((f) => f.size > MAX_ATTACHMENT_BYTES);
|
|
43
|
+
const allowed = incoming.filter((f) => f.size <= MAX_ATTACHMENT_BYTES);
|
|
44
|
+
setError(
|
|
45
|
+
oversized.length
|
|
46
|
+
? `${oversized.length === 1 ? `"${oversized[0].name}" exceeds` : `${oversized.length} files exceed`} the 100MB limit and ${oversized.length === 1 ? "was" : "were"} skipped.`
|
|
47
|
+
: null,
|
|
48
|
+
);
|
|
49
|
+
setPending((prev) => {
|
|
50
|
+
const room = MAX_ATTACHMENTS - prev.length;
|
|
51
|
+
const next = allowed.slice(0, Math.max(0, room)).map((file) => {
|
|
52
|
+
const kind = attachmentKind({ fileType: file.type, fileName: file.name });
|
|
53
|
+
const previewUrl =
|
|
54
|
+
typeof URL !== "undefined" && (kind === "image" || kind === "video" || kind === "audio")
|
|
55
|
+
? URL.createObjectURL(file)
|
|
56
|
+
: undefined;
|
|
57
|
+
return { id: nextId(), file, kind, previewUrl, progress: 0, status: "pending" as const };
|
|
58
|
+
});
|
|
59
|
+
return [...prev, ...next];
|
|
60
|
+
});
|
|
61
|
+
}, []);
|
|
62
|
+
|
|
63
|
+
const removeFile = useCallback((id: string) => {
|
|
64
|
+
setPending((prev) => {
|
|
65
|
+
const target = prev.find((p) => p.id === id);
|
|
66
|
+
if (target?.previewUrl) URL.revokeObjectURL(target.previewUrl);
|
|
67
|
+
return prev.filter((p) => p.id !== id);
|
|
68
|
+
});
|
|
69
|
+
}, []);
|
|
70
|
+
|
|
71
|
+
const clear = useCallback(() => {
|
|
72
|
+
pendingRef.current.forEach((p) => p.previewUrl && URL.revokeObjectURL(p.previewUrl));
|
|
73
|
+
setPending([]);
|
|
74
|
+
setError(null);
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
/** Upload every staged file and return the uploaded attachments (send-ready). */
|
|
78
|
+
const uploadAll = useCallback(async (): Promise<ChatAttachment[]> => {
|
|
79
|
+
const items = pendingRef.current;
|
|
80
|
+
const uploaded: ChatAttachment[] = [];
|
|
81
|
+
|
|
82
|
+
for (const item of items) {
|
|
83
|
+
if (item.status === "done" && item.uploaded) {
|
|
84
|
+
uploaded.push(item.uploaded);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
patch(item.id, { status: "uploading", progress: 0 });
|
|
88
|
+
try {
|
|
89
|
+
// Member-scoped presigned endpoint. The admin `/uploads/presigned-url`
|
|
90
|
+
// route requires profile write permissions, which website members
|
|
91
|
+
// don't have — using it made file-only messages fail silently.
|
|
92
|
+
const { data } = await client.post<{ presignedUrl: string; remoteUrl: string }>(
|
|
93
|
+
"/public/chat/attachments/presigned-url",
|
|
94
|
+
{ fileName: item.file.name, profileId },
|
|
95
|
+
);
|
|
96
|
+
await axios.put(data.presignedUrl, item.file, {
|
|
97
|
+
headers: { "Content-Type": item.file.type || "application/octet-stream" },
|
|
98
|
+
onUploadProgress: (e) => {
|
|
99
|
+
const total = e.total ?? item.file.size;
|
|
100
|
+
if (total) patch(item.id, { progress: Math.round((e.loaded / total) * 100) });
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
const attachment: ChatAttachment = {
|
|
104
|
+
url: data.remoteUrl,
|
|
105
|
+
fileName: item.file.name,
|
|
106
|
+
fileSize: item.file.size,
|
|
107
|
+
fileType: item.file.type,
|
|
108
|
+
};
|
|
109
|
+
patch(item.id, { status: "done", progress: 100, uploaded: attachment });
|
|
110
|
+
uploaded.push(attachment);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
patch(item.id, { status: "error" });
|
|
113
|
+
setError(`Couldn't upload "${item.file.name}". Please try again.`);
|
|
114
|
+
throw err;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return uploaded;
|
|
119
|
+
}, [client, profileId, patch]);
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
pending,
|
|
123
|
+
error,
|
|
124
|
+
addFiles,
|
|
125
|
+
removeFile,
|
|
126
|
+
uploadAll,
|
|
127
|
+
clear,
|
|
128
|
+
isUploading: pending.some((p) => p.status === "uploading"),
|
|
129
|
+
hasPending: pending.length > 0,
|
|
130
|
+
canAddMore: pending.length < MAX_ATTACHMENTS,
|
|
131
|
+
};
|
|
132
|
+
}
|