@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,105 @@
1
+ import { useState } from "react";
2
+ import { useForgotPassword } from "../../data/queries/useAuthActions";
3
+ import { useForgeTheme } from "../theme/ForgeThemeProvider";
4
+
5
+ export interface ForgotPasswordFormProps {
6
+ /** Href for the "Back to login" / "Login" links. */
7
+ loginHref: string;
8
+ }
9
+
10
+ const msg = (e: unknown) => (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
11
+
12
+ /** Themed forgot-password request form, built on `useForgotPassword`. */
13
+ export function ForgotPasswordForm({ loginHref }: ForgotPasswordFormProps) {
14
+ const theme = useForgeTheme();
15
+ const forgotPassword = useForgotPassword();
16
+ const [email, setEmail] = useState("");
17
+ const [error, setError] = useState("");
18
+ const [isSubmitting, setIsSubmitting] = useState(false);
19
+ const [isSuccess, setIsSuccess] = useState(false);
20
+
21
+ const card: React.CSSProperties = {
22
+ maxWidth: 420,
23
+ margin: "40px auto 0",
24
+ padding: 24,
25
+ border: `1px solid ${theme.colors.primary}40`,
26
+ borderRadius: theme.cornerRadius,
27
+ color: theme.colors.text,
28
+ };
29
+ const button: React.CSSProperties = {
30
+ width: "100%",
31
+ padding: "10px 14px",
32
+ borderRadius: theme.cornerRadius,
33
+ background: theme.colors.primary,
34
+ color: theme.colors.background,
35
+ border: "none",
36
+ fontWeight: 600,
37
+ cursor: "pointer",
38
+ };
39
+ const link: React.CSSProperties = { color: theme.colors.primary, textDecoration: "underline" };
40
+
41
+ const onSubmit = async (e: React.FormEvent) => {
42
+ e.preventDefault();
43
+ setError("");
44
+ setIsSubmitting(true);
45
+ try {
46
+ await forgotPassword.mutateAsync({ email, origin: window.location.origin });
47
+ setIsSuccess(true);
48
+ } catch (err) {
49
+ setError(msg(err) || "Something went wrong. Please try again.");
50
+ } finally {
51
+ setIsSubmitting(false);
52
+ }
53
+ };
54
+
55
+ if (isSuccess) {
56
+ return (
57
+ <div style={card}>
58
+ <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 12 }}>Check your email</h1>
59
+ <p style={{ fontSize: 14, marginBottom: 24 }}>
60
+ If an account exists with that email, we&apos;ve sent a password reset link. Please check your inbox.
61
+ </p>
62
+ <a href={loginHref} style={link}>
63
+ Back to login
64
+ </a>
65
+ </div>
66
+ );
67
+ }
68
+
69
+ return (
70
+ <div style={card}>
71
+ <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 24 }}>Reset password</h1>
72
+ {error && <p style={{ color: "#ef4444", marginBottom: 16 }}>{error}</p>}
73
+ <form onSubmit={onSubmit} style={{ display: "flex", flexDirection: "column", gap: 16 }}>
74
+ <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
75
+ <label style={{ fontSize: 14 }}>Email</label>
76
+ <input
77
+ type="email"
78
+ placeholder="Email"
79
+ value={email}
80
+ onChange={(e) => setEmail(e.target.value)}
81
+ style={{
82
+ width: "100%",
83
+ height: 40,
84
+ padding: "8px 12px",
85
+ borderRadius: theme.cornerRadius,
86
+ border: `1px solid ${theme.colors.primary}40`,
87
+ background: "transparent",
88
+ color: theme.colors.text,
89
+ outline: "none",
90
+ }}
91
+ />
92
+ </div>
93
+ <button type="submit" disabled={isSubmitting} style={button}>
94
+ {isSubmitting ? "Sending…" : "Send reset email"}
95
+ </button>
96
+ </form>
97
+ <p style={{ marginTop: 16, fontSize: 14, textAlign: "center" }}>
98
+ Already have an account?{" "}
99
+ <a href={loginHref} style={link}>
100
+ Login
101
+ </a>
102
+ </p>
103
+ </div>
104
+ );
105
+ }
@@ -0,0 +1,249 @@
1
+ import { useState } from "react";
2
+ import { usePublicForm, useSubmitPublicForm } from "../../data/queries/useForms";
3
+ import type { FormField } from "../../types/models";
4
+ import { useForgeTheme } from "../theme/ForgeThemeProvider";
5
+ import { Loading } from "./Loading";
6
+
7
+ export interface FormRendererProps {
8
+ formId: string;
9
+ }
10
+
11
+ /**
12
+ * Themed public form renderer, built on `usePublicForm` + `useSubmitPublicForm`.
13
+ * Renders every field type, validates required/email fields, and shows a
14
+ * thank-you state on success.
15
+ */
16
+ export function FormRenderer({ formId }: FormRendererProps) {
17
+ const theme = useForgeTheme();
18
+ const { data: form, isLoading } = usePublicForm(formId);
19
+ const submitMutation = useSubmitPublicForm(formId);
20
+
21
+ const [answers, setAnswers] = useState<Record<string, string>>({});
22
+ const [isCompleted, setIsCompleted] = useState(false);
23
+ const [error, setError] = useState("");
24
+
25
+ if (isLoading || !form) return <Loading fullPage />;
26
+
27
+ const settings = typeof form.settings === "string" ? JSON.parse(form.settings) : form.settings || {};
28
+ const fields = [...form.fields].sort((a, b) => a.order - b.order);
29
+
30
+ const setAnswer = (id: string, value: string) => setAnswers((prev) => ({ ...prev, [id]: value }));
31
+
32
+ const handleSubmit = async () => {
33
+ setError("");
34
+ for (const field of fields) {
35
+ if (field.isRequired && !answers[field.id]) {
36
+ setError(`"${field.label}" is required`);
37
+ return;
38
+ }
39
+ if (field.type === "email" && answers[field.id]) {
40
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(answers[field.id])) {
41
+ setError("Please enter a valid email address");
42
+ return;
43
+ }
44
+ }
45
+ }
46
+ try {
47
+ const entries = fields.map((f) => ({ fieldId: f.id, value: answers[f.id] || null }));
48
+ await submitMutation.mutateAsync(entries);
49
+ setIsCompleted(true);
50
+ } catch {
51
+ setError("Failed to submit. Please try again.");
52
+ }
53
+ };
54
+
55
+ const wrap: React.CSSProperties = { maxWidth: 560, margin: "0 auto", color: theme.colors.text };
56
+
57
+ if (isCompleted) {
58
+ return (
59
+ <div style={{ ...wrap, textAlign: "center" }}>
60
+ <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 12 }}>
61
+ {settings.thankYouTitle || "Thank you!"}
62
+ </h1>
63
+ <p style={{ opacity: 0.8 }}>{settings.thankYouMessage || "Your response has been recorded."}</p>
64
+ </div>
65
+ );
66
+ }
67
+
68
+ return (
69
+ <div style={wrap}>
70
+ <h1 style={{ fontSize: 28, fontWeight: 800, marginBottom: 8 }}>{form.title}</h1>
71
+ {form.description && <p style={{ opacity: 0.7, marginBottom: 24 }}>{form.description}</p>}
72
+
73
+ <div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
74
+ {fields.map((field) => (
75
+ <FieldInput
76
+ key={field.id}
77
+ field={field}
78
+ value={answers[field.id] || ""}
79
+ onChange={(v) => setAnswer(field.id, v)}
80
+ theme={theme}
81
+ />
82
+ ))}
83
+ </div>
84
+
85
+ {error && <p style={{ color: "#dc2626", marginTop: 16 }}>{error}</p>}
86
+
87
+ <button
88
+ onClick={handleSubmit}
89
+ disabled={submitMutation.isPending}
90
+ style={{
91
+ marginTop: 24,
92
+ padding: "12px 24px",
93
+ borderRadius: theme.cornerRadius,
94
+ border: "none",
95
+ fontWeight: 600,
96
+ cursor: "pointer",
97
+ opacity: submitMutation.isPending ? 0.5 : 1,
98
+ background: theme.colors.primary,
99
+ color: theme.colors.background,
100
+ }}
101
+ >
102
+ {submitMutation.isPending ? "Submitting…" : settings.submitButtonText || "Submit"}
103
+ </button>
104
+ </div>
105
+ );
106
+ }
107
+
108
+ export function FieldInput({
109
+ field,
110
+ value,
111
+ onChange,
112
+ theme,
113
+ }: {
114
+ field: FormField;
115
+ value: string;
116
+ onChange: (v: string) => void;
117
+ theme: ReturnType<typeof useForgeTheme>;
118
+ }) {
119
+ const inputStyle: React.CSSProperties = {
120
+ width: "100%",
121
+ padding: "10px 12px",
122
+ borderRadius: theme.cornerRadius,
123
+ border: `1px solid ${theme.colors.text}33`,
124
+ background: "transparent",
125
+ color: theme.colors.text,
126
+ fontSize: 15,
127
+ };
128
+
129
+ const label = (
130
+ <label style={{ display: "block", fontWeight: 600, marginBottom: 6 }}>
131
+ {field.label}
132
+ {field.isRequired && <span style={{ color: theme.colors.primary }}> *</span>}
133
+ </label>
134
+ );
135
+
136
+ let control: React.ReactNode;
137
+ switch (field.type) {
138
+ case "long_text":
139
+ control = (
140
+ <textarea
141
+ value={value}
142
+ onChange={(e) => onChange(e.target.value)}
143
+ placeholder={field.placeholder || ""}
144
+ rows={4}
145
+ style={inputStyle}
146
+ />
147
+ );
148
+ break;
149
+ case "single_select":
150
+ control = (
151
+ <select value={value} onChange={(e) => onChange(e.target.value)} style={inputStyle}>
152
+ <option value="">Select…</option>
153
+ {field.options?.map((o) => (
154
+ <option key={o.id} value={o.label}>
155
+ {o.label}
156
+ </option>
157
+ ))}
158
+ </select>
159
+ );
160
+ break;
161
+ case "multi_select": {
162
+ const selected = value ? value.split("||") : [];
163
+ const toggle = (optLabel: string) => {
164
+ const next = selected.includes(optLabel)
165
+ ? selected.filter((s) => s !== optLabel)
166
+ : [...selected, optLabel];
167
+ onChange(next.join("||"));
168
+ };
169
+ control = (
170
+ <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
171
+ {field.options?.map((o) => (
172
+ <label key={o.id} style={{ display: "flex", alignItems: "center", gap: 8 }}>
173
+ <input type="checkbox" checked={selected.includes(o.label)} onChange={() => toggle(o.label)} />
174
+ {o.label}
175
+ </label>
176
+ ))}
177
+ </div>
178
+ );
179
+ break;
180
+ }
181
+ case "yes_no":
182
+ control = (
183
+ <div style={{ display: "flex", gap: 16 }}>
184
+ {["Yes", "No"].map((opt) => (
185
+ <label key={opt} style={{ display: "flex", alignItems: "center", gap: 6 }}>
186
+ <input type="radio" name={field.id} checked={value === opt} onChange={() => onChange(opt)} />
187
+ {opt}
188
+ </label>
189
+ ))}
190
+ </div>
191
+ );
192
+ break;
193
+ case "rating":
194
+ control = (
195
+ <div style={{ display: "flex", gap: 8 }}>
196
+ {[1, 2, 3, 4, 5].map((n) => (
197
+ <button
198
+ key={n}
199
+ type="button"
200
+ onClick={() => onChange(String(n))}
201
+ style={{
202
+ width: 40,
203
+ height: 40,
204
+ borderRadius: theme.cornerRadius,
205
+ cursor: "pointer",
206
+ border: `1px solid ${theme.colors.text}33`,
207
+ background: value === String(n) ? theme.colors.primary : "transparent",
208
+ color: value === String(n) ? theme.colors.background : theme.colors.text,
209
+ }}
210
+ >
211
+ {n}
212
+ </button>
213
+ ))}
214
+ </div>
215
+ );
216
+ break;
217
+ case "number":
218
+ case "email":
219
+ case "date":
220
+ control = (
221
+ <input
222
+ type={field.type === "number" ? "number" : field.type === "email" ? "email" : "date"}
223
+ value={value}
224
+ onChange={(e) => onChange(e.target.value)}
225
+ placeholder={field.placeholder || ""}
226
+ style={inputStyle}
227
+ />
228
+ );
229
+ break;
230
+ default:
231
+ control = (
232
+ <input
233
+ type="text"
234
+ value={value}
235
+ onChange={(e) => onChange(e.target.value)}
236
+ placeholder={field.placeholder || ""}
237
+ style={inputStyle}
238
+ />
239
+ );
240
+ }
241
+
242
+ return (
243
+ <div>
244
+ {label}
245
+ {field.description && <p style={{ fontSize: 13, opacity: 0.7, marginBottom: 8 }}>{field.description}</p>}
246
+ {control}
247
+ </div>
248
+ );
249
+ }
@@ -0,0 +1,114 @@
1
+ import { useEffect, useState } from "react";
2
+ import { useInstallPrompt } from "../../data/queries/usePWA";
3
+ import { useThemeTokens } from "../theme/ForgeThemeProvider";
4
+ import { Button } from "./Button";
5
+
6
+ const DISMISS_KEY = "forge-install-banner-dismissed";
7
+
8
+ export interface InstallBannerProps {
9
+ /** Only show on the live published site (starter gates with FORGE_STATE/EDIT). */
10
+ enabled?: boolean;
11
+ /** Heading text. */
12
+ title?: string;
13
+ /** Supporting line for Android/desktop (a native prompt is available). */
14
+ description?: string;
15
+ /** iOS instruction (Safari can't prompt — user adds to Home Screen manually). */
16
+ iosInstructions?: string;
17
+ }
18
+
19
+ /**
20
+ * Dismissible bottom install banner (Tier-2, theme-token styled). Reads
21
+ * `useInstallPrompt()`:
22
+ * - Android/desktop (a `beforeinstallprompt` was captured) → an "Install"
23
+ * button that fires the native prompt.
24
+ * - iOS Safari (no prompt) → "Share → Add to Home Screen" instructions. On
25
+ * iOS the installed PWA is also the gateway to web push (iOS 16.4+).
26
+ * Hidden when installed, already dismissed (localStorage), or not `enabled`.
27
+ */
28
+ export function InstallBanner({
29
+ enabled = true,
30
+ title = "Install this app",
31
+ description = "Add it to your home screen for a faster, full-screen experience.",
32
+ iosInstructions = "Tap the Share icon, then “Add to Home Screen”.",
33
+ }: InstallBannerProps) {
34
+ const t = useThemeTokens();
35
+ const { isInstallable, isInstalled, isIOS, isStandalone, promptInstall } = useInstallPrompt();
36
+ const [dismissed, setDismissed] = useState(true);
37
+
38
+ useEffect(() => {
39
+ try {
40
+ setDismissed(window.localStorage.getItem(DISMISS_KEY) === "1");
41
+ } catch {
42
+ setDismissed(false);
43
+ }
44
+ }, []);
45
+
46
+ const dismiss = () => {
47
+ setDismissed(true);
48
+ try {
49
+ window.localStorage.setItem(DISMISS_KEY, "1");
50
+ } catch {
51
+ /* private mode — best effort */
52
+ }
53
+ };
54
+
55
+ if (!enabled || dismissed || isInstalled || isStandalone) return null;
56
+ // Android/desktop need a captured prompt; iOS shows manual instructions.
57
+ if (!isInstallable && !isIOS) return null;
58
+
59
+ return (
60
+ <div
61
+ role="dialog"
62
+ aria-label={title}
63
+ style={{
64
+ position: "fixed",
65
+ left: 12,
66
+ right: 12,
67
+ bottom: 12,
68
+ zIndex: 60,
69
+ maxWidth: 520,
70
+ margin: "0 auto",
71
+ padding: 16,
72
+ borderRadius: t.cornerRadius,
73
+ background: t.surface,
74
+ color: t.text,
75
+ border: `1px solid ${t.border}`,
76
+ boxShadow: "0 8px 30px rgba(0,0,0,0.18)",
77
+ display: "flex",
78
+ flexDirection: "column",
79
+ gap: 10,
80
+ }}
81
+ >
82
+ <div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 12 }}>
83
+ <div>
84
+ <div style={{ fontWeight: 700, fontSize: 15 }}>{title}</div>
85
+ <div style={{ fontSize: 13, color: t.muted, marginTop: 2 }}>{isIOS ? iosInstructions : description}</div>
86
+ </div>
87
+ <button
88
+ type="button"
89
+ aria-label="Dismiss"
90
+ onClick={dismiss}
91
+ style={{ background: "transparent", border: "none", color: t.muted, cursor: "pointer", fontSize: 18, lineHeight: 1 }}
92
+ >
93
+ {"×"}
94
+ </button>
95
+ </div>
96
+ {!isIOS && (
97
+ <div style={{ display: "flex", gap: 8 }}>
98
+ <Button
99
+ size="sm"
100
+ onClick={async () => {
101
+ const ok = await promptInstall();
102
+ if (ok) dismiss();
103
+ }}
104
+ >
105
+ Install
106
+ </Button>
107
+ <Button size="sm" variant="ghost" onClick={dismiss}>
108
+ Not now
109
+ </Button>
110
+ </div>
111
+ )}
112
+ </div>
113
+ );
114
+ }
@@ -0,0 +1,50 @@
1
+ import { useForgeTheme } from "../theme/ForgeThemeProvider";
2
+ import { useAmountFormatter } from "../format/useFormatCurrency";
3
+ import { Loading } from "./Loading";
4
+ import { useInvoiceFinalize } from "../../data/queries/useInvoice";
5
+
6
+ export interface InvoiceConfirmationProps {
7
+ invoiceId: string;
8
+ /**
9
+ * Formats a monetary amount for display. Receives the invoice's own currency
10
+ * as a second argument so the shell can honor it (defaults to plain USD-style).
11
+ */
12
+ formatAmount?: (n: number, currency?: string) => string;
13
+ /** Where the "view invoice" action links. Defaults to the invoice detail page. */
14
+ invoicePath?: string;
15
+ }
16
+
17
+ export function InvoiceConfirmation({
18
+ invoiceId,
19
+ formatAmount,
20
+ invoicePath = `/i/invoice/${invoiceId}`,
21
+ }: InvoiceConfirmationProps) {
22
+ const theme = useForgeTheme();
23
+ const fmt = useAmountFormatter(formatAmount);
24
+ const { data, isLoading, error } = useInvoiceFinalize(invoiceId);
25
+
26
+ if (isLoading) return <Loading fullPage />;
27
+ if (error || !data) return <p style={{ color: theme.colors.text }}>We couldn’t load this invoice.</p>;
28
+
29
+ const paid = data.status === "paid" || !!data.paidAt;
30
+
31
+ return (
32
+ <div style={{ maxWidth: 520, margin: "0 auto", textAlign: "center", color: theme.colors.text }}>
33
+ <h1 style={{ fontSize: 28, fontWeight: 800, marginBottom: 8 }}>
34
+ {paid ? "Payment received" : "Payment not completed"}
35
+ </h1>
36
+ <p style={{ opacity: 0.75, marginBottom: 16 }}>
37
+ {paid
38
+ ? `Thank you, ${data.clientName}. Invoice ${data.invoiceNumber} is now paid.`
39
+ : `Invoice ${data.invoiceNumber} is ${data.status}. If you were charged, please contact support.`}
40
+ </p>
41
+ <p style={{ fontWeight: 700, color: theme.colors.primary }}>{fmt(Number(data.total), data.currency)}</p>
42
+ <a
43
+ href={invoicePath}
44
+ style={{ display: "inline-block", marginTop: 24, color: theme.colors.primary, textDecoration: "underline" }}
45
+ >
46
+ View invoice
47
+ </a>
48
+ </div>
49
+ );
50
+ }
@@ -0,0 +1,141 @@
1
+ import { useInvoicePayment } from "../headless/invoice/useInvoicePayment";
2
+ import { usePaymentRenderer } from "../payment/ForgePaymentProvider";
3
+ import { useForgeTheme } from "../theme/ForgeThemeProvider";
4
+ import { useAmountFormatter } from "../format/useFormatCurrency";
5
+ import { summarizeTaxQuote } from "../format/PriceDisplay";
6
+ import { Loading } from "./Loading";
7
+
8
+ export interface InvoicePaymentProps {
9
+ invoiceId: string;
10
+ /** Currency formatter (e.g. `(n) => formatCurrency(n, CURRENCY)`). Falls back to the invoice's own currency. */
11
+ formatAmount?: (n: number) => string;
12
+ /** Return path after payment. Forwarded to `useInvoicePayment`. */
13
+ returnPath?: string;
14
+ }
15
+
16
+ /** Short date only, e.g. "Jun 30, 2026". */
17
+ const formatDate = (value: string | number | Date): string =>
18
+ new Date(value).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
19
+
20
+ /**
21
+ * Themed invoice view + payment surface, built on `useInvoicePayment` and the
22
+ * app-registered payment renderer (`usePaymentRenderer`). Renders line items,
23
+ * a PDF download, and — when unpaid — the Stripe payment surface.
24
+ */
25
+ export function InvoicePayment({ invoiceId, formatAmount, returnPath }: InvoicePaymentProps) {
26
+ const theme = useForgeTheme();
27
+ const fmt = useAmountFormatter(formatAmount);
28
+ const renderPayment = usePaymentRenderer();
29
+ const { invoice, downloadPdf, clientSecret, returnUrl, isStarting, error, taxQuote } = useInvoicePayment(invoiceId, {
30
+ returnPath,
31
+ });
32
+
33
+ if (invoice.isLoading) return <Loading fullPage />;
34
+ if (invoice.error || !invoice.data) return <p style={{ color: theme.colors.text }}>Invoice not found.</p>;
35
+
36
+ const inv = invoice.data;
37
+ const isPaid = inv.status === "paid" || !!inv.paidAt;
38
+ const money = (n: number) => fmt(n, inv.currency);
39
+
40
+ const handleDownload = async () => {
41
+ const blob = await downloadPdf.mutateAsync();
42
+ const url = URL.createObjectURL(blob);
43
+ const a = document.createElement("a");
44
+ a.href = url;
45
+ a.download = `invoice-${inv.invoiceNumber}.pdf`;
46
+ a.click();
47
+ URL.revokeObjectURL(url);
48
+ };
49
+
50
+ return (
51
+ <div style={{ maxWidth: 640, margin: "0 auto", color: theme.colors.text }}>
52
+ <div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 16 }}>
53
+ <div>
54
+ <h1 style={{ fontSize: 26, fontWeight: 800 }}>Invoice {inv.invoiceNumber}</h1>
55
+ <p style={{ opacity: 0.7, fontSize: 14 }}>{inv.profileName}</p>
56
+ </div>
57
+ <button
58
+ onClick={handleDownload}
59
+ disabled={downloadPdf.isPending}
60
+ style={{
61
+ border: `1px solid ${theme.colors.primary}`,
62
+ background: "transparent",
63
+ color: theme.colors.primary,
64
+ borderRadius: theme.cornerRadius,
65
+ padding: "8px 14px",
66
+ cursor: downloadPdf.isPending ? "not-allowed" : "pointer",
67
+ fontSize: 14,
68
+ }}
69
+ >
70
+ {downloadPdf.isPending ? "Preparing…" : "Download PDF"}
71
+ </button>
72
+ </div>
73
+
74
+ <div style={{ fontSize: 14, opacity: 0.85, marginBottom: 16 }}>
75
+ <div>Billed to: {inv.clientName}{inv.clientCompany ? ` · ${inv.clientCompany}` : ""}</div>
76
+ <div>Issued: {formatDate(inv.issueDate)}{inv.dueDate ? ` · Due: ${formatDate(inv.dueDate)}` : ""}</div>
77
+ </div>
78
+
79
+ <div style={{ border: `1px solid ${theme.colors.primary}20`, borderRadius: theme.cornerRadius, overflow: "hidden", marginBottom: 16 }}>
80
+ {inv.lineItems.map((li) => (
81
+ <div
82
+ key={li.id}
83
+ style={{ display: "flex", justifyContent: "space-between", padding: "10px 14px", borderBottom: `1px solid ${theme.colors.primary}10` }}
84
+ >
85
+ <span>
86
+ {li.description}
87
+ <span style={{ opacity: 0.6 }}> × {li.quantity}</span>
88
+ </span>
89
+ <span>{money(Number(li.amount))}</span>
90
+ </div>
91
+ ))}
92
+ {(() => {
93
+ // Invoice tax comes from the invoice's own operator-entered tax
94
+ // columns (no re-quote) — surfaced via the start-payment taxQuote.
95
+ const taxSummary = summarizeTaxQuote(taxQuote, money);
96
+ return taxSummary?.mode === "exclusive" ? (
97
+ <div style={{ display: "flex", justifyContent: "space-between", padding: "10px 14px", borderBottom: `1px solid ${theme.colors.primary}10`, opacity: 0.8 }}>
98
+ <span>Tax</span>
99
+ <span>{taxSummary.formattedTax}</span>
100
+ </div>
101
+ ) : null;
102
+ })()}
103
+ <div style={{ display: "flex", justifyContent: "space-between", padding: "12px 14px", fontWeight: 700 }}>
104
+ <span>Total</span>
105
+ <span style={{ color: theme.colors.primary, textAlign: "right" }}>
106
+ {money(Number(inv.total))}
107
+ {(() => {
108
+ const taxSummary = summarizeTaxQuote(taxQuote, money);
109
+ return taxSummary?.mode === "inclusive" ? (
110
+ <span style={{ display: "block", fontSize: 12, fontWeight: 400, color: theme.colors.text, opacity: 0.65 }}>
111
+ {taxSummary.label}
112
+ </span>
113
+ ) : null;
114
+ })()}
115
+ </span>
116
+ </div>
117
+ </div>
118
+
119
+ {inv.notes && <p style={{ fontSize: 13, opacity: 0.7, marginBottom: 16 }}>{inv.notes}</p>}
120
+
121
+ {isPaid ? (
122
+ <p style={{ fontWeight: 700, color: theme.colors.primary }}>This invoice has been paid. Thank you!</p>
123
+ ) : (
124
+ <div>
125
+ <h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 12 }}>Payment</h2>
126
+ {error && <p style={{ color: "#ef4444", fontSize: 14, marginBottom: 8 }}>{error}</p>}
127
+ {clientSecret ? (
128
+ renderPayment?.({
129
+ clientSecret,
130
+ returnUrl,
131
+ amount: Number(inv.total),
132
+ mode: "redirect",
133
+ })
134
+ ) : (
135
+ <>{isStarting ? <Loading label="Preparing payment…" size={22} /> : <p style={{ opacity: 0.7 }}>Payment is not available right now.</p>}</>
136
+ )}
137
+ </div>
138
+ )}
139
+ </div>
140
+ );
141
+ }