@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.
Files changed (255) hide show
  1. package/package.json +42 -0
  2. package/src/client/createForgeClient.ts +54 -0
  3. package/src/content/ForgeContentProvider.tsx +118 -0
  4. package/src/content/ForgeEditBridge.tsx +134 -0
  5. package/src/content/editFlag.ts +23 -0
  6. package/src/content/index.ts +28 -0
  7. package/src/content/resolve.ts +67 -0
  8. package/src/content/types.ts +97 -0
  9. package/src/content/useField.ts +64 -0
  10. package/src/contexts/AppAuthContext.tsx +185 -0
  11. package/src/contexts/AudioPlayerContext.tsx +389 -0
  12. package/src/contexts/CartContext.tsx +131 -0
  13. package/src/contexts/PublicAuthContext.tsx +333 -0
  14. package/src/contexts/useAppAdminGuard.ts +56 -0
  15. package/src/css.d.ts +5 -0
  16. package/src/data/collectionParams.ts +57 -0
  17. package/src/data/queries/useAccount.ts +71 -0
  18. package/src/data/queries/useAccountSettings.ts +82 -0
  19. package/src/data/queries/useAnalytics.ts +86 -0
  20. package/src/data/queries/useAuthActions.ts +28 -0
  21. package/src/data/queries/useBlog.ts +103 -0
  22. package/src/data/queries/useBlogComments.ts +96 -0
  23. package/src/data/queries/useBroadcasts.ts +31 -0
  24. package/src/data/queries/useCertificates.ts +58 -0
  25. package/src/data/queries/useChat.ts +267 -0
  26. package/src/data/queries/useCoachingAvailability.ts +69 -0
  27. package/src/data/queries/useCoachingProducts.ts +67 -0
  28. package/src/data/queries/useCohortPage.ts +17 -0
  29. package/src/data/queries/useCollections.ts +211 -0
  30. package/src/data/queries/useCommunity.ts +794 -0
  31. package/src/data/queries/useCoupons.ts +20 -0
  32. package/src/data/queries/useCourseAccess.ts +50 -0
  33. package/src/data/queries/useCourses.ts +91 -0
  34. package/src/data/queries/useCurrencies.ts +33 -0
  35. package/src/data/queries/useDocuments.ts +208 -0
  36. package/src/data/queries/useDonations.ts +29 -0
  37. package/src/data/queries/useEngagement.ts +115 -0
  38. package/src/data/queries/useEvents.ts +69 -0
  39. package/src/data/queries/useFinalize.ts +109 -0
  40. package/src/data/queries/useForms.ts +31 -0
  41. package/src/data/queries/useInvoice.ts +65 -0
  42. package/src/data/queries/useLeadMagnet.ts +96 -0
  43. package/src/data/queries/useMembership.ts +20 -0
  44. package/src/data/queries/useNotifications.ts +267 -0
  45. package/src/data/queries/useOffers.ts +56 -0
  46. package/src/data/queries/useOrders.ts +71 -0
  47. package/src/data/queries/usePWA.ts +101 -0
  48. package/src/data/queries/usePageActions.ts +36 -0
  49. package/src/data/queries/usePaymentFlow.ts +80 -0
  50. package/src/data/queries/usePaymentLink.ts +47 -0
  51. package/src/data/queries/usePodcast.ts +85 -0
  52. package/src/data/queries/usePostCollections.ts +48 -0
  53. package/src/data/queries/usePosts.ts +94 -0
  54. package/src/data/queries/useProducts.ts +108 -0
  55. package/src/data/queries/useReplay.ts +19 -0
  56. package/src/data/queries/useReviews.ts +122 -0
  57. package/src/data/queries/useShipping.ts +109 -0
  58. package/src/data/queries/useSubscriptions.ts +66 -0
  59. package/src/data/queries/useWebsite.ts +128 -0
  60. package/src/data/queries/useWebsiteAgent.ts +100 -0
  61. package/src/index.ts +98 -0
  62. package/src/provider/ForgeAppProvider.tsx +149 -0
  63. package/src/provider/ForgeProvider.tsx +91 -0
  64. package/src/provider/SiteConfigProvider.tsx +27 -0
  65. package/src/server/_tests/buildPwaHead.spec.ts +182 -0
  66. package/src/server/_tests/buildWebManifest.spec.ts +313 -0
  67. package/src/server/_tests/forgeServiceWorkerScript.spec.ts +251 -0
  68. package/src/server/index.ts +260 -0
  69. package/src/server/jobs.ts +104 -0
  70. package/src/server/pwa.ts +332 -0
  71. package/src/types/index.ts +74 -0
  72. package/src/types/models.ts +1313 -0
  73. package/src/ui/analytics/ForgeAnalytics.tsx +208 -0
  74. package/src/ui/analytics/PageMetaPixel.tsx +92 -0
  75. package/src/ui/content/Editable.tsx +90 -0
  76. package/src/ui/format/PriceDisplay.tsx +142 -0
  77. package/src/ui/format/useFormatCurrency.ts +92 -0
  78. package/src/ui/headless/agent/useAiAgent.ts +106 -0
  79. package/src/ui/headless/auth/useLoginFlow.ts +72 -0
  80. package/src/ui/headless/auth/useSignupForm.ts +85 -0
  81. package/src/ui/headless/broadcast/useBroadcastWatch.ts +59 -0
  82. package/src/ui/headless/chat/attachmentKind.ts +27 -0
  83. package/src/ui/headless/chat/useChatAttachmentUpload.ts +132 -0
  84. package/src/ui/headless/chat/useChatRoom.ts +223 -0
  85. package/src/ui/headless/chat/useMessageThread.ts +60 -0
  86. package/src/ui/headless/checkout/useCheckout.ts +438 -0
  87. package/src/ui/headless/coaching/useCoachingBooking.ts +223 -0
  88. package/src/ui/headless/community/index.ts +20 -0
  89. package/src/ui/headless/community/lexical.ts +131 -0
  90. package/src/ui/headless/community/replyTree.ts +51 -0
  91. package/src/ui/headless/community/richLexical.ts +314 -0
  92. package/src/ui/headless/community/useCommunityAttachmentUpload.ts +137 -0
  93. package/src/ui/headless/community/useCommunitySpaceFeed.ts +122 -0
  94. package/src/ui/headless/community/useCommunityThread.ts +95 -0
  95. package/src/ui/headless/community/useMentionComposer.ts +105 -0
  96. package/src/ui/headless/community/useRichMentionComposer.ts +193 -0
  97. package/src/ui/headless/consent/useCookieConsent.ts +81 -0
  98. package/src/ui/headless/course/useCourseCheckout.ts +99 -0
  99. package/src/ui/headless/dialog.tsx +14 -0
  100. package/src/ui/headless/document/index.ts +7 -0
  101. package/src/ui/headless/document/useContractSignature.ts +68 -0
  102. package/src/ui/headless/document/useDocumentSigningFlow.ts +92 -0
  103. package/src/ui/headless/document/useOtpCodeInput.ts +86 -0
  104. package/src/ui/headless/document/useSignaturePad.ts +180 -0
  105. package/src/ui/headless/donation/Donation.tsx +115 -0
  106. package/src/ui/headless/donation/DonationContext.tsx +223 -0
  107. package/src/ui/headless/donation/fees.ts +15 -0
  108. package/src/ui/headless/donation/index.ts +10 -0
  109. package/src/ui/headless/engagement/usePostEngagement.ts +119 -0
  110. package/src/ui/headless/event/useEventCheckout.ts +141 -0
  111. package/src/ui/headless/forms/useContactForm.ts +58 -0
  112. package/src/ui/headless/forms/useEmailListForm.ts +75 -0
  113. package/src/ui/headless/forms/useSectionedForm.ts +256 -0
  114. package/src/ui/headless/index.ts +60 -0
  115. package/src/ui/headless/invoice/useInvoicePayment.ts +48 -0
  116. package/src/ui/headless/memberHome/index.ts +7 -0
  117. package/src/ui/headless/memberHome/useMemberHomeNav.ts +82 -0
  118. package/src/ui/headless/membership/MembershipGate.tsx +39 -0
  119. package/src/ui/headless/membership/useMembershipCheckout.ts +121 -0
  120. package/src/ui/headless/offer/Offer.tsx +88 -0
  121. package/src/ui/headless/offer/OfferContext.tsx +162 -0
  122. package/src/ui/headless/offer/index.ts +8 -0
  123. package/src/ui/headless/paymentLink/usePaymentLinkPayment.ts +46 -0
  124. package/src/ui/headless/podcast/usePodcastPlayer.ts +96 -0
  125. package/src/ui/headless/posts/index.ts +13 -0
  126. package/src/ui/headless/posts/upsell.ts +41 -0
  127. package/src/ui/headless/posts/useMemberPostsFeed.ts +110 -0
  128. package/src/ui/headless/posts/usePostCollectionDetail.ts +56 -0
  129. package/src/ui/headless/posts/usePostCollectionsList.ts +90 -0
  130. package/src/ui/headless/posts/usePostItem.ts +81 -0
  131. package/src/ui/headless/reviews/index.ts +6 -0
  132. package/src/ui/headless/reviews/useHelpfulVote.ts +45 -0
  133. package/src/ui/headless/reviews/useReviewForm.ts +108 -0
  134. package/src/ui/headless/reviews/useReviewsList.ts +79 -0
  135. package/src/ui/headless/work/index.ts +25 -0
  136. package/src/ui/headless/work/useWorkPortal.ts +349 -0
  137. package/src/ui/index.ts +144 -0
  138. package/src/ui/payment/ForgePaymentProvider.tsx +45 -0
  139. package/src/ui/payment/ForgeStripePayment.tsx +171 -0
  140. package/src/ui/shell/TribeNestApp.tsx +121 -0
  141. package/src/ui/shell/shellGating.spec.ts +28 -0
  142. package/src/ui/shell/shellGating.ts +11 -0
  143. package/src/ui/styled/AccountDashboard.tsx +575 -0
  144. package/src/ui/styled/AiAgentWidget.tsx +152 -0
  145. package/src/ui/styled/AudioPlayer.tsx +123 -0
  146. package/src/ui/styled/BlogCategory.tsx +79 -0
  147. package/src/ui/styled/BlogComments.tsx +236 -0
  148. package/src/ui/styled/BlogList.tsx +101 -0
  149. package/src/ui/styled/BlogPost.tsx +160 -0
  150. package/src/ui/styled/BlogPostFooter.tsx +33 -0
  151. package/src/ui/styled/BotProtection.tsx +101 -0
  152. package/src/ui/styled/Button.tsx +88 -0
  153. package/src/ui/styled/Cart.tsx +276 -0
  154. package/src/ui/styled/ChatRoom.tsx +252 -0
  155. package/src/ui/styled/Checkout.tsx +988 -0
  156. package/src/ui/styled/CheckoutConfirmation.tsx +452 -0
  157. package/src/ui/styled/CoachingBooking.tsx +605 -0
  158. package/src/ui/styled/CoachingConfirmation.tsx +151 -0
  159. package/src/ui/styled/CoachingDetail.tsx +99 -0
  160. package/src/ui/styled/CohortPage.tsx +82 -0
  161. package/src/ui/styled/ConfirmSubscription.tsx +134 -0
  162. package/src/ui/styled/Confirmation.tsx +171 -0
  163. package/src/ui/styled/ContactForm.tsx +81 -0
  164. package/src/ui/styled/CookieConsent.tsx +239 -0
  165. package/src/ui/styled/CourseAccess.tsx +169 -0
  166. package/src/ui/styled/CourseCheckout.tsx +425 -0
  167. package/src/ui/styled/CourseConfirmation.tsx +191 -0
  168. package/src/ui/styled/CourseDetail.tsx +140 -0
  169. package/src/ui/styled/CoursesGrid.tsx +85 -0
  170. package/src/ui/styled/CurrencySwitcher.tsx +51 -0
  171. package/src/ui/styled/DonationButton.tsx +247 -0
  172. package/src/ui/styled/DonationPage.tsx +29 -0
  173. package/src/ui/styled/EmailListForm.tsx +88 -0
  174. package/src/ui/styled/EventConfirmation.tsx +228 -0
  175. package/src/ui/styled/EventCountdown.tsx +98 -0
  176. package/src/ui/styled/EventDetail.tsx +141 -0
  177. package/src/ui/styled/EventTickets.tsx +548 -0
  178. package/src/ui/styled/EventsList.tsx +83 -0
  179. package/src/ui/styled/ForgotPasswordForm.tsx +105 -0
  180. package/src/ui/styled/FormRenderer.tsx +249 -0
  181. package/src/ui/styled/InstallBanner.tsx +114 -0
  182. package/src/ui/styled/InvoiceConfirmation.tsx +50 -0
  183. package/src/ui/styled/InvoicePayment.tsx +141 -0
  184. package/src/ui/styled/LeadMagnet.tsx +93 -0
  185. package/src/ui/styled/Loading.tsx +56 -0
  186. package/src/ui/styled/LoginForm.tsx +134 -0
  187. package/src/ui/styled/Markdown.tsx +103 -0
  188. package/src/ui/styled/MembershipCheckout.tsx +197 -0
  189. package/src/ui/styled/MembershipTiers.tsx +115 -0
  190. package/src/ui/styled/OfferButton.tsx +181 -0
  191. package/src/ui/styled/PageActions.tsx +124 -0
  192. package/src/ui/styled/PaymentLinkConfirmation.tsx +50 -0
  193. package/src/ui/styled/PaymentLinkPayment.tsx +85 -0
  194. package/src/ui/styled/Paywall.tsx +54 -0
  195. package/src/ui/styled/PlayButton.tsx +48 -0
  196. package/src/ui/styled/PodcastEpisode.tsx +137 -0
  197. package/src/ui/styled/PodcastList.tsx +58 -0
  198. package/src/ui/styled/PodcastShow.tsx +164 -0
  199. package/src/ui/styled/PostsFeed.tsx +63 -0
  200. package/src/ui/styled/ProductDetail.tsx +616 -0
  201. package/src/ui/styled/ProductGrid.tsx +82 -0
  202. package/src/ui/styled/PushOptIn.tsx +65 -0
  203. package/src/ui/styled/PwaRegistration.tsx +58 -0
  204. package/src/ui/styled/ReactionBar.tsx +93 -0
  205. package/src/ui/styled/ReplayList.tsx +134 -0
  206. package/src/ui/styled/ResetPasswordForm.tsx +127 -0
  207. package/src/ui/styled/ReviewForm.tsx +292 -0
  208. package/src/ui/styled/ReviewsSection.tsx +260 -0
  209. package/src/ui/styled/SectionedFormRenderer.tsx +146 -0
  210. package/src/ui/styled/SignupForm.tsx +109 -0
  211. package/src/ui/styled/UserMenu.tsx +166 -0
  212. package/src/ui/styled/chat/ChatAttachments.tsx +172 -0
  213. package/src/ui/styled/chat/ChatComposer.tsx +153 -0
  214. package/src/ui/styled/chat/ChatThreadPanel.tsx +94 -0
  215. package/src/ui/styled/chat/MessageBubble.tsx +66 -0
  216. package/src/ui/styled/chat/NewDmDialog.tsx +78 -0
  217. package/src/ui/styled/community/CommunityComposer.tsx +358 -0
  218. package/src/ui/styled/community/CommunityFeed.tsx +483 -0
  219. package/src/ui/styled/community/CommunityLeaderboard.tsx +109 -0
  220. package/src/ui/styled/community/CommunityMediaGallery.tsx +116 -0
  221. package/src/ui/styled/community/CommunityMemberProfile.tsx +285 -0
  222. package/src/ui/styled/community/CommunityNotifications.tsx +222 -0
  223. package/src/ui/styled/community/CommunityPostCard.tsx +165 -0
  224. package/src/ui/styled/community/CommunityPostDetail.tsx +337 -0
  225. package/src/ui/styled/community/CommunityReactionBar.tsx +107 -0
  226. package/src/ui/styled/community/CommunityRichText.tsx +157 -0
  227. package/src/ui/styled/community/CommunitySpaces.tsx +174 -0
  228. package/src/ui/styled/community/MemberAvatar.tsx +74 -0
  229. package/src/ui/styled/community/index.ts +23 -0
  230. package/src/ui/styled/community/util.ts +15 -0
  231. package/src/ui/styled/members/MemberHomeLayout.tsx +98 -0
  232. package/src/ui/styled/members/MemberPostCard.tsx +392 -0
  233. package/src/ui/styled/members/MemberPostsFeed.tsx +219 -0
  234. package/src/ui/styled/members/PostCollectionDetail.tsx +227 -0
  235. package/src/ui/styled/members/PostCollectionsGrid.tsx +287 -0
  236. package/src/ui/styled/members/index.ts +7 -0
  237. package/src/ui/styled/rich-text.css +150 -0
  238. package/src/ui/styled/work/WorkInviteAccept.tsx +222 -0
  239. package/src/ui/styled/work/WorkPortalProjects.tsx +163 -0
  240. package/src/ui/styled/work/WorkProjectInvoices.tsx +113 -0
  241. package/src/ui/styled/work/WorkProjectReport.tsx +66 -0
  242. package/src/ui/styled/work/WorkReportBody.tsx +327 -0
  243. package/src/ui/styled/work/WorkTaskAttachments.tsx +67 -0
  244. package/src/ui/styled/work/WorkTaskComments.tsx +151 -0
  245. package/src/ui/styled/work/WorkTaskDetail.tsx +92 -0
  246. package/src/ui/styled/work/WorkTokenReport.tsx +49 -0
  247. package/src/ui/styled/work/index.ts +10 -0
  248. package/src/ui/theme/ForgeThemeProvider.tsx +121 -0
  249. package/src/ui/theme/contrast.ts +22 -0
  250. package/src/utils/analyticsBus.ts +31 -0
  251. package/src/utils/attribution.ts +68 -0
  252. package/src/utils/cookieConsent.ts +83 -0
  253. package/src/utils/landing.ts +153 -0
  254. package/src/utils/metaPixel.ts +122 -0
  255. package/src/utils/structuredData.ts +126 -0
