@tribe-nest/forge 3.53.0 → 3.54.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 (45) hide show
  1. package/package.json +1 -1
  2. package/src/contexts/AppAuthContext.tsx +3 -2
  3. package/src/contexts/AudioPlayerContext.tsx +3 -2
  4. package/src/contexts/CartContext.tsx +3 -2
  5. package/src/contexts/PublicAuthContext.tsx +3 -2
  6. package/src/data/queries/useEvents.ts +14 -0
  7. package/src/data/queries/useMyTickets.ts +49 -0
  8. package/src/data/queries/usePaymentFlow.ts +8 -0
  9. package/src/i18n/de.json +42 -1
  10. package/src/i18n/en.json +42 -1
  11. package/src/i18n/index.ts +3 -2
  12. package/src/index.ts +22 -0
  13. package/src/provider/ForgeAppProvider.tsx +8 -1
  14. package/src/provider/ForgeProvider.tsx +3 -2
  15. package/src/provider/SiteConfigProvider.tsx +3 -2
  16. package/src/runtime/RemotePage.tsx +110 -0
  17. package/src/runtime/hostRuntime.ts +84 -0
  18. package/src/runtime/pages.spec.ts +41 -0
  19. package/src/runtime/pages.ts +183 -0
  20. package/src/runtime/pagesClient.ts +127 -0
  21. package/src/runtime/registry.spec.ts +45 -0
  22. package/src/runtime/registry.ts +102 -0
  23. package/src/server/jobs.ts +4 -1
  24. package/src/server/platformEvents.generated.ts +92 -0
  25. package/src/types/models.ts +58 -0
  26. package/src/ui/headless/event/_tests/ticketApproval.spec.ts +137 -0
  27. package/src/ui/headless/event/_tests/useEventCheckoutApproval.spec.tsx +250 -0
  28. package/src/ui/headless/event/ticketApproval.ts +109 -0
  29. package/src/ui/headless/event/useEventCheckout.ts +74 -4
  30. package/src/ui/headless/index.ts +11 -0
  31. package/src/ui/index.ts +18 -0
  32. package/src/ui/payment/ForgePaymentProvider.tsx +15 -0
  33. package/src/ui/payment/ForgeStripePayment.tsx +37 -3
  34. package/src/ui/payment/_tests/stripeConfirmOutcome.spec.ts +61 -0
  35. package/src/ui/payment/stripeConfirmOutcome.ts +50 -0
  36. package/src/ui/shell/TribeNestApp.tsx +6 -0
  37. package/src/ui/styled/AccountDashboard.tsx +79 -13
  38. package/src/ui/styled/DocumentSigningPage.tsx +516 -0
  39. package/src/ui/styled/EventConfirmation.tsx +106 -9
  40. package/src/ui/styled/EventTickets.tsx +107 -13
  41. package/src/ui/styled/ReviewRequestPage.tsx +214 -0
  42. package/src/ui/styled/forge-utilities.css +310 -0
  43. package/src/ui/theme/ForgeThemeProvider.tsx +3 -2
  44. package/src/utils/_tests/ticketOrderOutcome.spec.ts +82 -0
  45. package/src/utils/ticketOrderOutcome.ts +57 -1
