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