@@ -0,0 +1,185 @@
1
+ "use client";
2
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
3
+ import { useForge } from "../provider/ForgeProvider";
4
+ import { createForgeClient } from "../client/createForgeClient";
5
+
6
+ // App-user auth for a mini-app frontend (account_associations type='app'). This
7
+ // is the app's OWN user base — distinct from TribeNest fans (usePublicAuth) and
8
+ // from the app's admins (useAppAdminGuard). It keeps an ISOLATED token + Axios
9
+ // client (its own localStorage key) so an app-user session never collides with a
10
+ // profile session on the same site. Backed by /public/app-sessions/*.
11
+
12
+ export const APP_ACCESS_TOKEN_KEY = "app-access-token";
13
+
14
+ export interface AppAuthUser {
15
+ id: string;
16
+ associationId: string;
17
+ email: string;
18
+ firstName: string | null;
19
+ lastName: string | null;
20
+ emailVerified: boolean;
21
+ status: string;
22
+ appId: string | null;
23
+ }
24
+
25
+ export interface AppSignupInput {
26
+ email: string;
27
+ password: string;
28
+ firstName: string;
29
+ lastName?: string;
30
+ }
31
+
32
+ export type AppSignupResult =
33
+ | { status: "pending"; user: AppAuthUser }
34
+ | { status: "active"; user: AppAuthUser };
35
+
36
+ interface AppAuthContextType {
37
+ appId?: string;
38
+ user: AppAuthUser | null;
39
+ isAuthenticated: boolean;
40
+ isInitialized: boolean;
41
+ isLoading: boolean;
42
+ errorMessage: string | null;
43
+ /** Sign up. When the app requires owner approval, returns status "pending" and
44
+ * does NOT log the user in (no token) — show a "pending approval" screen. */
45
+ signup: (input: AppSignupInput) => Promise<AppSignupResult>;
46
+ login: (input: { email: string; password: string }) => Promise<void>;
47
+ logout: () => Promise<void>;
48
+ refetch: () => Promise<void>;
49
+ clearError: () => void;
50
+ }
51
+
52
+ const AppAuthContext = createContext<AppAuthContextType | null>(null);
53
+
54
+ export function AppAuthProvider({ children }: { children: ReactNode }) {
55
+ const { apiUrl, appId, publishableKey } = useForge();
56
+
57
+ // Isolated token + client so app-user auth doesn't touch the shared Forge
58
+ // client (which carries the profile/member token).
59
+ const tokenRef = useRef<string | null>(null);
60
+ const client = useMemo(
61
+ () => createForgeClient({ baseURL: apiUrl, publishableKey, getToken: () => tokenRef.current }),
62
+ [apiUrl, publishableKey],
63
+ );
64
+
65
+ const [user, setUser] = useState<AppAuthUser | null>(null);
66
+ const [isInitialized, setIsInitialized] = useState(false);
67
+ const [isLoading, setIsLoading] = useState(false);
68
+ const [errorMessage, setErrorMessage] = useState<string | null>(null);
69
+
70
+ const setToken = useCallback((next: string | null) => {
71
+ tokenRef.current = next;
72
+ if (typeof localStorage === "undefined") return;
73
+ if (next) localStorage.setItem(APP_ACCESS_TOKEN_KEY, next);
74
+ else localStorage.removeItem(APP_ACCESS_TOKEN_KEY);
75
+ }, []);
76
+
77
+ const refetch = useCallback(async () => {
78
+ if (!tokenRef.current) {
79
+ setUser(null);
80
+ setIsInitialized(true);
81
+ return;
82
+ }
83
+ try {
84
+ const res = await client.get("/public/app-sessions/me");
85
+ setUser(res.data);
86
+ } catch {
87
+ setToken(null);
88
+ setUser(null);
89
+ } finally {
90
+ setIsInitialized(true);
91
+ }
92
+ }, [client, setToken]);
93
+
94
+ // Restore a stored token on mount and hydrate the user.
95
+ useEffect(() => {
96
+ const stored = typeof localStorage !== "undefined" ? localStorage.getItem(APP_ACCESS_TOKEN_KEY) : null;
97
+ tokenRef.current = stored;
98
+ refetch();
99
+ }, [refetch]);
100
+
101
+ const signup = useCallback<AppAuthContextType["signup"]>(
102
+ async (input) => {
103
+ if (!appId) throw new Error("AppAuthProvider: no appId in Forge context.");
104
+ setIsLoading(true);
105
+ setErrorMessage(null);
106
+ try {
107
+ const res = await client.post("/public/app-sessions/signup", { appId, ...input });
108
+ const { status, account, token } = res.data as { status: "pending" | "active"; account: AppAuthUser; token?: string };
109
+ if (status === "active" && token) {
110
+ setToken(token);
111
+ setUser(account);
112
+ return { status: "active", user: account };
113
+ }
114
+ // Pending approval — no token issued.
115
+ return { status: "pending", user: account };
116
+ } catch (err) {
117
+ setErrorMessage(extractError(err));
118
+ throw err;
119
+ } finally {
120
+ setIsLoading(false);
121
+ }
122
+ },
123
+ [appId, client, setToken],
124
+ );
125
+
126
+ const login = useCallback<AppAuthContextType["login"]>(
127
+ async (input) => {
128
+ if (!appId) throw new Error("AppAuthProvider: no appId in Forge context.");
129
+ setIsLoading(true);
130
+ setErrorMessage(null);
131
+ try {
132
+ const res = await client.post("/public/app-sessions", { appId, ...input });
133
+ setToken(res.data.token);
134
+ setUser(res.data.account);
135
+ } catch (err) {
136
+ setErrorMessage(extractError(err));
137
+ throw err;
138
+ } finally {
139
+ setIsLoading(false);
140
+ }
141
+ },
142
+ [appId, client, setToken],
143
+ );
144
+
145
+ const logout = useCallback<AppAuthContextType["logout"]>(async () => {
146
+ try {
147
+ await client.delete("/public/app-sessions");
148
+ } catch {
149
+ // best-effort — clear locally regardless
150
+ }
151
+ setToken(null);
152
+ setUser(null);
153
+ }, [client, setToken]);
154
+
155
+ const value = useMemo<AppAuthContextType>(
156
+ () => ({
157
+ appId,
158
+ user,
159
+ isAuthenticated: !!user,
160
+ isInitialized,
161
+ isLoading,
162
+ errorMessage,
163
+ signup,
164
+ login,
165
+ logout,
166
+ refetch,
167
+ clearError: () => setErrorMessage(null),
168
+ }),
169
+ [appId, user, isInitialized, isLoading, errorMessage, signup, login, logout, refetch],
170
+ );
171
+
172
+ return <AppAuthContext.Provider value={value}>{children}</AppAuthContext.Provider>;
173
+ }
174
+
175
+ /** Access the app-user auth state. Throws outside <ForgeProvider>. */
176
+ export const useAppAuth = (): AppAuthContextType => {
177
+ const ctx = useContext(AppAuthContext);
178
+ if (!ctx) throw new Error("useAppAuth must be used within a <ForgeProvider> (AppAuthProvider).");
179
+ return ctx;
180
+ };
181
+
182
+ function extractError(err: unknown): string {
183
+ const e = err as { response?: { data?: { message?: string } }; message?: string };
184
+ return e?.response?.data?.message ?? e?.message ?? "Something went wrong.";
185
+ }
@@ -0,0 +1,389 @@
1
+ "use client";
2
+ import { createContext, useContext, useState, useRef, useEffect, useCallback } from "react";
3
+ import type { ReactNode } from "react";
4
+
5
+ export interface AudioTrack {
6
+ id: string;
7
+ title: string;
8
+ artist?: string;
9
+ coverImage?: string;
10
+ url: string;
11
+ duration?: number;
12
+ productId?: string;
13
+ }
14
+
15
+ interface AudioPlayerContextType {
16
+ currentTrack: AudioTrack | null;
17
+ isPlaying: boolean;
18
+ currentTime: number;
19
+ duration: number;
20
+ isLoading: boolean;
21
+ error: string | null;
22
+ volume: number;
23
+ isMuted: boolean;
24
+ play: () => void;
25
+ pause: VoidFunction;
26
+ /** Stop playback, clear the current track + queue, and dismiss the player. */
27
+ stop: VoidFunction;
28
+ seek: (time: number) => void;
29
+ loadAndPlay: (track: AudioTrack, queue?: AudioTrack[]) => void;
30
+ setVolume: (volume: number) => void;
31
+ toggleMute: () => void;
32
+ playNext: () => void;
33
+ playPrevious: () => void;
34
+ queue: AudioTrack[];
35
+ }
36
+
37
+ const AudioPlayerContext = createContext<AudioPlayerContextType | undefined>(undefined);
38
+
39
+ export function AudioPlayerProvider({ children }: { children: ReactNode }) {
40
+ const [currentTrack, setCurrentTrack] = useState<AudioTrack | null>(null);
41
+ const [isPlaying, setIsPlaying] = useState(false);
42
+ const [currentTime, setCurrentTime] = useState(0);
43
+ const [duration, setDuration] = useState(0);
44
+ const [isLoading, setIsLoading] = useState(false);
45
+ const [error, setError] = useState<string | null>(null);
46
+ const [volume, setVolumeState] = useState(1);
47
+ const [isMuted, setIsMutedState] = useState(false);
48
+ const [previousVolume, setPreviousVolume] = useState(1);
49
+ const [queue, setQueue] = useState<AudioTrack[]>([]);
50
+ const [currentQueueIndex, setCurrentQueueIndex] = useState(-1);
51
+ const audioRef = useRef<HTMLAudioElement | null>(null);
52
+
53
+ const play = () => {
54
+ if (!audioRef.current || !currentTrack) return;
55
+
56
+ const audio = audioRef.current;
57
+
58
+ if (!isPlaying) {
59
+ audio
60
+ .play()
61
+ .catch(() => {
62
+ setError("Failed to play audio");
63
+ setIsPlaying(false);
64
+ })
65
+ .then(() => {
66
+ setIsPlaying(true);
67
+ });
68
+ }
69
+ };
70
+
71
+ const pause = useCallback(() => {
72
+ if (!audioRef.current || !currentTrack) return;
73
+
74
+ const audio = audioRef.current;
75
+ if (isPlaying) {
76
+ audio.pause();
77
+ setIsPlaying(false);
78
+ }
79
+ }, [currentTrack, isPlaying]);
80
+
81
+ const playPrevious = useCallback(() => {
82
+ if (queue.length === 0) return;
83
+
84
+ const prevIndex = currentQueueIndex > 0 ? currentQueueIndex - 1 : queue.length - 1;
85
+ const prevTrack = queue[prevIndex];
86
+
87
+ if (prevTrack) {
88
+ loadAndPlay(prevTrack, queue);
89
+ }
90
+ }, [queue, currentQueueIndex]);
91
+
92
+ // Setup media session with direct state access
93
+ const setupMediaSession = useCallback((track: AudioTrack, queue: AudioTrack[]) => {
94
+ if (!navigator.mediaSession) return;
95
+
96
+ const currentIndex = queue.findIndex((t) => t.id === track.id);
97
+ setCurrentQueueIndex(currentIndex);
98
+
99
+ navigator.mediaSession.metadata = new MediaMetadata({
100
+ title: track.title,
101
+ artist: track.artist || "Unknown Artist",
102
+ album: "Music",
103
+ artwork: track.coverImage
104
+ ? [
105
+ { src: track.coverImage, sizes: "512x512", type: "image/jpeg" },
106
+ { src: track.coverImage, sizes: "256x256", type: "image/jpeg" },
107
+ { src: track.coverImage, sizes: "128x128", type: "image/jpeg" },
108
+ ]
109
+ : undefined,
110
+ });
111
+
112
+ // Set action handlers with direct state access
113
+ navigator.mediaSession.setActionHandler("play", () => {
114
+ console.log("play initiated");
115
+ // Get current state directly from audio element
116
+ if (audioRef.current && audioRef.current.paused) {
117
+ audioRef.current
118
+ .play()
119
+ .catch(() => {
120
+ setError("Failed to play audio");
121
+ setIsPlaying(false);
122
+ })
123
+ .then(() => {
124
+ setIsPlaying(true);
125
+ });
126
+ }
127
+ });
128
+
129
+ navigator.mediaSession.setActionHandler("pause", () => {
130
+ console.log("pause initiated");
131
+ // Get current state directly from audio element
132
+ if (audioRef.current && !audioRef.current.paused) {
133
+ audioRef.current.pause();
134
+ setIsPlaying(false);
135
+ }
136
+ });
137
+
138
+ navigator.mediaSession.setActionHandler("previoustrack", () => {
139
+ console.log("previous initiated");
140
+ if (queue.length === 0) return;
141
+ const prevIndex = currentIndex > 0 ? currentIndex - 1 : queue.length - 1;
142
+ const prevTrack = queue[prevIndex];
143
+ if (prevTrack) {
144
+ loadAndPlay(prevTrack, queue);
145
+ }
146
+ });
147
+
148
+ navigator.mediaSession.setActionHandler("nexttrack", () => {
149
+ console.log("next initiated");
150
+ if (queue.length === 0) return;
151
+ const nextIndex = currentIndex < queue.length - 1 ? currentIndex + 1 : 0;
152
+ const nextTrack = queue[nextIndex];
153
+ if (nextTrack) {
154
+ loadAndPlay(nextTrack, queue);
155
+ }
156
+ });
157
+
158
+ navigator.mediaSession.setActionHandler("seekto", (details) => {
159
+ console.log("seekto initiated");
160
+ if (details.seekTime !== undefined && audioRef.current) {
161
+ audioRef.current.currentTime = details.seekTime;
162
+ }
163
+ });
164
+
165
+ navigator.mediaSession.setActionHandler("seekforward", (details) => {
166
+ console.log("seekforward initiated");
167
+ if (audioRef.current) {
168
+ const seekTime = Math.min(audioRef.current.currentTime + (details.seekOffset || 10), audioRef.current.duration);
169
+ audioRef.current.currentTime = seekTime;
170
+ }
171
+ });
172
+
173
+ navigator.mediaSession.setActionHandler("seekbackward", (details) => {
174
+ console.log("seekbackward initiated");
175
+ if (audioRef.current) {
176
+ const seekTime = Math.max(audioRef.current.currentTime - (details.seekOffset || 10), 0);
177
+ audioRef.current.currentTime = seekTime;
178
+ }
179
+ });
180
+
181
+ // Update playback state
182
+ navigator.mediaSession.playbackState = "playing";
183
+ }, []);
184
+
185
+ const loadAndPlay = useCallback(
186
+ async (track: AudioTrack, newQueue?: AudioTrack[]) => {
187
+ if (!audioRef.current) return;
188
+
189
+ setCurrentTrack(track);
190
+ setIsLoading(true);
191
+
192
+ const audio = audioRef.current;
193
+ audio.src = track.url;
194
+
195
+ // Update queue if provided
196
+ if (newQueue) {
197
+ setQueue(newQueue);
198
+ } else {
199
+ setQueue([]);
200
+ }
201
+
202
+ try {
203
+ await audio.play();
204
+ setIsPlaying(true);
205
+ setIsLoading(false);
206
+
207
+ // Setup media session after successful play
208
+ setupMediaSession(track, newQueue || queue);
209
+ } catch {
210
+ setError("Failed to play audio");
211
+ setIsPlaying(false);
212
+ setIsLoading(false);
213
+ }
214
+ },
215
+ [setupMediaSession, queue],
216
+ );
217
+
218
+ const playNext = useCallback(() => {
219
+ if (queue.length === 0) return;
220
+
221
+ const nextIndex = currentQueueIndex < queue.length - 1 ? currentQueueIndex + 1 : 0;
222
+ const nextTrack = queue[nextIndex];
223
+
224
+ if (nextTrack) {
225
+ loadAndPlay(nextTrack, queue);
226
+ }
227
+ }, [queue, currentQueueIndex, loadAndPlay]);
228
+
229
+ const handleEnded = useCallback(() => {
230
+ setIsPlaying(false);
231
+ setCurrentTime(0);
232
+
233
+ // Auto-play next track if available
234
+ if (queue.length > 0) {
235
+ playNext();
236
+ }
237
+ }, [queue, playNext]);
238
+
239
+ // Initialize audio element
240
+ useEffect(() => {
241
+ if (!audioRef.current) {
242
+ audioRef.current = new Audio();
243
+ audioRef.current.preload = "metadata";
244
+ audioRef.current.volume = volume;
245
+ }
246
+
247
+ const audio = audioRef.current;
248
+ audio.crossOrigin = "anonymous";
249
+
250
+ const handleLoadedMetadata = () => {
251
+ setDuration(audio.duration);
252
+ setIsLoading(false);
253
+ setError(null);
254
+ };
255
+
256
+ const handleTimeUpdate = () => {
257
+ setCurrentTime(audio.currentTime);
258
+
259
+ // Update media session position state
260
+ if (navigator.mediaSession && currentTrack && audio.duration && audio.currentTime) {
261
+ navigator.mediaSession.setPositionState({
262
+ duration: audio.duration,
263
+ position: audio.currentTime,
264
+ playbackRate: audio.playbackRate,
265
+ });
266
+ }
267
+ };
268
+
269
+ const handleError = () => {
270
+ setError("Failed to load audio");
271
+ setIsLoading(false);
272
+ };
273
+
274
+ const handleCanPlay = () => {
275
+ setIsLoading(false);
276
+ };
277
+
278
+ audio.addEventListener("loadedmetadata", handleLoadedMetadata);
279
+ audio.addEventListener("timeupdate", handleTimeUpdate);
280
+ audio.addEventListener("ended", handleEnded);
281
+ audio.addEventListener("error", handleError);
282
+ audio.addEventListener("canplay", handleCanPlay);
283
+
284
+ return () => {
285
+ audio.removeEventListener("loadedmetadata", handleLoadedMetadata);
286
+ audio.removeEventListener("timeupdate", handleTimeUpdate);
287
+ audio.removeEventListener("ended", handleEnded);
288
+ audio.removeEventListener("error", handleError);
289
+ audio.removeEventListener("canplay", handleCanPlay);
290
+ };
291
+ // eslint-disable-next-line react-hooks/exhaustive-deps
292
+ }, [handleEnded, currentTrack]);
293
+
294
+ // Update audio volume when volume state changes
295
+ useEffect(() => {
296
+ if (audioRef.current) {
297
+ audioRef.current.volume = isMuted ? 0 : volume;
298
+ }
299
+ }, [volume, isMuted]);
300
+
301
+ // Update media session playback state when playing state changes
302
+ useEffect(() => {
303
+ if (navigator.mediaSession) {
304
+ navigator.mediaSession.playbackState = isPlaying ? "playing" : "paused";
305
+ }
306
+ }, [isPlaying]);
307
+
308
+ const setVolume = (newVolume: number) => {
309
+ setVolumeState(newVolume);
310
+ setIsMutedState(newVolume === 0);
311
+ };
312
+
313
+ const toggleMute = () => {
314
+ if (isMuted) {
315
+ // Unmute
316
+ setIsMutedState(false);
317
+ setVolumeState(previousVolume);
318
+ } else {
319
+ // Mute
320
+ setPreviousVolume(volume);
321
+ setIsMutedState(true);
322
+ setVolumeState(0);
323
+ }
324
+ };
325
+
326
+ const seek = (time: number) => {
327
+ if (audioRef.current) {
328
+ audioRef.current.currentTime = time;
329
+ }
330
+ };
331
+
332
+ const stop = useCallback(() => {
333
+ if (audioRef.current) {
334
+ audioRef.current.pause();
335
+ audioRef.current.removeAttribute("src");
336
+ audioRef.current.load();
337
+ }
338
+ setIsPlaying(false);
339
+ setCurrentTrack(null);
340
+ setQueue([]);
341
+ setCurrentQueueIndex(-1);
342
+ setCurrentTime(0);
343
+ setDuration(0);
344
+ setError(null);
345
+ if (navigator.mediaSession) {
346
+ navigator.mediaSession.metadata = null;
347
+ navigator.mediaSession.playbackState = "none";
348
+ }
349
+ }, []);
350
+
351
+ // const destroy = () => {
352
+ // if (audioRef.current) {
353
+ // audioRef.current.pause();
354
+ // audioRef.current.src = "";
355
+ // audioRef.current = null;
356
+ // }
357
+ // };
358
+
359
+ const value: AudioPlayerContextType = {
360
+ currentTrack,
361
+ isPlaying,
362
+ currentTime,
363
+ duration,
364
+ isLoading,
365
+ error,
366
+ volume,
367
+ isMuted,
368
+ play,
369
+ pause,
370
+ stop,
371
+ seek,
372
+ loadAndPlay,
373
+ setVolume,
374
+ toggleMute,
375
+ playNext,
376
+ playPrevious,
377
+ queue,
378
+ };
379
+
380
+ return <AudioPlayerContext.Provider value={value}>{children}</AudioPlayerContext.Provider>;
381
+ }
382
+
383
+ export function useAudioPlayer() {
384
+ const context = useContext(AudioPlayerContext);
385
+ if (context === undefined) {
386
+ throw new Error("useAudioPlayer must be used within an AudioPlayerProvider");
387
+ }
388
+ return context;
389
+ }
@@ -0,0 +1,131 @@
1
+ "use client";
2
+ import type { ProductDeliveryType } from "../types/models";
3
+ import { createContext, useCallback, useContext, useState } from "react";
4
+ import type { ReactNode } from "react";
5
+ import { useEffect } from "react";
6
+
7
+ export type CartItem = {
8
+ productId: string;
9
+ productVariantId: string;
10
+ title: string;
11
+ price: number;
12
+ coverImage?: string;
13
+ isGift: boolean;
14
+ recipientName?: string;
15
+ recipientEmail?: string;
16
+ canIncreaseQuantity: boolean;
17
+ quantity: number;
18
+ recipientMessage?: string;
19
+ payWhatYouWant: boolean;
20
+ color?: string;
21
+ size?: string;
22
+ deliveryType?: ProductDeliveryType;
23
+ };
24
+
25
+ interface CartContextType {
26
+ cartItems: CartItem[];
27
+ addToCart: (item: CartItem) => boolean;
28
+ removeFromCart: (productId: string, isGift: boolean, recipientEmail?: string) => void;
29
+ clearCart: () => void;
30
+ isCartOpen: boolean;
31
+ setCartOpen: React.Dispatch<React.SetStateAction<boolean>>;
32
+ /** False until the cart has been read from storage after mount. Gate any
33
+ * "empty cart" UI on this so it doesn't flash before the saved cart loads. */
34
+ isReady: boolean;
35
+ }
36
+
37
+ const CartContext = createContext<CartContextType | undefined>(undefined);
38
+
39
+ const CART_STORAGE_KEY = "tribenest-cart";
40
+
41
+ export function CartProvider({ children }: { children: ReactNode }) {
42
+ const [cartItems, setCartItems] = useState<CartItem[]>([]);
43
+ const [isInitialized, setIsInitialized] = useState(false);
44
+ const [isCartOpen, setCartOpen] = useState(false);
45
+
46
+ // Load cart from localStorage on initial mount
47
+ useEffect(() => {
48
+ if (!isInitialized) {
49
+ try {
50
+ const savedCart = localStorage.getItem(CART_STORAGE_KEY);
51
+ if (savedCart) {
52
+ setCartItems(JSON.parse(savedCart));
53
+ }
54
+ } catch (error) {
55
+ console.error("Failed to load cart from localStorage:", error);
56
+ }
57
+ setIsInitialized(true);
58
+ }
59
+ }, [isInitialized]);
60
+
61
+ // Save cart to localStorage whenever it changes
62
+ useEffect(() => {
63
+ if (isInitialized) {
64
+ try {
65
+ localStorage.setItem(CART_STORAGE_KEY, JSON.stringify(cartItems));
66
+ } catch (error) {
67
+ console.error("Failed to save cart to localStorage:", error);
68
+ }
69
+ }
70
+ }, [cartItems, isInitialized]);
71
+
72
+ const addToCart = useCallback(
73
+ (item: CartItem): boolean => {
74
+ const exists = cartItems.some(
75
+ (i) =>
76
+ i.productId === item.productId &&
77
+ i.productVariantId === item.productVariantId &&
78
+ i.isGift === item.isGift &&
79
+ i.recipientEmail === item.recipientEmail,
80
+ );
81
+
82
+ if (exists) {
83
+ setCartItems((prev) =>
84
+ prev.map((i) => {
85
+ if (
86
+ i.productId === item.productId &&
87
+ i.productVariantId === item.productVariantId &&
88
+ i.isGift === item.isGift &&
89
+ i.recipientEmail === item.recipientEmail
90
+ ) {
91
+ return item;
92
+ } else {
93
+ return i;
94
+ }
95
+ }),
96
+ );
97
+ // Headless: signal the duplicate via the return value; the consumer
98
+ // decides whether to toast. (Was `toast.info("Item already in cart")`.)
99
+ return false;
100
+ }
101
+ setCartItems((prev) => [...prev, item]);
102
+ setCartOpen(true);
103
+ return true;
104
+ },
105
+ [cartItems],
106
+ );
107
+
108
+ const removeFromCart = (productVariantId: string, isGift: boolean, recipientEmail?: string) => {
109
+ setCartItems((prev) =>
110
+ prev.filter(
111
+ (i) => !(i.productVariantId === productVariantId && i.isGift === isGift && i.recipientEmail === recipientEmail),
112
+ ),
113
+ );
114
+ };
115
+
116
+ const clearCart = () => setCartItems([]);
117
+
118
+ return (
119
+ <CartContext.Provider
120
+ value={{ cartItems, addToCart, removeFromCart, clearCart, isCartOpen, setCartOpen, isReady: isInitialized }}
121
+ >
122
+ {children}
123
+ </CartContext.Provider>
124
+ );
125
+ }
126
+
127
+ export function useCart() {
128
+ const ctx = useContext(CartContext);
129
+ if (!ctx) throw new Error("useCart must be used within a CartProvider");
130
+ return ctx;
131
+ }