@tribe-nest/forge 3.53.0 → 3.57.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 +1 -1
- package/src/contexts/AppAuthContext.tsx +3 -2
- package/src/contexts/AudioPlayerContext.tsx +3 -2
- package/src/contexts/CartContext.tsx +3 -2
- package/src/contexts/PublicAuthContext.tsx +3 -2
- package/src/data/queries/useCoachingProducts.ts +15 -1
- package/src/data/queries/useCourses.ts +15 -1
- package/src/data/queries/useEvents.ts +26 -1
- package/src/data/queries/useMembership.ts +176 -4
- package/src/data/queries/useMyTickets.ts +49 -0
- package/src/data/queries/usePaymentFlow.ts +8 -0
- package/src/data/queries/useProducts.ts +15 -1
- package/src/data/queries/useSubscriptions.ts +21 -1
- package/src/i18n/de.json +116 -1
- package/src/i18n/en.json +116 -1
- package/src/i18n/index.ts +3 -2
- package/src/index.ts +22 -0
- package/src/provider/ForgeAppProvider.tsx +8 -1
- package/src/provider/ForgeProvider.tsx +3 -2
- package/src/provider/SiteConfigProvider.tsx +3 -2
- package/src/runtime/RemotePage.tsx +110 -0
- package/src/runtime/hostRuntime.ts +84 -0
- package/src/runtime/pages.spec.ts +41 -0
- package/src/runtime/pages.ts +183 -0
- package/src/runtime/pagesClient.ts +127 -0
- package/src/runtime/registry.spec.ts +45 -0
- package/src/runtime/registry.ts +102 -0
- package/src/server/index.ts +74 -0
- package/src/server/jobs.ts +4 -1
- package/src/server/platformEvents.generated.ts +152 -0
- package/src/types/models.ts +185 -0
- package/src/ui/headless/event/_tests/ticketApproval.spec.ts +137 -0
- package/src/ui/headless/event/_tests/useEventCheckoutApproval.spec.tsx +250 -0
- package/src/ui/headless/event/ticketApproval.ts +109 -0
- package/src/ui/headless/event/useEventCheckout.ts +74 -4
- package/src/ui/headless/index.ts +34 -0
- package/src/ui/headless/membership/_tests/membershipApplication.spec.ts +85 -0
- package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +2 -0
- package/src/ui/headless/membership/_tests/membershipTrial.spec.ts +61 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +253 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +241 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +165 -0
- package/src/ui/headless/membership/membershipApplication.ts +109 -0
- package/src/ui/headless/membership/membershipQuestionnaire.ts +44 -0
- package/src/ui/headless/membership/membershipTrial.ts +53 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +227 -8
- package/src/ui/index.ts +27 -0
- package/src/ui/payment/ForgePaymentProvider.tsx +29 -0
- package/src/ui/payment/ForgeStripePayment.tsx +110 -5
- package/src/ui/payment/_tests/ForgeStripePaymentTrial.spec.tsx +101 -0
- package/src/ui/payment/_tests/stripeConfirmOutcome.spec.ts +61 -0
- package/src/ui/payment/_tests/stripeIntentKind.spec.ts +43 -0
- package/src/ui/payment/stripeConfirmOutcome.ts +50 -0
- package/src/ui/payment/stripeIntentKind.ts +32 -0
- package/src/ui/shell/TribeNestApp.tsx +6 -0
- package/src/ui/styled/AccountDashboard.tsx +239 -15
- package/src/ui/styled/DocumentSigningPage.tsx +516 -0
- package/src/ui/styled/EventConfirmation.tsx +106 -9
- package/src/ui/styled/EventTickets.tsx +107 -13
- package/src/ui/styled/LoginForm.tsx +5 -3
- package/src/ui/styled/MembershipCheckout.tsx +596 -256
- package/src/ui/styled/MembershipTierCallout.tsx +17 -3
- package/src/ui/styled/MembershipTiers.tsx +148 -6
- package/src/ui/styled/ProductGrid.tsx +14 -2
- package/src/ui/styled/ReviewRequestPage.tsx +214 -0
- package/src/ui/styled/SignupForm.tsx +4 -2
- package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +105 -0
- package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +106 -0
- package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +139 -0
- package/src/ui/styled/_tests/membershipTiersTrial.spec.tsx +97 -0
- package/src/ui/styled/forge-utilities.css +310 -0
- package/src/ui/theme/ForgeThemeProvider.tsx +3 -2
- package/src/utils/_tests/ticketOrderOutcome.spec.ts +82 -0
- package/src/utils/ticketOrderOutcome.ts +57 -1
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page a contract's signing link lands on.
|
|
3
|
+
*
|
|
4
|
+
* In Forge rather than in a storefront because both storefronts need it, and
|
|
5
|
+
* because BOTH addresses on each of them render it: the canonical
|
|
6
|
+
* `/i/documents/$token` the platform emails, and the older `/documents/$token`
|
|
7
|
+
* that links already in inboxes still point at.
|
|
8
|
+
*/
|
|
9
|
+
// Public proposal/contract signing page (`/documents/:token` — the emailed
|
|
10
|
+
// magic link IS the identity, no login). Built entirely from the headless
|
|
11
|
+
// document primitives (`useDocumentSigningFlow`, `useOtpCodeInput`,
|
|
12
|
+
// `useContractSignature`, `useSignaturePad`) + Tailwind theme tokens. Lives
|
|
13
|
+
// under `/i/` (centrally managed); the top-level `/documents/$token` route is
|
|
14
|
+
// a thin shell so emailed links keep working while the page stays updatable.
|
|
15
|
+
import { useEffect, useState } from "react";
|
|
16
|
+
import { CheckCircle2, Clock, Download, FileText, PenLine, ShieldCheck, Type, XCircle } from "lucide-react";
|
|
17
|
+
import { Loading } from "./Loading";
|
|
18
|
+
import {
|
|
19
|
+
lexicalToPlainText,
|
|
20
|
+
useContractSignature,
|
|
21
|
+
useDocumentSigningFlow,
|
|
22
|
+
useOtpCodeInput,
|
|
23
|
+
useSignaturePad,
|
|
24
|
+
OTP_LENGTH,
|
|
25
|
+
} from "../headless";
|
|
26
|
+
|
|
27
|
+
const formatDate = (value: string | number | Date): string =>
|
|
28
|
+
new Date(value).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
|
|
29
|
+
|
|
30
|
+
const formatMoney = (amountCents: number, currency: string): string =>
|
|
31
|
+
new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amountCents / 100);
|
|
32
|
+
|
|
33
|
+
export function DocumentSigningPage({ token }: { token: string }) {
|
|
34
|
+
const flow = useDocumentSigningFlow(token);
|
|
35
|
+
const [feedback, setFeedback] = useState<string | null>(null);
|
|
36
|
+
|
|
37
|
+
if (flow.stage === "loading") return <Loading fullPage />;
|
|
38
|
+
|
|
39
|
+
if (flow.stage === "invalid" || !flow.document || !flow.recipient) {
|
|
40
|
+
return (
|
|
41
|
+
<div className="fg:mx-auto fg:w-full fg:max-w-2xl fg:px-4 fg:py-16 fg:text-center">
|
|
42
|
+
<FileText size={40} className="fg:mx-auto fg:mb-4 fg:text-muted" />
|
|
43
|
+
<h1 className="fg:mb-2 fg:text-xl fg:font-bold fg:text-text">Document not available</h1>
|
|
44
|
+
<p className="fg:text-muted">
|
|
45
|
+
This link is invalid or no longer active. If you were expecting a document, ask the sender to send a fresh
|
|
46
|
+
link.
|
|
47
|
+
</p>
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const doc = flow.document;
|
|
53
|
+
const recipient = flow.recipient;
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div className="fg:mx-auto fg:w-full fg:max-w-3xl fg:px-4 fg:py-8 fg:lg:py-12">
|
|
57
|
+
{/* Header */}
|
|
58
|
+
<div className="fg:mb-6">
|
|
59
|
+
<div className="fg:mb-2 fg:flex fg:items-center fg:gap-2">
|
|
60
|
+
<span className="fg:rounded-full fg:border fg:border-primary fg:px-2 fg:py-0.5 fg:text-xs fg:font-semibold fg:uppercase fg:tracking-wide fg:text-primary">
|
|
61
|
+
{doc.kind === "contract" ? "Contract" : "Proposal"}
|
|
62
|
+
</span>
|
|
63
|
+
{doc.expiresAt && !flow.isTerminal && (
|
|
64
|
+
<span className="fg:flex fg:items-center fg:gap-1 fg:text-xs fg:text-muted">
|
|
65
|
+
<Clock size={12} /> Expires {formatDate(doc.expiresAt)}
|
|
66
|
+
</span>
|
|
67
|
+
)}
|
|
68
|
+
</div>
|
|
69
|
+
<h1 className="fg:text-2xl fg:font-bold fg:text-text fg:lg:text-3xl">{doc.title}</h1>
|
|
70
|
+
<p className="fg:mt-1 fg:text-sm fg:text-muted">
|
|
71
|
+
{flow.profile?.name ? `From ${flow.profile.name} · ` : ""}Sent to {recipient.email}
|
|
72
|
+
{doc.sentAt ? ` · ${formatDate(doc.sentAt)}` : ""}
|
|
73
|
+
</p>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
{/* Terminal / waiting banners */}
|
|
77
|
+
<StatusBanner flow={flow} />
|
|
78
|
+
|
|
79
|
+
{/* Document body */}
|
|
80
|
+
<div className="fg:mb-8 fg:rounded-xl fg:border fg:border-border fg:p-5 fg:lg:p-8">
|
|
81
|
+
{doc.bodyType === "pdf" && doc.sourcePdfUrl ? (
|
|
82
|
+
<PdfEmbed url={doc.sourcePdfUrl} title={doc.title} />
|
|
83
|
+
) : (
|
|
84
|
+
<p className="fg:whitespace-pre-line fg:leading-relaxed fg:text-text/90">{lexicalToPlainText(doc.body)}</p>
|
|
85
|
+
)}
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
{/* Action area */}
|
|
89
|
+
{flow.stage === "otp" && <OtpGate flow={flow} onFeedback={setFeedback} />}
|
|
90
|
+
{flow.stage === "sign" && <SignPanel token={token} flow={flow} onFeedback={setFeedback} />}
|
|
91
|
+
{flow.stage === "accept" && <ProposalPanel flow={flow} onFeedback={setFeedback} />}
|
|
92
|
+
|
|
93
|
+
{feedback && <p className="fg:mt-4 fg:rounded-lg fg:border fg:border-border fg:bg-surface fg:px-3 fg:py-2 fg:text-sm fg:text-text">{feedback}</p>}
|
|
94
|
+
|
|
95
|
+
<p className="fg:mt-8 fg:text-center fg:text-xs fg:text-muted">
|
|
96
|
+
All activity on this document (opens, verification, signatures, and downloads) is recorded in a
|
|
97
|
+
tamper-evident audit trail.
|
|
98
|
+
</p>
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
type Flow = ReturnType<typeof useDocumentSigningFlow>;
|
|
104
|
+
|
|
105
|
+
function StatusBanner({ flow }: { flow: Flow }) {
|
|
106
|
+
const doc = flow.document!;
|
|
107
|
+
const recipient = flow.recipient!;
|
|
108
|
+
|
|
109
|
+
const banner = (icon: React.ReactNode, title: string, detail?: string, action?: React.ReactNode) => (
|
|
110
|
+
<div className="fg:mb-6 fg:flex fg:flex-col fg:gap-3 fg:rounded-xl fg:border fg:border-primary/40 fg:bg-primary/5 fg:p-5 fg:sm:flex-row fg:sm:items-center fg:sm:justify-between">
|
|
111
|
+
<div className="fg:flex fg:items-start fg:gap-3">
|
|
112
|
+
{icon}
|
|
113
|
+
<div>
|
|
114
|
+
<p className="fg:font-semibold fg:text-text">{title}</p>
|
|
115
|
+
{detail && <p className="fg:mt-0.5 fg:text-sm fg:text-muted">{detail}</p>}
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
{action}
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const downloadButton = doc.signedPdfUrl ? (
|
|
123
|
+
<button
|
|
124
|
+
type="button"
|
|
125
|
+
onClick={() => flow.download.mutate()}
|
|
126
|
+
disabled={flow.download.isPending}
|
|
127
|
+
className="fg:flex fg:shrink-0 fg:items-center fg:gap-2 fg:rounded-lg fg:border fg:border-primary fg:px-4 fg:py-2 fg:text-sm fg:font-medium fg:text-primary fg:disabled:opacity-50"
|
|
128
|
+
>
|
|
129
|
+
<Download size={16} /> {flow.download.isPending ? "Preparing…" : "Download signed PDF"}
|
|
130
|
+
</button>
|
|
131
|
+
) : undefined;
|
|
132
|
+
|
|
133
|
+
switch (doc.status) {
|
|
134
|
+
case "signed":
|
|
135
|
+
return banner(
|
|
136
|
+
<CheckCircle2 size={22} className="fg:mt-0.5 fg:shrink-0 fg:text-primary" />,
|
|
137
|
+
"This contract has been signed by all parties.",
|
|
138
|
+
`${recipient.signedName ? `Signed by ${recipient.signedName}` : "Signed"}${
|
|
139
|
+
recipient.signedAt ? ` on ${formatDate(recipient.signedAt)}` : ""
|
|
140
|
+
}. A copy was emailed to all signers.`,
|
|
141
|
+
downloadButton,
|
|
142
|
+
);
|
|
143
|
+
case "accepted":
|
|
144
|
+
return banner(
|
|
145
|
+
<CheckCircle2 size={22} className="fg:mt-0.5 fg:shrink-0 fg:text-primary" />,
|
|
146
|
+
"This proposal has been accepted.",
|
|
147
|
+
flow.selectedPricingOption
|
|
148
|
+
? `Selected option: ${flow.selectedPricingOption.label}, ${formatMoney(
|
|
149
|
+
flow.selectedPricingOption.amountCents,
|
|
150
|
+
flow.selectedPricingOption.currency,
|
|
151
|
+
)}${flow.selectedPricingOption.isRecurring ? " (recurring)" : ""}`
|
|
152
|
+
: doc.amountCents != null && doc.currency
|
|
153
|
+
? `Accepted at ${formatMoney(doc.amountCents, doc.currency)}`
|
|
154
|
+
: "The sender has been notified.",
|
|
155
|
+
);
|
|
156
|
+
case "converted":
|
|
157
|
+
return banner(
|
|
158
|
+
<CheckCircle2 size={22} className="fg:mt-0.5 fg:shrink-0 fg:text-primary" />,
|
|
159
|
+
`This ${flow.kindLabel} is complete.`,
|
|
160
|
+
"It has been finalized by the sender.",
|
|
161
|
+
downloadButton,
|
|
162
|
+
);
|
|
163
|
+
case "declined":
|
|
164
|
+
return banner(
|
|
165
|
+
<XCircle size={22} className="fg:mt-0.5 fg:shrink-0 fg:text-muted" />,
|
|
166
|
+
`This ${flow.kindLabel} was declined.`,
|
|
167
|
+
"No further action is needed. Contact the sender if this was a mistake.",
|
|
168
|
+
);
|
|
169
|
+
case "expired":
|
|
170
|
+
return banner(
|
|
171
|
+
<Clock size={22} className="fg:mt-0.5 fg:shrink-0 fg:text-muted" />,
|
|
172
|
+
`This ${flow.kindLabel} has expired.`,
|
|
173
|
+
"The signing window has closed. Ask the sender to issue a new one.",
|
|
174
|
+
);
|
|
175
|
+
default:
|
|
176
|
+
if (recipient.status === "signed") {
|
|
177
|
+
return banner(
|
|
178
|
+
<CheckCircle2 size={22} className="fg:mt-0.5 fg:shrink-0 fg:text-primary" />,
|
|
179
|
+
"You've signed this contract.",
|
|
180
|
+
"Waiting for the remaining signatures. You'll receive the completed copy by email.",
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Email OTP verification gate — request the 6-digit code, enter, confirm.
|
|
188
|
+
function OtpGate({ flow, onFeedback }: { flow: Flow; onFeedback: (msg: string | null) => void }) {
|
|
189
|
+
const [codeSent, setCodeSent] = useState(false);
|
|
190
|
+
const otp = useOtpCodeInput({
|
|
191
|
+
disabled: flow.confirmOtp.isPending,
|
|
192
|
+
onComplete: (code) => confirm(code),
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const request = () => {
|
|
196
|
+
onFeedback(null);
|
|
197
|
+
flow.requestOtp.mutate(undefined, {
|
|
198
|
+
onSuccess: () => setCodeSent(true),
|
|
199
|
+
onError: () => onFeedback("Could not send the verification code."),
|
|
200
|
+
});
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const confirm = (code: string) => {
|
|
204
|
+
if (code.length !== OTP_LENGTH) return;
|
|
205
|
+
flow.confirmOtp.mutate(code, {
|
|
206
|
+
onSuccess: () => onFeedback(null),
|
|
207
|
+
onError: () => {
|
|
208
|
+
otp.reset();
|
|
209
|
+
onFeedback("That code didn't work. Try again.");
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
return (
|
|
215
|
+
<div className="fg:rounded-xl fg:border fg:border-border fg:p-5">
|
|
216
|
+
<div className="fg:mb-1 fg:flex fg:items-center fg:gap-2">
|
|
217
|
+
<ShieldCheck size={18} className="fg:text-primary" />
|
|
218
|
+
<h3 className="fg:text-base fg:font-semibold fg:text-text">Verify your email to continue</h3>
|
|
219
|
+
</div>
|
|
220
|
+
<p className="fg:mb-4 fg:text-sm fg:text-muted">
|
|
221
|
+
This document requires email verification before you can act on it. We'll send a 6-digit code to{" "}
|
|
222
|
+
<span className="fg:font-medium fg:text-text">{flow.recipient!.email}</span>.
|
|
223
|
+
</p>
|
|
224
|
+
{!codeSent ? (
|
|
225
|
+
<button
|
|
226
|
+
type="button"
|
|
227
|
+
onClick={request}
|
|
228
|
+
disabled={flow.requestOtp.isPending}
|
|
229
|
+
className="fg:rounded-lg fg:bg-primary fg:px-4 fg:py-2 fg:text-sm fg:font-semibold fg:disabled:opacity-50"
|
|
230
|
+
style={{ color: "var(--forge-text-primary, #fff)" }}
|
|
231
|
+
>
|
|
232
|
+
{flow.requestOtp.isPending ? "Sending…" : "Send verification code"}
|
|
233
|
+
</button>
|
|
234
|
+
) : (
|
|
235
|
+
<div>
|
|
236
|
+
<div className="fg:mb-3 fg:flex fg:gap-2">
|
|
237
|
+
{otp.digits.map((digit, index) => (
|
|
238
|
+
<input
|
|
239
|
+
key={index}
|
|
240
|
+
{...otp.getInputProps(index)}
|
|
241
|
+
className={`fg:h-12 fg:w-10 fg:rounded-lg fg:border fg:bg-transparent fg:text-center fg:text-xl fg:font-semibold fg:text-text fg:outline-none ${
|
|
242
|
+
digit ? "fg:border-primary" : "fg:border-border"
|
|
243
|
+
}`}
|
|
244
|
+
/>
|
|
245
|
+
))}
|
|
246
|
+
</div>
|
|
247
|
+
<div className="fg:flex fg:items-center fg:gap-4">
|
|
248
|
+
<button
|
|
249
|
+
type="button"
|
|
250
|
+
onClick={() => confirm(otp.code)}
|
|
251
|
+
disabled={flow.confirmOtp.isPending || !otp.isComplete}
|
|
252
|
+
className="fg:rounded-lg fg:bg-primary fg:px-4 fg:py-2 fg:text-sm fg:font-semibold fg:disabled:opacity-50"
|
|
253
|
+
style={{ color: "var(--forge-text-primary, #fff)" }}
|
|
254
|
+
>
|
|
255
|
+
{flow.confirmOtp.isPending ? "Verifying…" : "Verify"}
|
|
256
|
+
</button>
|
|
257
|
+
<button
|
|
258
|
+
type="button"
|
|
259
|
+
onClick={request}
|
|
260
|
+
disabled={flow.requestOtp.isPending}
|
|
261
|
+
className="fg:text-sm fg:text-muted fg:underline fg:disabled:opacity-40"
|
|
262
|
+
>
|
|
263
|
+
{flow.requestOtp.isPending ? "Resending…" : "Resend code"}
|
|
264
|
+
</button>
|
|
265
|
+
</div>
|
|
266
|
+
</div>
|
|
267
|
+
)}
|
|
268
|
+
</div>
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Contract signature capture: draw (headless canvas pad) or type; legal name +
|
|
273
|
+
// explicit e-signature consent are required in both modes.
|
|
274
|
+
function SignPanel({ token, flow, onFeedback }: { token: string; flow: Flow; onFeedback: (msg: string | null) => void }) {
|
|
275
|
+
// Canvas strokeStyle can't resolve CSS vars — near-black ink reads well on
|
|
276
|
+
// the light pad surface regardless of theme.
|
|
277
|
+
const pad = useSignaturePad({ inkColor: "#111111" });
|
|
278
|
+
const signature = useContractSignature(token, {
|
|
279
|
+
defaultName: flow.recipient!.name || "",
|
|
280
|
+
getDrawnSignature: pad.toDataURL,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// Wire the pad's ink state into the signature submit gating.
|
|
284
|
+
const { hasInk } = pad;
|
|
285
|
+
const { setHasInk } = signature;
|
|
286
|
+
useEffect(() => setHasInk(hasInk), [hasInk, setHasInk]);
|
|
287
|
+
|
|
288
|
+
const submit = async () => {
|
|
289
|
+
onFeedback(null);
|
|
290
|
+
const result = await signature.submit();
|
|
291
|
+
if (!result.ok && result.error) onFeedback(result.error);
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const modeButton = (value: "draw" | "type", label: string, Icon: typeof PenLine) => (
|
|
295
|
+
<button
|
|
296
|
+
type="button"
|
|
297
|
+
onClick={() => signature.setMode(value)}
|
|
298
|
+
className={`fg:flex fg:flex-1 fg:items-center fg:justify-center fg:gap-2 fg:rounded-lg fg:border fg:px-3 fg:py-2 fg:text-sm fg:font-medium ${
|
|
299
|
+
signature.mode === value ? "fg:border-primary fg:bg-primary/10 fg:text-primary" : "fg:border-border fg:text-muted"
|
|
300
|
+
}`}
|
|
301
|
+
>
|
|
302
|
+
<Icon size={16} /> {label}
|
|
303
|
+
</button>
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
return (
|
|
307
|
+
<div className="fg:rounded-xl fg:border fg:border-border fg:p-5">
|
|
308
|
+
<h3 className="fg:mb-4 fg:text-base fg:font-semibold fg:text-text">Sign this contract</h3>
|
|
309
|
+
|
|
310
|
+
<div className="fg:mb-4 fg:flex fg:gap-2">
|
|
311
|
+
{modeButton("draw", "Draw", PenLine)}
|
|
312
|
+
{modeButton("type", "Type", Type)}
|
|
313
|
+
</div>
|
|
314
|
+
|
|
315
|
+
{signature.mode === "draw" && (
|
|
316
|
+
<div>
|
|
317
|
+
<div className="fg:relative fg:overflow-hidden fg:rounded-lg fg:border fg:border-dashed fg:border-border fg:bg-surface">
|
|
318
|
+
<canvas {...pad.getCanvasProps()} style={{ ...pad.getCanvasProps().style, height: 180 }} />
|
|
319
|
+
{!pad.hasInk && (
|
|
320
|
+
<span className="fg:pointer-events-none fg:absolute fg:inset-0 fg:flex fg:items-center fg:justify-center fg:text-sm fg:text-muted">
|
|
321
|
+
Draw your signature here
|
|
322
|
+
</span>
|
|
323
|
+
)}
|
|
324
|
+
</div>
|
|
325
|
+
<div className="fg:mt-2 fg:flex fg:justify-end">
|
|
326
|
+
<button
|
|
327
|
+
type="button"
|
|
328
|
+
onClick={pad.clear}
|
|
329
|
+
disabled={!pad.hasInk}
|
|
330
|
+
className="fg:text-sm fg:text-muted fg:underline fg:disabled:opacity-40"
|
|
331
|
+
>
|
|
332
|
+
Clear
|
|
333
|
+
</button>
|
|
334
|
+
</div>
|
|
335
|
+
</div>
|
|
336
|
+
)}
|
|
337
|
+
|
|
338
|
+
<label className="fg:mb-1 fg:mt-4 fg:block fg:text-sm fg:font-medium fg:text-text">Full legal name</label>
|
|
339
|
+
<div className="fg:mb-1 fg:w-full fg:rounded-lg fg:border fg:border-border fg:bg-surface fg:px-3 fg:py-2 fg:text-text">
|
|
340
|
+
{signature.legalName || "Not set"}
|
|
341
|
+
</div>
|
|
342
|
+
<p className="fg:mb-3 fg:text-xs fg:text-muted">Set by the sender and can't be changed.</p>
|
|
343
|
+
|
|
344
|
+
{signature.mode === "type" && signature.legalName.trim() && (
|
|
345
|
+
<div className="fg:mb-3 fg:flex fg:items-center fg:justify-center fg:rounded-lg fg:border fg:border-dashed fg:border-border fg:bg-surface fg:px-4 fg:py-5">
|
|
346
|
+
<span className="fg:text-3xl fg:text-text" style={{ fontFamily: "cursive" }}>
|
|
347
|
+
{signature.legalName.trim()}
|
|
348
|
+
</span>
|
|
349
|
+
</div>
|
|
350
|
+
)}
|
|
351
|
+
|
|
352
|
+
<label className="fg:mb-5 fg:flex fg:cursor-pointer fg:items-start fg:gap-2 fg:text-sm fg:text-text">
|
|
353
|
+
<input
|
|
354
|
+
type="checkbox"
|
|
355
|
+
checked={signature.consent}
|
|
356
|
+
onChange={(e) => signature.setConsent(e.target.checked)}
|
|
357
|
+
className="fg:mt-0.5 fg:h-4 fg:w-4 fg:shrink-0 fg:accent-primary"
|
|
358
|
+
/>
|
|
359
|
+
<span>I agree this constitutes my electronic signature.</span>
|
|
360
|
+
</label>
|
|
361
|
+
|
|
362
|
+
<button
|
|
363
|
+
type="button"
|
|
364
|
+
onClick={submit}
|
|
365
|
+
disabled={!signature.canSubmit}
|
|
366
|
+
className="fg:w-full fg:rounded-lg fg:bg-primary fg:py-2.5 fg:font-semibold fg:disabled:opacity-50"
|
|
367
|
+
style={{ color: "var(--forge-text-primary, #fff)" }}
|
|
368
|
+
>
|
|
369
|
+
{signature.isSigning ? "Signing…" : "Sign contract"}
|
|
370
|
+
</button>
|
|
371
|
+
|
|
372
|
+
<p className="fg:mt-3 fg:text-xs fg:text-muted">
|
|
373
|
+
Your name, email, IP address, and the time of signing are recorded in this document's audit trail. Signing as{" "}
|
|
374
|
+
{flow.recipient!.email}.
|
|
375
|
+
</p>
|
|
376
|
+
|
|
377
|
+
<DeclineControl flow={flow} onFeedback={onFeedback} />
|
|
378
|
+
</div>
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Proposal acceptance: pricing option radio group (defaulting to the first)
|
|
383
|
+
// + accept, with the quiet decline control underneath.
|
|
384
|
+
function ProposalPanel({ flow, onFeedback }: { flow: Flow; onFeedback: (msg: string | null) => void }) {
|
|
385
|
+
const options = flow.pricingOptions;
|
|
386
|
+
const [selectedId, setSelectedId] = useState<string | undefined>(options[0]?.id);
|
|
387
|
+
const doc = flow.document!;
|
|
388
|
+
|
|
389
|
+
const accept = () => {
|
|
390
|
+
onFeedback(null);
|
|
391
|
+
flow.accept.mutate(
|
|
392
|
+
{ ...(options.length > 0 && selectedId ? { pricingOptionId: selectedId } : {}) },
|
|
393
|
+
{ onError: () => onFeedback("Accepting failed. Please try again.") },
|
|
394
|
+
);
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
return (
|
|
398
|
+
<div className="fg:rounded-xl fg:border fg:border-border fg:p-5">
|
|
399
|
+
<h3 className="fg:mb-4 fg:text-base fg:font-semibold fg:text-text">Accept this proposal</h3>
|
|
400
|
+
|
|
401
|
+
{options.length > 0 ? (
|
|
402
|
+
<div className="fg:mb-4 fg:flex fg:flex-col fg:gap-2" role="radiogroup" aria-label="Pricing options">
|
|
403
|
+
{options.map((option) => (
|
|
404
|
+
<label
|
|
405
|
+
key={option.id}
|
|
406
|
+
className={`fg:flex fg:cursor-pointer fg:items-center fg:justify-between fg:gap-3 fg:rounded-lg fg:border fg:p-3 ${
|
|
407
|
+
selectedId === option.id ? "fg:border-primary fg:bg-primary/5" : "fg:border-border"
|
|
408
|
+
}`}
|
|
409
|
+
>
|
|
410
|
+
<span className="fg:flex fg:items-center fg:gap-2.5">
|
|
411
|
+
<input
|
|
412
|
+
type="radio"
|
|
413
|
+
name="pricing-option"
|
|
414
|
+
checked={selectedId === option.id}
|
|
415
|
+
onChange={() => setSelectedId(option.id)}
|
|
416
|
+
className="fg:h-4 fg:w-4 fg:accent-primary"
|
|
417
|
+
/>
|
|
418
|
+
<span className="fg:text-sm fg:font-medium fg:text-text">{option.label}</span>
|
|
419
|
+
</span>
|
|
420
|
+
<span className="fg:text-sm fg:font-bold fg:text-text">
|
|
421
|
+
{formatMoney(option.amountCents, option.currency)}
|
|
422
|
+
{option.isRecurring && <span className="fg:font-normal fg:text-muted"> / recurring</span>}
|
|
423
|
+
</span>
|
|
424
|
+
</label>
|
|
425
|
+
))}
|
|
426
|
+
</div>
|
|
427
|
+
) : (
|
|
428
|
+
doc.amountCents != null &&
|
|
429
|
+
doc.currency && (
|
|
430
|
+
<p className="fg:mb-4 fg:text-2xl fg:font-extrabold fg:text-text">{formatMoney(doc.amountCents, doc.currency)}</p>
|
|
431
|
+
)
|
|
432
|
+
)}
|
|
433
|
+
|
|
434
|
+
<button
|
|
435
|
+
type="button"
|
|
436
|
+
onClick={accept}
|
|
437
|
+
disabled={flow.accept.isPending}
|
|
438
|
+
className="fg:w-full fg:rounded-lg fg:bg-primary fg:py-2.5 fg:font-semibold fg:disabled:opacity-50"
|
|
439
|
+
style={{ color: "var(--forge-text-primary, #fff)" }}
|
|
440
|
+
>
|
|
441
|
+
{flow.accept.isPending ? "Accepting…" : "Accept proposal"}
|
|
442
|
+
</button>
|
|
443
|
+
|
|
444
|
+
<DeclineControl flow={flow} onFeedback={onFeedback} />
|
|
445
|
+
</div>
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Quiet expandable decline control under both action panels.
|
|
450
|
+
function DeclineControl({ flow, onFeedback }: { flow: Flow; onFeedback: (msg: string | null) => void }) {
|
|
451
|
+
const [open, setOpen] = useState(false);
|
|
452
|
+
const [reason, setReason] = useState("");
|
|
453
|
+
|
|
454
|
+
const decline = () => {
|
|
455
|
+
onFeedback(null);
|
|
456
|
+
flow.decline.mutate(
|
|
457
|
+
{ reason: reason.trim() || undefined },
|
|
458
|
+
{ onError: () => onFeedback("Declining failed. Please try again.") },
|
|
459
|
+
);
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
return (
|
|
463
|
+
<div className="fg:mt-5">
|
|
464
|
+
{!open ? (
|
|
465
|
+
<button type="button" onClick={() => setOpen(true)} className="fg:text-sm fg:text-muted fg:underline">
|
|
466
|
+
Decline this {flow.kindLabel} instead
|
|
467
|
+
</button>
|
|
468
|
+
) : (
|
|
469
|
+
<div className="fg:rounded-lg fg:border fg:border-border fg:p-3">
|
|
470
|
+
<label className="fg:mb-1 fg:block fg:text-sm fg:font-medium fg:text-text">
|
|
471
|
+
Reason <span className="fg:font-normal fg:text-muted">(optional)</span>
|
|
472
|
+
</label>
|
|
473
|
+
<textarea
|
|
474
|
+
rows={2}
|
|
475
|
+
maxLength={2000}
|
|
476
|
+
value={reason}
|
|
477
|
+
onChange={(e) => setReason(e.target.value)}
|
|
478
|
+
className="fg:mb-2 fg:w-full fg:resize-y fg:rounded-lg fg:border fg:border-border fg:bg-transparent fg:px-3 fg:py-2 fg:text-sm fg:text-text fg:outline-none fg:focus:border-primary"
|
|
479
|
+
/>
|
|
480
|
+
<div className="fg:flex fg:items-center fg:gap-3">
|
|
481
|
+
<button
|
|
482
|
+
type="button"
|
|
483
|
+
onClick={decline}
|
|
484
|
+
disabled={flow.decline.isPending}
|
|
485
|
+
className="fg:rounded-lg fg:border fg:border-border fg:px-3 fg:py-1.5 fg:text-sm fg:font-semibold fg:text-text fg:disabled:opacity-50"
|
|
486
|
+
>
|
|
487
|
+
{flow.decline.isPending ? "Declining…" : "Decline"}
|
|
488
|
+
</button>
|
|
489
|
+
<button type="button" onClick={() => setOpen(false)} className="fg:text-sm fg:text-muted fg:underline">
|
|
490
|
+
Cancel
|
|
491
|
+
</button>
|
|
492
|
+
</div>
|
|
493
|
+
</div>
|
|
494
|
+
)}
|
|
495
|
+
</div>
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Embedded PDF body with a graceful fallback link (iOS often refuses inline PDFs).
|
|
500
|
+
function PdfEmbed({ url, title }: { url: string; title: string }) {
|
|
501
|
+
const safeUrl = encodeURI(url);
|
|
502
|
+
return (
|
|
503
|
+
<div>
|
|
504
|
+
<object data={safeUrl} type="application/pdf" className="fg:h-[70vh] fg:w-full" aria-label={title}>
|
|
505
|
+
<iframe src={safeUrl} title={title} className="fg:h-[70vh] fg:w-full" style={{ border: 0 }} />
|
|
506
|
+
</object>
|
|
507
|
+
<p className="fg:mt-2 fg:text-xs fg:text-muted">
|
|
508
|
+
Trouble viewing?{" "}
|
|
509
|
+
<a href={safeUrl} target="_blank" rel="noopener noreferrer" className="fg:text-primary fg:underline">
|
|
510
|
+
Open the PDF in a new tab
|
|
511
|
+
</a>
|
|
512
|
+
.
|
|
513
|
+
</p>
|
|
514
|
+
</div>
|
|
515
|
+
);
|
|
516
|
+
}
|