@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,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
|
+
}
|