@@ -4,6 +4,8 @@ import {
4
4
  useUserOrders,
5
5
  useMyTickets,
6
6
  useCancelMyTicket,
7
+ isMyTicketRequestOpenOrEnded,
8
+ myTicketRequestState,
7
9
  myTicketHeldPassIds,
8
10
  myTicketPasses,
9
11
  type MyTicket,
@@ -599,17 +601,47 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
599
601
  const cancelled = !!ticket.selfCancelledAt;
600
602
  // Absent on an older API build, where every row WAS a purchase.
601
603
  const gifted = ticket.role === "holder";
604
+ /**
605
+ * A ticket REQUEST (curated access, feature 3). While it is pending,
606
+ * or once it ended without a sale, the row is a request and not a
607
+ * purchase: no passes exist, so nothing to cancel, transfer or add
608
+ * to a wallet, and the state line is the whole story. The "Decide
609
+ * by" date is the same one the fan's email carries, so somebody who
610
+ * lost the email still knows where they stand.
611
+ */
612
+ const requestState = myTicketRequestState(ticket);
613
+ const isRequestRow = isMyTicketRequestOpenOrEnded(ticket);
614
+ const requestEnded = isRequestRow && requestState !== "pending";
602
615
  return (
603
616
  <div
604
617
  key={ticket.id}
605
- style={{ border: `1px solid ${t.primary}20`, borderRadius: t.cornerRadius, padding: 16, opacity: cancelled ? 0.6 : 1 }}
618
+ data-testid={requestState ? `my-ticket-request-${requestState}` : undefined}
619
+ style={{
620
+ border: `1px solid ${t.primary}20`,
621
+ borderRadius: t.cornerRadius,
622
+ padding: 16,
623
+ opacity: cancelled || requestEnded ? 0.6 : 1,
624
+ }}
606
625
  >
607
626
  <div style={{ display: "flex", justifyContent: "space-between", marginBottom: 12, gap: 12 }}>
608
627
  <div>
609
628
  <div style={{ fontWeight: 600 }}>{ticket.eventTitle}</div>
610
629
  <div style={{ fontSize: 13, opacity: 0.7 }}>{formatDate(ticket.eventDateTime)}</div>
611
630
  </div>
612
- {cancelled && (
631
+ {requestState && (
632
+ <span style={{ fontSize: 13, color: t.primary, textAlign: "right" }}>
633
+ {requestState === "pending"
634
+ ? tr("forge.account_dashboard.request_pending")
635
+ : requestState === "approved"
636
+ ? tr("forge.account_dashboard.request_approved")
637
+ : requestState === "declined"
638
+ ? tr("forge.account_dashboard.request_declined")
639
+ : requestState === "expired"
640
+ ? tr("forge.account_dashboard.request_expired")
641
+ : tr("forge.account_dashboard.request_payment_failed")}
642
+ </span>
643
+ )}
644
+ {cancelled && !requestState && (
613
645
  <span style={{ fontSize: 13, color: t.primary }}>{tr("forge.account_dashboard.cancelled")}</span>
614
646
  )}
615
647
  {/* Says WHY this is in the list at all. Without it a
@@ -641,23 +673,57 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
641
673
 
642
674
  {!gifted && (
643
675
  <div style={{ display: "flex", justifyContent: "space-between", fontWeight: 700, marginTop: 12, paddingTop: 12, borderTop: `1px solid ${t.primary}20` }}>
644
- <span>{tr("forge.account_dashboard.total")}</span>
676
+ {/* Nothing has been taken on a pending request, and never
677
+ will be on one that ended: the label says which. */}
678
+ <span>
679
+ {requestState === "pending"
680
+ ? tr("forge.account_dashboard.total_on_approval")
681
+ : tr("forge.account_dashboard.total")}
682
+ </span>
645
683
  <span style={{ color: t.primary }}>{formatAmount(Number(ticket.totalAmount), ticket.currency || currency)}</span>
646
684
  </div>
647
685
  )}
648
686
 
687
+ {/* State the fan must act on (or wait on) stays visible: the
688
+ deadline while pending, the organiser's note when declined,
689
+ and where to go when the seat was released. */}
690
+ {requestState === "pending" && ticket.request?.expiresAt && (
691
+ <div data-testid="my-ticket-decide-by" style={{ fontSize: 13, marginTop: 12, fontWeight: 600 }}>
692
+ {tr("forge.account_dashboard.request_decide_by", { date: formatDate(ticket.request.expiresAt) })}
693
+ </div>
694
+ )}
695
+ {requestState === "pending" && (
696
+ <div style={{ fontSize: 13, opacity: 0.75, marginTop: 4 }}>
697
+ {tr("forge.account_dashboard.request_nothing_charged")}
698
+ </div>
699
+ )}
700
+ {requestState === "declined" && ticket.request?.decisionNote && (
701
+ <div data-testid="my-ticket-decision-note" style={{ fontSize: 13, opacity: 0.75, marginTop: 12 }}>
702
+ {tr("forge.account_dashboard.request_note", { note: ticket.request.decisionNote })}
703
+ </div>
704
+ )}
705
+ {(requestState === "expired" || requestState === "payment_failed") && (
706
+ <div style={{ fontSize: 13, opacity: 0.75, marginTop: 12 }}>
707
+ {tr("forge.account_dashboard.request_seat_released")}
708
+ </div>
709
+ )}
710
+
649
711
  {/* Diarise it — including the Apple option, which needs the
650
712
  platform's own `.ics` and so could not exist before. Null
651
713
  for a called-off show, and the component draws nothing. */}
652
- <AddToCalendar
653
- variant="compact"
654
- title={ticket.eventTitle}
655
- start={ticket.eventDateTime}
656
- end={ticket.eventEndDateTime}
657
- icsUrl={ticket.calendarUrl}
658
- webcalUrl={ticket.calendarWebcalUrl}
659
- style={{ marginTop: 12 }}
660
- />
714
+ {/* Not offered on a request: the fan has not been admitted to
715
+ anything yet, and a calendar entry says they have. */}
716
+ {!isRequestRow && (
717
+ <AddToCalendar
718
+ variant="compact"
719
+ title={ticket.eventTitle}
720
+ start={ticket.eventDateTime}
721
+ end={ticket.eventEndDateTime}
722
+ icsUrl={ticket.calendarUrl}
723
+ webcalUrl={ticket.calendarWebcalUrl}
724
+ style={{ marginTop: 12 }}
725
+ />
726
+ )}
661
727
 
662
728
  {/*
663
729
  Wallet passes (2.3). Renders NOTHING unless the API offers a
@@ -705,7 +771,7 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
705
771
  <div style={{ fontSize: 13, opacity: 0.75, marginTop: 4 }}>{ticket.cancellation.terms}</div>
706
772
  )}
707
773
 
708
- {!cancelled && ticket.cancellation.canCancel && (
774
+ {!cancelled && !isRequestRow && ticket.cancellation.canCancel && (
709
775
  <button
710
776
  onClick={() => void onCancel(ticket)}
711
777
  disabled={pendingId === ticket.id}
@@ -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
+ }