@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,548 @@
1
+ import { type CSSProperties, type ReactNode, useEffect, useMemo, useState } from "react";
2
+ import { AlertCircle, Clock, X } from "lucide-react";
3
+ import type { IEvent, ITicket } from "../../types/models";
4
+ import { useEventCheckout } from "../headless/event/useEventCheckout";
5
+ import { useForgeTheme, type ForgeTheme } from "../theme/ForgeThemeProvider";
6
+ import { useAmountFormatter } from "../format/useFormatCurrency";
7
+ import { PriceDisplay, summarizeTaxQuote } from "../format/PriceDisplay";
8
+ import { usePricesIncludeTax } from "../../data/queries/useWebsite";
9
+ import { Loading } from "./Loading";
10
+ import { usePaymentRenderer, type PaymentRenderProps } from "../payment/ForgePaymentProvider";
11
+ import { buttonStyle } from "./Button";
12
+ import {
13
+ DialogRoot,
14
+ DialogTrigger,
15
+ DialogPortal,
16
+ DialogOverlay,
17
+ DialogContent,
18
+ DialogTitle,
19
+ DialogClose,
20
+ } from "../headless/dialog";
21
+
22
+ export interface EventTicketsProps {
23
+ /** Event slug (resolves the event + drives the purchase flow). */
24
+ slug?: string;
25
+ /** Trigger button label. */
26
+ triggerText?: string;
27
+ /** Format a numeric amount for display. */
28
+ formatAmount?: (amount: number) => string;
29
+ /** Optional payment renderer — falls back to a registered <ForgePaymentProvider>. */
30
+ renderPayment?: (props: PaymentRenderProps) => ReactNode;
31
+ /** Stripe return URL for a PAID purchase. Default `/i/events/:slug/finalise`. */
32
+ finalisePath?: (slug: string, orderId: string) => string;
33
+ /** Host-owned navigation on FREE completion (e.g. router navigate). */
34
+ onComplete?: (info: { slug: string; orderId: string }) => void;
35
+ }
36
+
37
+ /** Append an alpha to a 6-digit hex color (mirrors frontend-shared addAlphaToHexCode). */
38
+ const a = (hex: string, alpha: number) => hex + Math.round(alpha * 255).toString(16).padStart(2, "0");
39
+
40
+ /**
41
+ * Themed event ticket purchase — a "Buy tickets" trigger + modal running the
42
+ * three-step flow (tickets → details → payment) on `useEventCheckout`. Port of
43
+ * the client app's EventTickets modal, styled from `ForgeThemeProvider`.
44
+ */
45
+ export function EventTickets({
46
+ slug,
47
+ triggerText = "Buy tickets",
48
+ formatAmount,
49
+ renderPayment,
50
+ finalisePath,
51
+ onComplete,
52
+ }: EventTicketsProps) {
53
+ const theme = useForgeTheme();
54
+ const registered = usePaymentRenderer();
55
+ const fmt = useAmountFormatter(formatAmount);
56
+ const c = useEventCheckout(slug, { finalisePath, onComplete });
57
+ const renderPay = renderPayment ?? registered ?? undefined;
58
+
59
+ const [open, setOpen] = useState(false);
60
+
61
+ const tryClose = (next: boolean) => {
62
+ if (!next && c.step !== "tickets" && typeof window !== "undefined") {
63
+ if (!window.confirm("Are you sure you want to leave checkout?")) return;
64
+ }
65
+ setOpen(next);
66
+ };
67
+
68
+ return (
69
+ <DialogRoot open={open} onOpenChange={tryClose}>
70
+ <DialogTrigger asChild>
71
+ <button style={triggerButtonStyle(theme)}>{triggerText}</button>
72
+ </DialogTrigger>
73
+ <DialogPortal>
74
+ <DialogOverlay style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.5)", zIndex: 50 }} />
75
+ <DialogContent
76
+ style={{
77
+ position: "fixed",
78
+ top: "50%",
79
+ left: "50%",
80
+ transform: "translate(-50%, -50%)",
81
+ width: "min(640px, calc(100vw - 24px))",
82
+ maxHeight: "88vh",
83
+ overflowY: "auto",
84
+ background: theme.colors.background,
85
+ color: theme.colors.text,
86
+ borderRadius: theme.cornerRadius,
87
+ padding: 24,
88
+ zIndex: 51,
89
+ fontFamily: theme.fontFamily,
90
+ }}
91
+ aria-describedby={undefined}
92
+ >
93
+ <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
94
+ <DialogTitle style={{ fontSize: 18, fontWeight: 700, margin: 0 }}>{c.event?.title ?? "Tickets"}</DialogTitle>
95
+ <DialogClose asChild>
96
+ <button aria-label="Close" style={closeButtonStyle(theme)}>
97
+ <X size={18} />
98
+ </button>
99
+ </DialogClose>
100
+ </div>
101
+
102
+ {c.isLoading && <Loading />}
103
+ {!c.isLoading && !c.event && <p style={{ marginTop: 8 }}>Event not found.</p>}
104
+ {c.event && <EventTicketsBody c={c} event={c.event} fmt={fmt} renderPayment={renderPay} />}
105
+ </DialogContent>
106
+ </DialogPortal>
107
+ </DialogRoot>
108
+ );
109
+ }
110
+
111
+ type Checkout = ReturnType<typeof useEventCheckout>;
112
+
113
+ function EventTicketsBody({
114
+ c,
115
+ event,
116
+ fmt,
117
+ renderPayment,
118
+ }: {
119
+ c: Checkout;
120
+ event: IEvent;
121
+ fmt: (n: number) => string;
122
+ renderPayment?: (props: PaymentRenderProps) => ReactNode;
123
+ }) {
124
+ const stepIndex = c.step === "tickets" ? 0 : c.step === "details" ? 1 : 2;
125
+ return (
126
+ <div style={{ width: "100%" }}>
127
+ <Progress current={stepIndex} />
128
+ {c.step === "tickets" && <TicketStep c={c} event={event} fmt={fmt} />}
129
+ {c.step === "details" && <DetailsStep c={c} event={event} fmt={fmt} />}
130
+ {c.step === "payment" && <PaymentStep c={c} fmt={fmt} renderPayment={renderPayment} />}
131
+ </div>
132
+ );
133
+ }
134
+
135
+ // ── Progress indicator (3 numbered steps) ────────────────────────────────────
136
+ function Progress({ current }: { current: number }) {
137
+ const theme = useForgeTheme();
138
+ const steps = [0, 1, 2];
139
+ return (
140
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 24 }}>
141
+ {steps.map((s, index) => (
142
+ <div key={s} style={{ display: "flex", alignItems: "center", flex: index < steps.length - 1 ? 1 : "0 0 auto" }}>
143
+ <div
144
+ style={{
145
+ width: 32,
146
+ height: 32,
147
+ borderRadius: "9999px",
148
+ display: "flex",
149
+ alignItems: "center",
150
+ justifyContent: "center",
151
+ fontSize: 14,
152
+ fontWeight: 600,
153
+ border: `1px solid ${index <= current ? theme.colors.primary : a(theme.colors.primary, 0.25)}`,
154
+ background: index <= current ? theme.colors.primary : "transparent",
155
+ color: index <= current ? "#fff" : theme.colors.primary,
156
+ }}
157
+ >
158
+ {index + 1}
159
+ </div>
160
+ {index < steps.length - 1 && (
161
+ <div
162
+ style={{
163
+ flex: 1,
164
+ height: 2,
165
+ margin: "0 16px",
166
+ background: index < current ? theme.colors.primary : a(theme.colors.primary, 0.13),
167
+ }}
168
+ />
169
+ )}
170
+ </div>
171
+ ))}
172
+ </div>
173
+ );
174
+ }
175
+
176
+ // ── Step 1: ticket selection (with live expiry/urgency + sold-out/max) ────────
177
+ function TicketStep({ c, event, fmt }: { c: Checkout; event: IEvent; fmt: (n: number) => string }) {
178
+ const theme = useForgeTheme();
179
+ // Inclusive stores caption ticket prices as tax-inclusive (display only).
180
+ const pricesIncludeTax = usePricesIncludeTax();
181
+ const [now, setNow] = useState(() => Date.now());
182
+
183
+ useEffect(() => {
184
+ const id = setInterval(() => setNow(Date.now()), 1000);
185
+ return () => clearInterval(id);
186
+ }, []);
187
+
188
+ const urgency = (expiresAt?: string) => {
189
+ if (!expiresAt) return null;
190
+ const diff = new Date(expiresAt).getTime() - now;
191
+ if (diff <= 0) return { expired: true, color: "#ef4444", alert: true, message: "This ticket has expired" };
192
+ const hours = Math.floor(diff / 3_600_000);
193
+ const minutes = Math.floor((diff % 3_600_000) / 60_000);
194
+ const totalMinutes = hours * 60 + minutes;
195
+ if (totalMinutes < 30) return { expired: false, color: "#dc2626", alert: true, message: `⚠️ Only ${minutes} minute${minutes !== 1 ? "s" : ""} left!` };
196
+ if (totalMinutes < 60) return { expired: false, color: "#f59e0b", alert: true, message: "⏰ Less than 1 hour remaining!" };
197
+ if (hours < 6) return { expired: false, color: "#f97316", alert: false, message: `⏰ Expires in ${hours} hour${hours !== 1 ? "s" : ""}!` };
198
+ if (hours < 24) return { expired: false, color: theme.colors.primary, alert: false, message: `Expires in ${hours} hours` };
199
+ const days = Math.floor(hours / 24);
200
+ return { expired: false, color: theme.colors.primary, alert: false, message: `Expires in ${days} day${days !== 1 ? "s" : ""}` };
201
+ };
202
+
203
+ const setQty = (ticket: ITicket, qty: number) => {
204
+ const remaining = ticket.quantity - ticket.sold;
205
+ const maxAllowed = Math.min(remaining, ticket.maxPerPerson ?? remaining);
206
+ c.setTicketQty(ticket.id, Math.max(0, Math.min(qty, maxAllowed)));
207
+ };
208
+
209
+ return (
210
+ <div>
211
+ <h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 16 }}>Select Tickets</h2>
212
+ <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
213
+ {event.tickets.length === 0 && <p style={{ opacity: 0.7 }}>No tickets available.</p>}
214
+ {event.tickets.map((ticket) => {
215
+ const selectedQty = c.selectedTickets[ticket.id] ?? 0;
216
+ const remaining = ticket.quantity - ticket.sold;
217
+ const maxAllowed = Math.min(remaining, ticket.maxPerPerson ?? remaining);
218
+ const isSoldOut = remaining <= 0;
219
+ const u = urgency(ticket.expiresAt);
220
+ const isExpired = !!u?.expired;
221
+ const borderColor = isExpired ? "#ef4444" : u && !u.expired ? a(u.color, 0.5) : a(theme.colors.primary, 0.25);
222
+ return (
223
+ <div
224
+ key={ticket.id}
225
+ style={{
226
+ padding: 16,
227
+ border: `2px solid ${borderColor}`,
228
+ background: theme.colors.background,
229
+ borderRadius: theme.cornerRadius,
230
+ opacity: isSoldOut || isExpired ? 0.5 : 1,
231
+ }}
232
+ >
233
+ <div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 8 }}>
234
+ <div style={{ flex: 1 }}>
235
+ <h3 style={{ fontSize: 17, fontWeight: 600, marginBottom: 6 }}>{ticket.title}</h3>
236
+ {u && !u.expired && (
237
+ <div
238
+ style={{
239
+ display: "inline-flex",
240
+ alignItems: "center",
241
+ gap: 4,
242
+ padding: "2px 8px",
243
+ borderRadius: 6,
244
+ fontSize: 12,
245
+ fontWeight: 700,
246
+ marginBottom: 6,
247
+ background: a(u.color, 0.05),
248
+ color: u.color,
249
+ border: `1px solid ${a(u.color, 0.3)}`,
250
+ }}
251
+ >
252
+ {u.alert ? <AlertCircle size={12} /> : <Clock size={12} />}
253
+ {u.message}
254
+ </div>
255
+ )}
256
+ <div style={{ fontSize: 22, fontWeight: 700, color: theme.colors.primary }}>
257
+ {ticket.compareAtPrice != null && Number(ticket.compareAtPrice) > Number(ticket.price) && (
258
+ <span style={{ textDecoration: "line-through", opacity: 0.6, marginRight: 8, fontWeight: 400, fontSize: 16 }}>
259
+ {fmt(Number(ticket.compareAtPrice))}
260
+ </span>
261
+ )}
262
+ <PriceDisplay amount={Number(ticket.price)} pricesIncludeTax={pricesIncludeTax} formatAmount={fmt} mutedColor={theme.colors.text} captionStyle={{ opacity: 0.65 }} />
263
+ </div>
264
+ </div>
265
+ <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
266
+ <button
267
+ onClick={() => setQty(ticket, selectedQty - 1)}
268
+ disabled={selectedQty === 0 || isSoldOut}
269
+ style={stepperStyle(theme, false)}
270
+ >
271
+
272
+ </button>
273
+ <span style={{ fontSize: 18, fontWeight: 700, minWidth: 32, textAlign: "center" }}>
274
+ {isSoldOut ? "SOLD OUT" : selectedQty}
275
+ </span>
276
+ <button
277
+ onClick={() => setQty(ticket, selectedQty + 1)}
278
+ disabled={selectedQty >= maxAllowed || isSoldOut || isExpired}
279
+ style={stepperStyle(theme, true)}
280
+ >
281
+ +
282
+ </button>
283
+ </div>
284
+ </div>
285
+ {ticket.description && (
286
+ <p dangerouslySetInnerHTML={{ __html: ticket.description }} style={{ marginTop: 12, fontSize: 14 }} />
287
+ )}
288
+ {isExpired && (
289
+ <div
290
+ style={{
291
+ marginTop: 12,
292
+ padding: "8px 12px",
293
+ borderRadius: 6,
294
+ fontSize: 14,
295
+ fontWeight: 600,
296
+ background: a("#ef4444", 0.1),
297
+ color: "#ef4444",
298
+ border: `1px solid ${a("#ef4444", 0.3)}`,
299
+ display: "flex",
300
+ alignItems: "center",
301
+ gap: 8,
302
+ }}
303
+ >
304
+ <AlertCircle size={16} />
305
+ This ticket has expired and is no longer available
306
+ </div>
307
+ )}
308
+ </div>
309
+ );
310
+ })}
311
+ </div>
312
+
313
+ {c.error && <p style={{ color: "#ef4444", fontSize: 14, marginTop: 12 }}>{c.error}</p>}
314
+ <ActionBar c={c} fmt={fmt} onNext={c.goToDetails} nextLabel="Continue" />
315
+ </div>
316
+ );
317
+ }
318
+
319
+ // ── Step 2: buyer details + questionnaire ─────────────────────────────────────
320
+ function DetailsStep({ c, event, fmt }: { c: Checkout; event: IEvent; fmt: (n: number) => string }) {
321
+ const theme = useForgeTheme();
322
+ const [confirmEmail, setConfirmEmail] = useState("");
323
+ const [answers, setAnswers] = useState<Record<string, string>>({});
324
+ const [localError, setLocalError] = useState<string | null>(null);
325
+
326
+ const questions = useMemo(() => event.questionnaire ?? [], [event.questionnaire]);
327
+
328
+ const onAnswer = (qid: string, value: string) => {
329
+ const next = { ...answers, [qid]: value };
330
+ setAnswers(next);
331
+ c.setQuestionnaire(questions.map((q) => ({ ...q, answer: next[q.id] ?? "" })));
332
+ };
333
+
334
+ const onContinue = () => {
335
+ setLocalError(null);
336
+ if (!c.firstName.trim() || !c.lastName.trim()) return setLocalError("Please enter your name.");
337
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(c.email)) return setLocalError("Please enter a valid email.");
338
+ if (c.email !== confirmEmail) return setLocalError("Emails do not match.");
339
+ for (const q of questions) {
340
+ if (!q.optional && !(answers[q.id] ?? "").trim()) return setLocalError(`Please answer: ${q.question}`);
341
+ }
342
+ c.continueToPayment();
343
+ };
344
+
345
+ return (
346
+ <div>
347
+ <h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 16 }}>Your details</h2>
348
+ <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
349
+ <div style={{ display: "flex", gap: 8 }}>
350
+ <input placeholder="First name" value={c.firstName} onChange={(e) => c.setFirstName(e.target.value)} style={inputStyle(theme)} />
351
+ <input placeholder="Last name" value={c.lastName} onChange={(e) => c.setLastName(e.target.value)} style={inputStyle(theme)} />
352
+ </div>
353
+ <div>
354
+ <input placeholder="Email" type="email" value={c.email} onChange={(e) => c.setEmail(e.target.value)} style={inputStyle(theme)} />
355
+ {c.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(c.email) && <FieldError>Please enter a valid email address</FieldError>}
356
+ </div>
357
+ <div>
358
+ <input placeholder="Confirm email" type="email" value={confirmEmail} onChange={(e) => setConfirmEmail(e.target.value)} style={inputStyle(theme)} />
359
+ {confirmEmail && c.email !== confirmEmail && <FieldError>Email addresses do not match</FieldError>}
360
+ </div>
361
+
362
+ {questions.map((q) => {
363
+ const missing = !q.optional && !(answers[q.id] ?? "").trim();
364
+ return (
365
+ <label key={q.id} style={{ display: "flex", flexDirection: "column", gap: 6, fontSize: 14 }}>
366
+ <span>
367
+ {q.question}
368
+ {!q.optional && <span style={{ color: "#ef4444" }}> *</span>}
369
+ </span>
370
+ {q.options && q.options.length > 0 ? (
371
+ <select value={answers[q.id] ?? ""} onChange={(e) => onAnswer(q.id, e.target.value)} style={inputStyle(theme)}>
372
+ <option value="">Select…</option>
373
+ {q.options.map((opt) => (
374
+ <option key={opt} value={opt}>
375
+ {opt}
376
+ </option>
377
+ ))}
378
+ </select>
379
+ ) : (
380
+ <input value={answers[q.id] ?? ""} onChange={(e) => onAnswer(q.id, e.target.value)} style={inputStyle(theme)} />
381
+ )}
382
+ {missing && <FieldError>This field is required</FieldError>}
383
+ </label>
384
+ );
385
+ })}
386
+
387
+ {(localError || c.error) && <p style={{ color: "#ef4444", fontSize: 14 }}>{localError ?? c.error}</p>}
388
+ </div>
389
+ <ActionBar
390
+ c={c}
391
+ fmt={fmt}
392
+ onBack={() => c.setStep("tickets")}
393
+ onNext={onContinue}
394
+ nextLabel={c.isProcessing ? "Processing…" : "Continue to payment"}
395
+ nextDisabled={c.isProcessing}
396
+ />
397
+ </div>
398
+ );
399
+ }
400
+
401
+ // ── Step 3: payment ───────────────────────────────────────────────────────────
402
+ function PaymentStep({
403
+ c,
404
+ fmt,
405
+ renderPayment,
406
+ }: {
407
+ c: Checkout;
408
+ fmt: (n: number) => string;
409
+ renderPayment?: (props: PaymentRenderProps) => ReactNode;
410
+ }) {
411
+ const theme = useForgeTheme();
412
+ // Authoritative tax quote from start-payment: exclusive stores get a "Tax"
413
+ // row (already contained in the charged total below); inclusive stores get a
414
+ // muted "Includes X tax" caption. Display only.
415
+ const taxSummary = summarizeTaxQuote(c.taxQuote);
416
+ return (
417
+ <div>
418
+ <h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 8 }}>Payment</h2>
419
+ <p style={{ fontSize: 14, opacity: 0.7, marginBottom: 16 }}>Complete your purchase to secure your tickets.</p>
420
+ <div
421
+ style={{
422
+ padding: 20,
423
+ border: `2px solid ${a(theme.colors.primary, 0.25)}`,
424
+ borderRadius: theme.cornerRadius,
425
+ marginBottom: 16,
426
+ }}
427
+ >
428
+ {taxSummary?.mode === "exclusive" && (
429
+ <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
430
+ <span style={{ fontSize: 14, opacity: 0.75 }}>Tax</span>
431
+ <span style={{ fontSize: 14, opacity: 0.75 }}>{taxSummary.formattedTax}</span>
432
+ </div>
433
+ )}
434
+ <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
435
+ <span style={{ fontSize: 16 }}>Total Amount</span>
436
+ <span style={{ fontSize: 22, fontWeight: 700, color: theme.colors.primary, textAlign: "right" }}>
437
+ {fmt(c.totalAmount)}
438
+ {taxSummary?.mode === "inclusive" && (
439
+ <span style={{ display: "block", fontSize: 12, fontWeight: 400, color: theme.colors.text, opacity: 0.65 }}>
440
+ {taxSummary.label}
441
+ </span>
442
+ )}
443
+ </span>
444
+ </div>
445
+ </div>
446
+ {!renderPayment ? (
447
+ <p style={{ color: "#ef4444", fontSize: 14 }}>Payment is not configured.</p>
448
+ ) : c.clientSecret ? (
449
+ renderPayment({ clientSecret: c.clientSecret, returnUrl: c.returnUrl, amount: c.totalAmount, mode: "redirect" })
450
+ ) : (
451
+ <p style={{ opacity: 0.7 }}>Preparing payment…</p>
452
+ )}
453
+ <button onClick={() => c.setStep("details")} style={{ ...secondaryButtonStyle(theme), marginTop: 16 }}>
454
+ Back
455
+ </button>
456
+ </div>
457
+ );
458
+ }
459
+
460
+ function FieldError({ children }: { children: ReactNode }) {
461
+ return <p style={{ color: "#ef4444", fontSize: 13, marginTop: 4 }}>{children}</p>;
462
+ }
463
+
464
+ // ── Shared bits ───────────────────────────────────────────────────────────────
465
+ function ActionBar({
466
+ c,
467
+ fmt,
468
+ onBack,
469
+ onNext,
470
+ nextLabel,
471
+ nextDisabled,
472
+ }: {
473
+ c: Checkout;
474
+ fmt: (n: number) => string;
475
+ onBack?: () => void;
476
+ onNext: () => void;
477
+ nextLabel: string;
478
+ nextDisabled?: boolean;
479
+ }) {
480
+ const theme = useForgeTheme();
481
+ return (
482
+ <div style={{ marginTop: 24 }}>
483
+ <div
484
+ style={{
485
+ padding: 16,
486
+ border: `1px solid ${a(theme.colors.primary, 0.25)}`,
487
+ borderRadius: theme.cornerRadius,
488
+ marginBottom: 16,
489
+ display: "flex",
490
+ justifyContent: "space-between",
491
+ alignItems: "center",
492
+ }}
493
+ >
494
+ <span style={{ fontWeight: 600 }}>Total</span>
495
+ <span style={{ fontSize: 18, fontWeight: 700, color: theme.colors.primary }}>{fmt(c.totalAmount)}</span>
496
+ </div>
497
+ <div style={{ display: "flex", gap: 8, justifyContent: "space-between" }}>
498
+ <button onClick={onBack} disabled={!onBack} style={{ ...secondaryButtonStyle(theme), opacity: onBack ? 1 : 0.4 }}>
499
+ Back
500
+ </button>
501
+ <button onClick={onNext} disabled={nextDisabled} style={{ ...primaryButtonStyle(theme), opacity: nextDisabled ? 0.6 : 1 }}>
502
+ {nextLabel}
503
+ </button>
504
+ </div>
505
+ </div>
506
+ );
507
+ }
508
+
509
+ const triggerButtonStyle = (t: ForgeTheme): CSSProperties => buttonStyle(t, { variant: "primary", fullWidth: true });
510
+
511
+ const primaryButtonStyle = (t: ForgeTheme): CSSProperties => buttonStyle(t, { variant: "primary" });
512
+
513
+ const secondaryButtonStyle = (t: ForgeTheme): CSSProperties => buttonStyle(t, { variant: "outline" });
514
+
515
+ const closeButtonStyle = (t: ForgeTheme): CSSProperties => ({
516
+ background: "transparent",
517
+ border: "none",
518
+ color: t.colors.text,
519
+ cursor: "pointer",
520
+ fontSize: 16,
521
+ lineHeight: 1,
522
+ opacity: 0.7,
523
+ });
524
+
525
+ const stepperStyle = (t: ForgeTheme, filled: boolean): CSSProperties => ({
526
+ width: 40,
527
+ height: 40,
528
+ borderRadius: t.cornerRadius,
529
+ border: `2px solid ${t.colors.primary}`,
530
+ background: filled ? t.colors.primary : "transparent",
531
+ color: filled ? "#fff" : t.colors.primary,
532
+ fontSize: 20,
533
+ fontWeight: 700,
534
+ cursor: "pointer",
535
+ display: "flex",
536
+ alignItems: "center",
537
+ justifyContent: "center",
538
+ });
539
+
540
+ const inputStyle = (t: ForgeTheme): CSSProperties => ({
541
+ width: "100%",
542
+ padding: 10,
543
+ border: `1px solid ${a(t.colors.primary, 0.25)}`,
544
+ borderRadius: t.cornerRadius,
545
+ background: t.colors.background,
546
+ color: t.colors.text,
547
+ fontFamily: t.fontFamily,
548
+ });
@@ -0,0 +1,83 @@
1
+ import { useEvents } from "../../data/queries/useEvents";
2
+ import type { IEvent } from "../../types/models";
3
+ import { useThemeTokens } from "../theme/ForgeThemeProvider";
4
+ import { Loading } from "./Loading";
5
+
6
+ export interface EventsListProps {
7
+ limit?: number;
8
+ onSelect?: (event: IEvent) => void;
9
+ className?: string;
10
+ style?: React.CSSProperties;
11
+ }
12
+
13
+ function formatDate(dateTime: string, timezone?: string) {
14
+ return new Date(dateTime).toLocaleString("en-US", {
15
+ weekday: "short",
16
+ month: "short",
17
+ day: "numeric",
18
+ hour: "numeric",
19
+ minute: "2-digit",
20
+ hour12: true,
21
+ timeZone: timezone || "UTC",
22
+ });
23
+ }
24
+
25
+ function locationText(event: IEvent): string {
26
+ if (event.type === "virtual") return "Virtual";
27
+ if (event.address) return event.address.name || event.address.city;
28
+ return "Virtual";
29
+ }
30
+
31
+ /** Themed upcoming-events list, built on `useEvents`. */
32
+ export function EventsList({ limit, onSelect, className, style }: EventsListProps) {
33
+ const t = useThemeTokens();
34
+ const { data, isLoading } = useEvents();
35
+ const events = (data ?? []).slice(0, limit ?? 100);
36
+
37
+ if (isLoading) return <Loading />;
38
+ if (!events.length) return <p style={{ color: t.text, opacity: 0.7 }}>No upcoming events.</p>;
39
+
40
+ return (
41
+ <div className={className} style={{ display: "flex", flexDirection: "column", gap: 12, fontFamily: t.fontFamily, ...style }}>
42
+ {events.map((ev) => {
43
+ const cover = ev.media?.find((m) => m.type === "image")?.url ?? ev.media?.[0]?.url;
44
+ return (
45
+ <button
46
+ key={ev.id}
47
+ onClick={() => onSelect?.(ev)}
48
+ style={{
49
+ display: "flex",
50
+ gap: 12,
51
+ alignItems: "center",
52
+ textAlign: "left",
53
+ cursor: onSelect ? "pointer" : "default",
54
+ background: t.surface,
55
+ border: `1px solid ${t.primary}20`,
56
+ borderRadius: t.cornerRadius,
57
+ padding: 10,
58
+ color: t.text,
59
+ }}
60
+ >
61
+ <div
62
+ style={{
63
+ width: 72,
64
+ height: 72,
65
+ borderRadius: t.cornerRadius,
66
+ overflow: "hidden",
67
+ flexShrink: 0,
68
+ background: `${t.primary}10`,
69
+ }}
70
+ >
71
+ {cover && <img src={cover} alt={ev.title} style={{ width: "100%", height: "100%", objectFit: "cover" }} />}
72
+ </div>
73
+ <div>
74
+ <div style={{ fontWeight: 600 }}>{ev.title}</div>
75
+ <div style={{ fontSize: 13, opacity: 0.75 }}>{formatDate(ev.dateTime, ev.timezone)}</div>
76
+ <div style={{ fontSize: 13, opacity: 0.75 }}>{locationText(ev)}</div>
77
+ </div>
78
+ </button>
79
+ );
80
+ })}
81
+ </div>
82
+ );
83
+ }