@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,82 @@
|
|
|
1
|
+
import { useGetProducts } from "../../data/queries/useProducts";
|
|
2
|
+
import type { IPublicProduct } from "../../types/models";
|
|
3
|
+
import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
4
|
+
import { useAmountFormatter } from "../format/useFormatCurrency";
|
|
5
|
+
import { PriceDisplay } from "../format/PriceDisplay";
|
|
6
|
+
import { usePricesIncludeTax } from "../../data/queries/useWebsite";
|
|
7
|
+
import { Loading } from "./Loading";
|
|
8
|
+
|
|
9
|
+
export interface ProductGridProps {
|
|
10
|
+
columns?: number;
|
|
11
|
+
limit?: number;
|
|
12
|
+
formatAmount?: (amount: number) => string;
|
|
13
|
+
onSelect?: (product: IPublicProduct) => void;
|
|
14
|
+
className?: string;
|
|
15
|
+
style?: React.CSSProperties;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function minPrice(p: IPublicProduct): number {
|
|
19
|
+
const prices = (p.variants ?? []).map((v) => v.price).filter((n) => typeof n === "number");
|
|
20
|
+
return prices.length ? Math.min(...prices) : 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Themed product grid, built on `useGetProducts`. */
|
|
24
|
+
export function ProductGrid({ columns = 3, limit, formatAmount, onSelect, className, style }: ProductGridProps) {
|
|
25
|
+
const t = useThemeTokens();
|
|
26
|
+
const fmt = useAmountFormatter(formatAmount);
|
|
27
|
+
const pricesIncludeTax = usePricesIncludeTax();
|
|
28
|
+
const { data, isLoading } = useGetProducts({ page: 1 });
|
|
29
|
+
const products = (data?.data ?? []).slice(0, limit ?? 100);
|
|
30
|
+
|
|
31
|
+
if (isLoading) return <Loading />;
|
|
32
|
+
if (!products.length) return <p style={{ color: t.text, opacity: 0.7 }}>No products yet.</p>;
|
|
33
|
+
|
|
34
|
+
// Responsive: each card is at least ~150px wide, but also at least
|
|
35
|
+
// (container / columns), so it renders `columns` across on wide screens and
|
|
36
|
+
// wraps to fewer on narrow ones — no fixed column count that cramps mobile.
|
|
37
|
+
const gap = 16;
|
|
38
|
+
const track = `minmax(max(150px, calc((100% - ${(columns - 1) * gap}px) / ${columns})), 1fr)`;
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div
|
|
42
|
+
className={className}
|
|
43
|
+
style={{
|
|
44
|
+
display: "grid",
|
|
45
|
+
gridTemplateColumns: `repeat(auto-fill, ${track})`,
|
|
46
|
+
gap,
|
|
47
|
+
fontFamily: t.fontFamily,
|
|
48
|
+
...style,
|
|
49
|
+
}}
|
|
50
|
+
>
|
|
51
|
+
{products.map((p) => {
|
|
52
|
+
const cover = p.media?.find((m) => m.type === "image")?.url ?? p.media?.[0]?.url;
|
|
53
|
+
return (
|
|
54
|
+
<button
|
|
55
|
+
key={p.id}
|
|
56
|
+
onClick={() => onSelect?.(p)}
|
|
57
|
+
style={{
|
|
58
|
+
textAlign: "left",
|
|
59
|
+
cursor: onSelect ? "pointer" : "default",
|
|
60
|
+
background: t.surface,
|
|
61
|
+
border: `1px solid ${t.primary}20`,
|
|
62
|
+
borderRadius: t.cornerRadius,
|
|
63
|
+
overflow: "hidden",
|
|
64
|
+
padding: 0,
|
|
65
|
+
color: t.text,
|
|
66
|
+
}}
|
|
67
|
+
>
|
|
68
|
+
<div style={{ aspectRatio: "1 / 1", background: `${t.primary}10` }}>
|
|
69
|
+
{cover && <img src={cover} alt={p.title} style={{ width: "100%", height: "100%", objectFit: "cover", objectPosition: "top" }} />}
|
|
70
|
+
</div>
|
|
71
|
+
<div style={{ padding: 10 }}>
|
|
72
|
+
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 2 }}>{p.title}</div>
|
|
73
|
+
<div style={{ color: t.primary, fontWeight: 600, fontSize: 14 }}>
|
|
74
|
+
<PriceDisplay amount={minPrice(p)} pricesIncludeTax={pricesIncludeTax} formatAmount={fmt} mutedColor={t.text} captionStyle={{ opacity: 0.65 }} />
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
</button>
|
|
78
|
+
);
|
|
79
|
+
})}
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { useForge } from "../../provider/ForgeProvider";
|
|
2
|
+
import { usePublicAuth } from "../../contexts/PublicAuthContext";
|
|
3
|
+
import { usePushSubscription } from "../../data/queries/useNotifications";
|
|
4
|
+
import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
5
|
+
import { Button } from "./Button";
|
|
6
|
+
|
|
7
|
+
export interface PushOptInProps {
|
|
8
|
+
/** Row heading. */
|
|
9
|
+
title?: string;
|
|
10
|
+
/** Supporting copy. */
|
|
11
|
+
description?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A small opt-in row for push notifications, for account/membership pages. Uses
|
|
16
|
+
* the FROZEN `usePushSubscription()` contract (baked `profileId`, fan auth token
|
|
17
|
+
* flows automatically). Subscribe requires a logged-in fan (the subscribe API is
|
|
18
|
+
* fan-authenticated), so this renders nothing when unauthenticated or when push
|
|
19
|
+
* isn't supported. On iOS, push works only from the installed PWA (16.4+) — pair
|
|
20
|
+
* with `<InstallBanner/>`.
|
|
21
|
+
*
|
|
22
|
+
* The membership-notifications spec's full preference UI supersedes this row on
|
|
23
|
+
* `/i/account` later; this is the minimal subscription primitive it plugs into.
|
|
24
|
+
*/
|
|
25
|
+
export function PushOptIn({
|
|
26
|
+
title = "Push notifications",
|
|
27
|
+
description = "Get notified about new posts, replies, and messages.",
|
|
28
|
+
}: PushOptInProps) {
|
|
29
|
+
const t = useThemeTokens();
|
|
30
|
+
const { profileId } = useForge();
|
|
31
|
+
const { isAuthenticated } = usePublicAuth();
|
|
32
|
+
const { isSupported, isSubscribed, isLoading, subscribe, unsubscribe } = usePushSubscription(profileId ?? "");
|
|
33
|
+
|
|
34
|
+
if (!isAuthenticated || !isSupported || !profileId) return null;
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div
|
|
38
|
+
style={{
|
|
39
|
+
display: "flex",
|
|
40
|
+
alignItems: "center",
|
|
41
|
+
justifyContent: "space-between",
|
|
42
|
+
gap: 16,
|
|
43
|
+
padding: 16,
|
|
44
|
+
borderRadius: t.cornerRadius,
|
|
45
|
+
border: `1px solid ${t.border}`,
|
|
46
|
+
background: t.surface,
|
|
47
|
+
color: t.text,
|
|
48
|
+
}}
|
|
49
|
+
>
|
|
50
|
+
<div>
|
|
51
|
+
<div style={{ fontWeight: 600, fontSize: 15 }}>{title}</div>
|
|
52
|
+
<div style={{ fontSize: 13, color: t.muted, marginTop: 2 }}>{description}</div>
|
|
53
|
+
</div>
|
|
54
|
+
{isSubscribed ? (
|
|
55
|
+
<Button size="sm" variant="outline" loading={isLoading} onClick={() => unsubscribe()}>
|
|
56
|
+
Turn off
|
|
57
|
+
</Button>
|
|
58
|
+
) : (
|
|
59
|
+
<Button size="sm" loading={isLoading} onClick={() => subscribe()}>
|
|
60
|
+
Enable
|
|
61
|
+
</Button>
|
|
62
|
+
)}
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
|
|
3
|
+
export interface PwaRegistrationProps {
|
|
4
|
+
/**
|
|
5
|
+
* Register the service worker only for the live published site. The starter
|
|
6
|
+
* passes `enabled={!FORGE_EDIT && FORGE_STATE === "published"}` so the HMR
|
|
7
|
+
* edit sandbox and `preview-<versionId>.*` review Workers (deployed with
|
|
8
|
+
* state:"draft") never install a SW or cache a preview. Defaults to `false`
|
|
9
|
+
* so an un-gated mount is a no-op, never an accidental prod SW.
|
|
10
|
+
*/
|
|
11
|
+
enabled?: boolean;
|
|
12
|
+
/** Path the SW is served from (default `/sw.js`, scope `/`). */
|
|
13
|
+
swUrl?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Registers the Forge service worker (`/sw.js`) on the published site. Renders
|
|
18
|
+
* nothing. The SW source lives in `@tribe-nest/forge/server`
|
|
19
|
+
* (`forgeServiceWorkerScript()`) and is served by the starter's `/sw.js` route,
|
|
20
|
+
* so a Forge version bump propagates SW fixes to every deployed site — never
|
|
21
|
+
* frozen into per-site files.
|
|
22
|
+
*
|
|
23
|
+
* Gating (locked decision): registration is skipped unless `enabled` is true,
|
|
24
|
+
* so reviewers on a preview Worker or the HMR sandbox are never SW-cached or
|
|
25
|
+
* install-prompted. Root-path SW ⇒ scope `/` with no `Service-Worker-Allowed`
|
|
26
|
+
* games; each origin (subdomain + custom domains) registers independently.
|
|
27
|
+
*/
|
|
28
|
+
export function PwaRegistration({ enabled = false, swUrl = "/sw.js" }: PwaRegistrationProps) {
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (!enabled) return;
|
|
31
|
+
if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) return;
|
|
32
|
+
let cancelled = false;
|
|
33
|
+
const register = () => {
|
|
34
|
+
navigator.serviceWorker.register(swUrl, { scope: "/" }).catch((err) => {
|
|
35
|
+
// Registration failure must never break the page.
|
|
36
|
+
console.error("SW registration failed:", err);
|
|
37
|
+
});
|
|
38
|
+
};
|
|
39
|
+
// Register after load so it never contends with the initial navigation.
|
|
40
|
+
if (document.readyState === "complete") {
|
|
41
|
+
if (!cancelled) register();
|
|
42
|
+
} else {
|
|
43
|
+
const onLoad = () => {
|
|
44
|
+
if (!cancelled) register();
|
|
45
|
+
};
|
|
46
|
+
window.addEventListener("load", onLoad, { once: true });
|
|
47
|
+
return () => {
|
|
48
|
+
cancelled = true;
|
|
49
|
+
window.removeEventListener("load", onLoad);
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
return () => {
|
|
53
|
+
cancelled = true;
|
|
54
|
+
};
|
|
55
|
+
}, [enabled, swUrl]);
|
|
56
|
+
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { usePostEngagement, type UsePostEngagementOptions } from "../headless/engagement/usePostEngagement";
|
|
3
|
+
import type { EngagementEntityType } from "../../types/models";
|
|
4
|
+
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
5
|
+
import { readableTextOn } from "../theme/contrast";
|
|
6
|
+
|
|
7
|
+
export interface ReactionBarProps extends UsePostEngagementOptions {
|
|
8
|
+
entityId: string;
|
|
9
|
+
entityType?: EngagementEntityType;
|
|
10
|
+
showComments?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Themed like / save / comment bar built on `usePostEngagement`. */
|
|
14
|
+
export function ReactionBar({ entityId, entityType = "post", showComments = true, ...opts }: ReactionBarProps) {
|
|
15
|
+
const theme = useForgeTheme();
|
|
16
|
+
const e = usePostEngagement(entityId, entityType, opts);
|
|
17
|
+
const [draft, setDraft] = useState("");
|
|
18
|
+
|
|
19
|
+
const iconBtn = {
|
|
20
|
+
display: "inline-flex",
|
|
21
|
+
alignItems: "center",
|
|
22
|
+
gap: 6,
|
|
23
|
+
background: "transparent",
|
|
24
|
+
border: "none",
|
|
25
|
+
cursor: "pointer",
|
|
26
|
+
color: theme.colors.text,
|
|
27
|
+
fontSize: 15,
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div style={{ color: theme.colors.text, fontFamily: theme.fontFamily }}>
|
|
32
|
+
<div style={{ display: "flex", gap: 20, alignItems: "center" }}>
|
|
33
|
+
<button onClick={e.toggleLike} style={iconBtn} aria-pressed={e.isLiked}>
|
|
34
|
+
<span style={{ color: e.isLiked ? theme.colors.primary : "inherit" }}>{e.isLiked ? "♥" : "♡"}</span>
|
|
35
|
+
{e.likeCount}
|
|
36
|
+
</button>
|
|
37
|
+
|
|
38
|
+
{showComments && (
|
|
39
|
+
<button onClick={e.openComments} style={iconBtn}>
|
|
40
|
+
💬 {e.commentCount}
|
|
41
|
+
</button>
|
|
42
|
+
)}
|
|
43
|
+
|
|
44
|
+
<button onClick={e.toggleSave} style={iconBtn} aria-pressed={e.isSaved}>
|
|
45
|
+
<span style={{ color: e.isSaved ? theme.colors.primary : "inherit" }}>{e.isSaved ? "★" : "☆"}</span>
|
|
46
|
+
</button>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
{showComments && e.showComments && (
|
|
50
|
+
<div style={{ marginTop: 12, display: "flex", flexDirection: "column", gap: 8 }}>
|
|
51
|
+
{e.comments.map((c) => (
|
|
52
|
+
<div key={c.id} style={{ fontSize: 14 }}>
|
|
53
|
+
<strong>{c.fullName}</strong> {c.content}
|
|
54
|
+
</div>
|
|
55
|
+
))}
|
|
56
|
+
<div style={{ display: "flex", gap: 8 }}>
|
|
57
|
+
<input
|
|
58
|
+
value={draft}
|
|
59
|
+
onChange={(ev) => setDraft(ev.target.value)}
|
|
60
|
+
placeholder="Add a comment…"
|
|
61
|
+
style={{
|
|
62
|
+
flex: 1,
|
|
63
|
+
padding: 8,
|
|
64
|
+
border: `1px solid ${theme.colors.primary}40`,
|
|
65
|
+
borderRadius: theme.cornerRadius,
|
|
66
|
+
background: theme.colors.background,
|
|
67
|
+
color: theme.colors.text,
|
|
68
|
+
}}
|
|
69
|
+
/>
|
|
70
|
+
<button
|
|
71
|
+
onClick={async () => {
|
|
72
|
+
await e.addComment(draft);
|
|
73
|
+
setDraft("");
|
|
74
|
+
}}
|
|
75
|
+
disabled={e.isAddingComment || !draft.trim()}
|
|
76
|
+
style={{
|
|
77
|
+
background: theme.colors.primary,
|
|
78
|
+
color: readableTextOn(theme.colors.primary),
|
|
79
|
+
border: "none",
|
|
80
|
+
borderRadius: theme.cornerRadius,
|
|
81
|
+
padding: "8px 14px",
|
|
82
|
+
cursor: "pointer",
|
|
83
|
+
opacity: e.isAddingComment || !draft.trim() ? 0.6 : 1,
|
|
84
|
+
}}
|
|
85
|
+
>
|
|
86
|
+
Post
|
|
87
|
+
</button>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
)}
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { useReplayBroadcasts } from "../../data/queries/useReplay";
|
|
3
|
+
import type { ReplayBroadcast } from "../../types/models";
|
|
4
|
+
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
5
|
+
import { Loading } from "./Loading";
|
|
6
|
+
|
|
7
|
+
export interface ReplayListProps {
|
|
8
|
+
/** Ticket id gating access to the replay broadcasts (from the URL search param). */
|
|
9
|
+
ticketId?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Locale date-time formatting. */
|
|
13
|
+
function formatDateTime(value: string | number | Date, timezone?: string): string {
|
|
14
|
+
return new Date(value).toLocaleString("en-US", {
|
|
15
|
+
weekday: "long",
|
|
16
|
+
year: "numeric",
|
|
17
|
+
month: "long",
|
|
18
|
+
day: "numeric",
|
|
19
|
+
hour: "numeric",
|
|
20
|
+
minute: "2-digit",
|
|
21
|
+
hour12: true,
|
|
22
|
+
timeZone: timezone || "UTC",
|
|
23
|
+
timeZoneName: "short",
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Replay broadcasts gallery, gated by ticket id. Built on `useReplayBroadcasts`. */
|
|
28
|
+
export function ReplayList({ ticketId }: ReplayListProps) {
|
|
29
|
+
const theme = useForgeTheme();
|
|
30
|
+
const [selected, setSelected] = useState<ReplayBroadcast | null>(null);
|
|
31
|
+
|
|
32
|
+
const { data, isLoading, error } = useReplayBroadcasts(ticketId);
|
|
33
|
+
|
|
34
|
+
if (!ticketId) {
|
|
35
|
+
return (
|
|
36
|
+
<div style={{ maxWidth: 520, margin: "0 auto", textAlign: "center", color: theme.colors.text }}>
|
|
37
|
+
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 12 }}>Enter Your Ticket ID</h1>
|
|
38
|
+
<p style={{ opacity: 0.7 }}>
|
|
39
|
+
Add your ticket id to the URL, e.g. <code>/i/replay?ticketId=<ticketId></code>.
|
|
40
|
+
</p>
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (isLoading) return <Loading fullPage />;
|
|
46
|
+
|
|
47
|
+
if (error || !data) {
|
|
48
|
+
return (
|
|
49
|
+
<p style={{ color: theme.colors.text }}>
|
|
50
|
+
Invalid ticket id or no broadcasts found. Please check your ticket id and try again.
|
|
51
|
+
</p>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (selected) {
|
|
56
|
+
return (
|
|
57
|
+
<div style={{ maxWidth: 960, margin: "0 auto", color: theme.colors.text }}>
|
|
58
|
+
<button
|
|
59
|
+
onClick={() => setSelected(null)}
|
|
60
|
+
style={{
|
|
61
|
+
marginBottom: 16,
|
|
62
|
+
cursor: "pointer",
|
|
63
|
+
background: "none",
|
|
64
|
+
border: "none",
|
|
65
|
+
color: theme.colors.primary,
|
|
66
|
+
fontSize: 14,
|
|
67
|
+
}}
|
|
68
|
+
>
|
|
69
|
+
← Back to broadcasts
|
|
70
|
+
</button>
|
|
71
|
+
<div style={{ borderRadius: theme.cornerRadius, overflow: "hidden", border: `1px solid ${theme.colors.text}1a` }}>
|
|
72
|
+
{selected.vodUrl ? (
|
|
73
|
+
<video src={selected.vodUrl} controls playsInline style={{ width: "100%", background: "#000" }} />
|
|
74
|
+
) : (
|
|
75
|
+
<div style={{ aspectRatio: "16/9", display: "flex", alignItems: "center", justifyContent: "center", background: "#000", color: "#fff" }}>
|
|
76
|
+
Video not available
|
|
77
|
+
</div>
|
|
78
|
+
)}
|
|
79
|
+
<div style={{ padding: 24 }}>
|
|
80
|
+
<h1 style={{ fontSize: 22, fontWeight: 700 }}>{selected.title}</h1>
|
|
81
|
+
{selected.endedAt && (
|
|
82
|
+
<p style={{ fontSize: 13, opacity: 0.7, marginTop: 4 }}>Ended {formatDateTime(selected.endedAt)}</p>
|
|
83
|
+
)}
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (data.broadcasts.length === 0) {
|
|
91
|
+
return <p style={{ color: theme.colors.text }}>No replay broadcasts available for this ticket.</p>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<div style={{ maxWidth: 960, margin: "0 auto", color: theme.colors.text }}>
|
|
96
|
+
<h1 style={{ fontSize: 28, fontWeight: 700, marginBottom: 24 }}>Replay Broadcasts</h1>
|
|
97
|
+
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))", gap: 16 }}>
|
|
98
|
+
{data.broadcasts.map((broadcast) => {
|
|
99
|
+
const thumb = broadcast.thumbnailUrl || broadcast.generatedThumbnailUrl;
|
|
100
|
+
return (
|
|
101
|
+
<button
|
|
102
|
+
key={broadcast.id}
|
|
103
|
+
onClick={() => setSelected(broadcast)}
|
|
104
|
+
style={{
|
|
105
|
+
textAlign: "left",
|
|
106
|
+
cursor: "pointer",
|
|
107
|
+
padding: 0,
|
|
108
|
+
background: "none",
|
|
109
|
+
color: theme.colors.text,
|
|
110
|
+
border: `1px solid ${theme.colors.text}1a`,
|
|
111
|
+
borderRadius: theme.cornerRadius,
|
|
112
|
+
overflow: "hidden",
|
|
113
|
+
}}
|
|
114
|
+
>
|
|
115
|
+
<div style={{ aspectRatio: "16/9", background: "#000", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
|
116
|
+
{thumb ? (
|
|
117
|
+
<img src={thumb} alt={broadcast.title} style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
|
118
|
+
) : (
|
|
119
|
+
<span style={{ color: "#fff", opacity: 0.6 }}>▶</span>
|
|
120
|
+
)}
|
|
121
|
+
</div>
|
|
122
|
+
<div style={{ padding: 16 }}>
|
|
123
|
+
<h3 style={{ fontWeight: 600 }}>{broadcast.title}</h3>
|
|
124
|
+
{broadcast.startedAt && (
|
|
125
|
+
<p style={{ fontSize: 12, opacity: 0.7, marginTop: 4 }}>{formatDateTime(broadcast.startedAt)}</p>
|
|
126
|
+
)}
|
|
127
|
+
</div>
|
|
128
|
+
</button>
|
|
129
|
+
);
|
|
130
|
+
})}
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { useResetPassword } from "../../data/queries/useAuthActions";
|
|
3
|
+
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
4
|
+
|
|
5
|
+
export interface ResetPasswordFormProps {
|
|
6
|
+
/** Reset token from the email link (read from search params by the shell). */
|
|
7
|
+
token?: string;
|
|
8
|
+
/** Href for the "Back to login" link (shown on success). */
|
|
9
|
+
loginHref: string;
|
|
10
|
+
/** Href for the "Request a new reset link" link (shown when token is missing). */
|
|
11
|
+
forgotHref: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const msg = (e: unknown) => (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
|
15
|
+
|
|
16
|
+
/** Themed reset-password form, built on `useResetPassword`. */
|
|
17
|
+
export function ResetPasswordForm({ token, loginHref, forgotHref }: ResetPasswordFormProps) {
|
|
18
|
+
const theme = useForgeTheme();
|
|
19
|
+
const resetPassword = useResetPassword();
|
|
20
|
+
const [password, setPassword] = useState("");
|
|
21
|
+
const [confirmPassword, setConfirmPassword] = useState("");
|
|
22
|
+
const [error, setError] = useState("");
|
|
23
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
24
|
+
const [isSuccess, setIsSuccess] = useState(false);
|
|
25
|
+
|
|
26
|
+
const card: React.CSSProperties = {
|
|
27
|
+
maxWidth: 420,
|
|
28
|
+
margin: "40px auto 0",
|
|
29
|
+
padding: 24,
|
|
30
|
+
border: `1px solid ${theme.colors.primary}40`,
|
|
31
|
+
borderRadius: theme.cornerRadius,
|
|
32
|
+
color: theme.colors.text,
|
|
33
|
+
};
|
|
34
|
+
const input: React.CSSProperties = {
|
|
35
|
+
width: "100%",
|
|
36
|
+
height: 40,
|
|
37
|
+
padding: "8px 12px",
|
|
38
|
+
borderRadius: theme.cornerRadius,
|
|
39
|
+
border: `1px solid ${theme.colors.primary}40`,
|
|
40
|
+
background: "transparent",
|
|
41
|
+
color: theme.colors.text,
|
|
42
|
+
outline: "none",
|
|
43
|
+
};
|
|
44
|
+
const button: React.CSSProperties = {
|
|
45
|
+
width: "100%",
|
|
46
|
+
padding: "10px 14px",
|
|
47
|
+
borderRadius: theme.cornerRadius,
|
|
48
|
+
background: theme.colors.primary,
|
|
49
|
+
color: theme.colors.background,
|
|
50
|
+
border: "none",
|
|
51
|
+
fontWeight: 600,
|
|
52
|
+
cursor: "pointer",
|
|
53
|
+
};
|
|
54
|
+
const link: React.CSSProperties = { color: theme.colors.primary, textDecoration: "underline" };
|
|
55
|
+
|
|
56
|
+
if (!token) {
|
|
57
|
+
return (
|
|
58
|
+
<div style={card}>
|
|
59
|
+
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 12 }}>Invalid link</h1>
|
|
60
|
+
<p style={{ fontSize: 14, marginBottom: 24 }}>This password reset link is invalid or has expired.</p>
|
|
61
|
+
<a href={forgotHref} style={link}>
|
|
62
|
+
Request a new reset link
|
|
63
|
+
</a>
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (isSuccess) {
|
|
69
|
+
return (
|
|
70
|
+
<div style={card}>
|
|
71
|
+
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 12 }}>Password reset</h1>
|
|
72
|
+
<p style={{ fontSize: 14, marginBottom: 24 }}>Your password has been reset successfully.</p>
|
|
73
|
+
<a href={loginHref} style={link}>
|
|
74
|
+
Back to login
|
|
75
|
+
</a>
|
|
76
|
+
</div>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const onSubmit = async (e: React.FormEvent) => {
|
|
81
|
+
e.preventDefault();
|
|
82
|
+
setError("");
|
|
83
|
+
if (password.length < 8) {
|
|
84
|
+
setError("Password must be at least 8 characters.");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (password !== confirmPassword) {
|
|
88
|
+
setError("Passwords don't match.");
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
setIsSubmitting(true);
|
|
92
|
+
try {
|
|
93
|
+
await resetPassword.mutateAsync({ token, password });
|
|
94
|
+
setIsSuccess(true);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
setError(msg(err) || "Something went wrong. Please try again.");
|
|
97
|
+
} finally {
|
|
98
|
+
setIsSubmitting(false);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<div style={card}>
|
|
104
|
+
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 24 }}>Reset password</h1>
|
|
105
|
+
{error && <p style={{ color: "#ef4444", marginBottom: 16 }}>{error}</p>}
|
|
106
|
+
<form onSubmit={onSubmit} style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
|
107
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
|
108
|
+
<label style={{ fontSize: 14 }}>Password</label>
|
|
109
|
+
<input type="password" placeholder="Password" value={password} onChange={(e) => setPassword(e.target.value)} style={input} />
|
|
110
|
+
</div>
|
|
111
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
|
112
|
+
<label style={{ fontSize: 14 }}>Confirm password</label>
|
|
113
|
+
<input
|
|
114
|
+
type="password"
|
|
115
|
+
placeholder="Confirm password"
|
|
116
|
+
value={confirmPassword}
|
|
117
|
+
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
118
|
+
style={input}
|
|
119
|
+
/>
|
|
120
|
+
</div>
|
|
121
|
+
<button type="submit" disabled={isSubmitting} style={button}>
|
|
122
|
+
{isSubmitting ? "Resetting…" : "Reset password"}
|
|
123
|
+
</button>
|
|
124
|
+
</form>
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
}
|