@tribe-nest/forge 3.14.0 → 3.19.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 (36) hide show
  1. package/package.json +1 -1
  2. package/src/contexts/CartContext.tsx +25 -1
  3. package/src/data/queries/useCheckouts.ts +14 -0
  4. package/src/data/queries/useCoachingAvailability.ts +13 -0
  5. package/src/data/queries/useCourseAccess.ts +81 -0
  6. package/src/data/queries/useCourses.ts +19 -1
  7. package/src/data/queries/useEvents.ts +6 -0
  8. package/src/data/queries/useFinalize.ts +24 -4
  9. package/src/index.ts +7 -0
  10. package/src/types/models.ts +41 -1
  11. package/src/ui/format/_tests/pwyw.spec.ts +157 -0
  12. package/src/ui/format/pwyw.ts +95 -0
  13. package/src/ui/headless/booking/useBookingSecret.ts +41 -0
  14. package/src/ui/headless/coaching/useCoachingBooking.ts +26 -3
  15. package/src/ui/headless/course/_tests/courseAccessGate.spec.ts +135 -0
  16. package/src/ui/headless/course/useCourseCheckout.ts +18 -1
  17. package/src/ui/headless/course/useCourseClassroom.ts +242 -0
  18. package/src/ui/headless/event/useEventCheckout.ts +96 -6
  19. package/src/ui/headless/index.ts +11 -0
  20. package/src/ui/index.ts +17 -0
  21. package/src/ui/styled/AccountDashboard.tsx +7 -0
  22. package/src/ui/styled/Cart.tsx +11 -8
  23. package/src/ui/styled/CartLineOptions.tsx +107 -0
  24. package/src/ui/styled/Checkout.tsx +5 -0
  25. package/src/ui/styled/CheckoutConfirmation.tsx +4 -6
  26. package/src/ui/styled/CoachingBooking.tsx +9 -1
  27. package/src/ui/styled/CoachingConfirmation.tsx +44 -3
  28. package/src/ui/styled/Confirmation.tsx +94 -0
  29. package/src/ui/styled/CourseAccess.tsx +191 -51
  30. package/src/ui/styled/CourseCheckout.tsx +9 -1
  31. package/src/ui/styled/CourseConfirmation.tsx +42 -3
  32. package/src/ui/styled/EventTickets.tsx +114 -0
  33. package/src/ui/styled/InvoicePayment.tsx +65 -11
  34. package/src/ui/styled/ProductDetail.tsx +8 -0
  35. package/src/utils/_tests/bookingSecret.spec.ts +115 -0
  36. package/src/utils/bookingSecret.ts +100 -0
@@ -1,85 +1,216 @@
1
- import { useRef, useState } from "react";
2
- import { useCourseAccess, useUpdateCourseProgress } from "../../data/queries/useCourseAccess";
3
- import { useForgeTheme } from "../theme/ForgeThemeProvider";
1
+ import type { CSSProperties } from "react";
2
+ import { useThemeTokens } from "../theme/ForgeThemeProvider";
3
+ import { useCourseClassroom, type UseCourseClassroomOptions } from "../headless/course/useCourseClassroom";
4
4
  import { Loading } from "./Loading";
5
5
 
6
- export interface CourseAccessProps {
6
+ export interface CourseAccessProps extends UseCourseClassroomOptions {
7
7
  /** Course access id (route param). */
8
8
  accessId: string;
9
+ /** SPA navigation adapter for the sign-in / secure-access links. Defaults to a full-page visit. */
10
+ navigate?: (href: string) => void;
11
+ className?: string;
12
+ style?: CSSProperties;
9
13
  }
10
14
 
11
- /** Enrolled course player — module/lesson sidebar, video progress tracking. Built on `useCourseAccess` + `useUpdateCourseProgress`. */
12
- export function CourseAccess({ accessId }: CourseAccessProps) {
13
- const theme = useForgeTheme();
14
- const { data, isLoading, error } = useCourseAccess(accessId);
15
- const updateProgress = useUpdateCourseProgress(accessId);
16
- const lastSent = useRef(0);
15
+ /**
16
+ * Enrolled course player module/lesson sidebar, video progress tracking, and
17
+ * the account-binding gate that stands in front of both. Built on the headless
18
+ * `useCourseClassroom`.
19
+ *
20
+ * The three states worth naming, because each one strands somebody if it is
21
+ * drawn as the others:
22
+ *
23
+ * - **401** — a bound grant opened with no session. Almost always the buyer,
24
+ * on an old email link, on a device that never signed in. Gets a sign-in
25
+ * button that returns HERE, never a dead end.
26
+ * - **403** — a bound grant opened as somebody else. Gets "this belongs to a
27
+ * different account" and a way OUT of the current session. Never the word
28
+ * "sign in", which they have already done.
29
+ * - **legacy** — no `account_id`, so the link alone is the credential. Opens
30
+ * normally, with a dismissible note offering to put it behind an account.
31
+ */
32
+ export function CourseAccess({
33
+ accessId,
34
+ navigate,
35
+ className,
36
+ style,
37
+ ...classroomOptions
38
+ }: CourseAccessProps) {
39
+ const t = useThemeTokens();
40
+ const classroom = useCourseClassroom(accessId, classroomOptions);
41
+ const go = navigate ?? ((href: string) => window.location.assign(href));
17
42
 
18
- const modules = data?.course.modules ?? [];
19
- const allLessons = modules.flatMap((m) => m.lessons);
20
- const initialLessonId = data?.access.currentLessonId || allLessons[0]?.id || null;
21
- const [selectedLessonId, setSelectedLessonId] = useState<string | null>(initialLessonId);
43
+ if (classroom.isLoading) return <Loading fullPage />;
22
44
 
23
- if (isLoading) return <Loading fullPage />;
24
- if (error || !data) return <p style={{ color: theme.colors.text }}>Course access not found.</p>;
45
+ const panel: CSSProperties = {
46
+ maxWidth: 520,
47
+ margin: "0 auto",
48
+ padding: 28,
49
+ borderRadius: t.cornerRadius,
50
+ border: `1px solid ${t.border}`,
51
+ background: t.surface,
52
+ color: t.text,
53
+ textAlign: "center",
54
+ display: "flex",
55
+ flexDirection: "column",
56
+ gap: 12,
57
+ fontFamily: t.fontFamily,
58
+ };
59
+
60
+ const primaryButton: CSSProperties = {
61
+ alignSelf: "center",
62
+ padding: "10px 20px",
63
+ borderRadius: t.cornerRadius,
64
+ border: "none",
65
+ background: t.primary,
66
+ color: t.textPrimary,
67
+ fontSize: 14,
68
+ fontWeight: 700,
69
+ cursor: "pointer",
70
+ textDecoration: "none",
71
+ };
72
+
73
+ const gate = classroom.gate;
74
+ if (gate) {
75
+ return (
76
+ <div className={className} style={{ ...panel, ...style }}>
77
+ <h1 style={{ fontSize: 20, fontWeight: 800, fontFamily: t.headingFontFamily || t.fontFamily }}>
78
+ {gate.title}
79
+ </h1>
80
+ <p style={{ fontSize: 14, color: t.muted, lineHeight: 1.6 }}>{gate.body}</p>
81
+
82
+ {/* Naming the session they are in is what makes "wrong account"
83
+ actionable — otherwise the reader has no idea which of their
84
+ addresses they are currently signed in with. */}
85
+ {gate.kind === "wrong_account" && classroom.signedInEmail && (
86
+ <p style={{ fontSize: 13, color: t.muted }}>
87
+ Signed in as <strong style={{ color: t.text }}>{classroom.signedInEmail}</strong>
88
+ </p>
89
+ )}
90
+
91
+ {gate.actionLabel && gate.actionHref && (
92
+ <button type="button" style={primaryButton} onClick={() => go(gate.actionHref!)}>
93
+ {gate.actionLabel}
94
+ </button>
95
+ )}
25
96
 
26
- const selected = allLessons.find((l) => l.id === selectedLessonId) ?? allLessons[0];
97
+ {gate.actionLabel && !gate.actionHref && (
98
+ <button type="button" style={primaryButton} onClick={() => void classroom.signOutAndRetry()}>
99
+ {gate.actionLabel}
100
+ </button>
101
+ )}
102
+ </div>
103
+ );
104
+ }
105
+
106
+ const data = classroom.data;
107
+ if (!data) return null;
108
+
109
+ const selected = classroom.selected;
27
110
  const videoUrl = selected?.media?.find((m) => m.type === "video")?.url;
28
111
  const files = selected?.media?.filter((m) => m.type !== "video") ?? [];
29
- const completedCount = allLessons.filter((l) => l.progress?.isCompleted).length;
30
-
31
- const handleTimeUpdate = (e: React.SyntheticEvent<HTMLVideoElement>) => {
32
- const el = e.currentTarget;
33
- if (!selected || !el.duration) return;
34
- const now = Date.now();
35
- if (now - lastSent.current < 1000) return;
36
- lastSent.current = now;
37
- const percent = (el.currentTime / el.duration) * 100;
38
- const isCompleted = percent >= 95;
39
- updateProgress.mutate({ lessonId: selected.id, videoProgress: percent, isCompleted });
40
- };
112
+ const securePrompt = classroom.securePrompt;
41
113
 
42
114
  return (
43
115
  <div
116
+ className={className}
44
117
  style={{
45
118
  display: "flex",
46
119
  flexDirection: "column",
47
120
  gap: 24,
48
121
  maxWidth: 1100,
49
122
  margin: "0 auto",
50
- color: theme.colors.text,
123
+ color: t.text,
124
+ fontFamily: t.fontFamily,
125
+ ...style,
51
126
  }}
52
127
  >
128
+ {/* Legacy grant, opened fine. An offer, not an obstacle — inline, muted,
129
+ dismissible, and never in front of the course the reader came for. */}
130
+ {securePrompt && (
131
+ <div
132
+ style={{
133
+ display: "flex",
134
+ flexWrap: "wrap",
135
+ alignItems: "center",
136
+ gap: 12,
137
+ padding: "10px 14px",
138
+ borderRadius: t.cornerRadius,
139
+ border: `1px solid ${t.border}`,
140
+ background: `${t.primary}0d`,
141
+ fontSize: 13,
142
+ }}
143
+ >
144
+ <span style={{ flex: "1 1 260px", color: t.muted, lineHeight: 1.5 }}>
145
+ Anyone with this link can open your course. Create an account with{" "}
146
+ <strong style={{ color: t.text }}>{securePrompt.email}</strong> to keep it to yourself.
147
+ </span>
148
+ <button
149
+ type="button"
150
+ onClick={() => go(securePrompt.href)}
151
+ style={{
152
+ padding: "6px 14px",
153
+ borderRadius: t.cornerRadius,
154
+ border: `1px solid ${t.primary}`,
155
+ background: "transparent",
156
+ color: t.primary,
157
+ fontSize: 13,
158
+ fontWeight: 600,
159
+ cursor: "pointer",
160
+ }}
161
+ >
162
+ Secure my access
163
+ </button>
164
+ <button
165
+ type="button"
166
+ onClick={classroom.dismissSecurePrompt}
167
+ aria-label="Dismiss"
168
+ style={{
169
+ padding: "6px 10px",
170
+ border: "none",
171
+ background: "transparent",
172
+ color: t.muted,
173
+ fontSize: 13,
174
+ cursor: "pointer",
175
+ }}
176
+ >
177
+ Not now
178
+ </button>
179
+ </div>
180
+ )}
181
+
53
182
  <header>
54
- <h1 style={{ fontSize: 26, fontWeight: 800 }}>{data.course.title}</h1>
55
- <p style={{ fontSize: 14, opacity: 0.7 }}>
56
- {completedCount} of {allLessons.length} lessons completed
183
+ <h1 style={{ fontSize: 26, fontWeight: 800, fontFamily: t.headingFontFamily || t.fontFamily }}>
184
+ {data.course.title}
185
+ </h1>
186
+ <p style={{ fontSize: 14, color: t.muted }}>
187
+ {classroom.completedCount} of {classroom.lessons.length} lessons completed
57
188
  </p>
58
189
  </header>
59
190
 
60
191
  <div style={{ display: "flex", gap: 24, flexWrap: "wrap" }}>
61
192
  {/* Sidebar */}
62
193
  <aside style={{ flex: "1 1 260px", minWidth: 240 }}>
63
- {modules.map((module) => (
194
+ {classroom.modules.map((module) => (
64
195
  <div key={module.id} style={{ marginBottom: 16 }}>
65
196
  <h2 style={{ fontSize: 14, fontWeight: 700, marginBottom: 6 }}>{module.title}</h2>
66
197
  <div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
67
- {module.lessons.map((lesson) => {
198
+ {(module.lessons ?? []).map((lesson) => {
68
199
  const isSelected = lesson.id === selected?.id;
69
200
  const done = lesson.progress?.isCompleted;
70
201
  return (
71
202
  <button
72
203
  key={lesson.id}
73
- onClick={() => setSelectedLessonId(lesson.id)}
204
+ onClick={() => classroom.selectLesson(lesson.id)}
74
205
  style={{
75
206
  textAlign: "left",
76
207
  padding: "8px 10px",
77
- borderRadius: theme.cornerRadius,
208
+ borderRadius: t.cornerRadius,
78
209
  border: "none",
79
210
  cursor: "pointer",
80
211
  fontSize: 14,
81
- background: isSelected ? `${theme.colors.primary}20` : "transparent",
82
- color: isSelected ? theme.colors.primary : theme.colors.text,
212
+ background: isSelected ? `${t.primary}20` : "transparent",
213
+ color: isSelected ? t.primary : t.text,
83
214
  fontWeight: isSelected ? 600 : 400,
84
215
  }}
85
216
  >
@@ -103,8 +234,8 @@ export function CourseAccess({ accessId }: CourseAccessProps) {
103
234
  src={videoUrl}
104
235
  controls
105
236
  playsInline
106
- onTimeUpdate={handleTimeUpdate}
107
- style={{ width: "100%", aspectRatio: "16/9", background: "#000", borderRadius: theme.cornerRadius }}
237
+ onTimeUpdate={(e) => classroom.reportProgress(e.currentTarget.currentTime, e.currentTarget.duration)}
238
+ style={{ width: "100%", aspectRatio: "16/9", background: "#000", borderRadius: t.cornerRadius }}
108
239
  />
109
240
  ) : (
110
241
  <div
@@ -114,16 +245,25 @@ export function CourseAccess({ accessId }: CourseAccessProps) {
114
245
  display: "flex",
115
246
  alignItems: "center",
116
247
  justifyContent: "center",
117
- background: `${theme.colors.text}0d`,
118
- borderRadius: theme.cornerRadius,
119
- opacity: 0.7,
248
+ background: `${t.text}0d`,
249
+ borderRadius: t.cornerRadius,
250
+ color: t.muted,
120
251
  }}
121
252
  >
122
253
  No video available for this lesson
123
254
  </div>
124
255
  )}
125
256
 
126
- <h2 style={{ fontSize: 20, fontWeight: 700, margin: "16px 0 8px" }}>{selected.title}</h2>
257
+ <h2
258
+ style={{
259
+ fontSize: 20,
260
+ fontWeight: 700,
261
+ margin: "16px 0 8px",
262
+ fontFamily: t.headingFontFamily || t.fontFamily,
263
+ }}
264
+ >
265
+ {selected.title}
266
+ </h2>
127
267
 
128
268
  {files.length > 0 && (
129
269
  <div style={{ marginBottom: 16 }}>
@@ -139,16 +279,16 @@ export function CourseAccess({ accessId }: CourseAccessProps) {
139
279
  display: "flex",
140
280
  justifyContent: "space-between",
141
281
  padding: "10px 12px",
142
- borderRadius: theme.cornerRadius,
143
- border: `1px solid ${theme.colors.primary}40`,
144
- background: `${theme.colors.primary}10`,
145
- color: theme.colors.text,
282
+ borderRadius: t.cornerRadius,
283
+ border: `1px solid ${t.border}`,
284
+ background: `${t.primary}10`,
285
+ color: t.text,
146
286
  textDecoration: "none",
147
287
  fontSize: 14,
148
288
  }}
149
289
  >
150
290
  <span>{file.filename}</span>
151
- <span style={{ opacity: 0.6 }}>{(file.size / 1024 / 1024).toFixed(2)} MB</span>
291
+ <span style={{ color: t.muted }}>{(file.size / 1024 / 1024).toFixed(2)} MB</span>
152
292
  </a>
153
293
  ))}
154
294
  </div>
@@ -160,7 +300,7 @@ export function CourseAccess({ accessId }: CourseAccessProps) {
160
300
  )}
161
301
  </>
162
302
  ) : (
163
- <p style={{ opacity: 0.7 }}>No lesson selected.</p>
303
+ <p style={{ color: t.muted }}>No lesson selected.</p>
164
304
  )}
165
305
  </main>
166
306
  </div>
@@ -28,7 +28,15 @@ export interface CourseCheckoutProps {
28
28
  formatAmount?: (amount: number) => string;
29
29
  /** Optional payment renderer — falls back to a registered <ForgePaymentProvider>. */
30
30
  renderPayment?: (props: PaymentRenderProps) => ReactNode;
31
- /** Stripe return URL for a PAID enrollment. Default `/i/courses/:slug/finalise`. */
31
+ /**
32
+ * Stripe return URL for a PAID enrollment. Default `/i/courses/:slug/finalise`.
33
+ *
34
+ * Put ONLY the bookingId in this URL. The booking's secret is handled for you
35
+ * by the hook (stashed in `sessionStorage`, read back by `CourseConfirmation`)
36
+ * — putting it in the URL would hand it to history, referrers and shared
37
+ * links, which is the exact leak it exists to close. See
38
+ * `utils/bookingSecret`.
39
+ */
32
40
  finalisePath?: (slug: string, bookingId: string) => string;
33
41
  /** Host-owned navigation on FREE completion (e.g. router navigate). */
34
42
  onComplete?: (info: { slug: string; bookingId: string }) => void;
@@ -3,9 +3,19 @@ import { useForgeTheme } from "../theme/ForgeThemeProvider";
3
3
  import { readableTextOn } from "../theme/contrast";
4
4
  import { useAmountFormatter } from "../format/useFormatCurrency";
5
5
  import { Loading } from "./Loading";
6
- import { ConfirmationStage, ConfirmationCard, CheckSeal, WarnSeal, Perforation, ConfirmationRow, alpha } from "./Confirmation";
6
+ import {
7
+ ConfirmationStage,
8
+ ConfirmationCard,
9
+ CheckSeal,
10
+ WarnSeal,
11
+ Perforation,
12
+ ConfirmationRow,
13
+ ConfirmationLostCheckout,
14
+ alpha,
15
+ } from "./Confirmation";
7
16
  import { useCourseBookingFinalize } from "../../data/queries/useFinalize";
8
17
  import { useGetCourse } from "../../data/queries/useCourses";
18
+ import { useBookingSecret } from "../headless/booking/useBookingSecret";
9
19
 
10
20
  export interface CourseConfirmationProps {
11
21
  slug: string;
@@ -14,6 +24,11 @@ export interface CourseConfirmationProps {
14
24
  formatAmount?: (n: number) => string;
15
25
  /** Where the "browse more courses" action links. */
16
26
  explorePath?: string;
27
+ /**
28
+ * Where "start over" goes when this browser has no credential for the booking
29
+ * — i.e. back to the course, to enroll again. Defaults to `/i/courses/:slug`.
30
+ */
31
+ restartPath?: string;
17
32
  }
18
33
 
19
34
  export function CourseConfirmation({
@@ -21,16 +36,25 @@ export function CourseConfirmation({
21
36
  bookingId,
22
37
  formatAmount,
23
38
  explorePath = "/i/courses",
39
+ restartPath,
24
40
  }: CourseConfirmationProps) {
25
41
  const theme = useForgeTheme();
26
42
  const fmt = useAmountFormatter(formatAmount);
27
- const { data, isLoading, error } = useCourseBookingFinalize(slug, bookingId);
43
+ // The credential the checkout stashed before the payment redirect. `resolved`
44
+ // gates the finalise: storage can only be read on the client, so firing before
45
+ // then would send the call without a secret it actually has and get a 404.
46
+ const { secret, resolved, isMissing } = useBookingSecret(bookingId);
47
+ const { data, isLoading, error } = useCourseBookingFinalize(
48
+ slug,
49
+ resolved ? bookingId : undefined,
50
+ secret ?? undefined,
51
+ );
28
52
  const { data: course } = useGetCourse(slug);
29
53
 
30
54
  const text = theme.colors.text;
31
55
  const primary = theme.colors.primary;
32
56
 
33
- if (isLoading) {
57
+ if (!resolved || isLoading) {
34
58
  return (
35
59
  <ConfirmationStage>
36
60
  <Loading label="Confirming your enrollment…" />
@@ -39,6 +63,21 @@ export function CourseConfirmation({
39
63
  }
40
64
 
41
65
  if (error || !data) {
66
+ // A booking with no credential in THIS browser is indistinguishable
67
+ // server-side from a stranger holding a leaked id, so it 404s on purpose.
68
+ // Explain that rather than implying the enrollment never existed.
69
+ if (isMissing) {
70
+ return (
71
+ <ConfirmationStage>
72
+ <ConfirmationLostCheckout
73
+ title="We can’t confirm your enrollment in this browser"
74
+ body="Confirming an enrollment needs the checkout details saved in the tab you started in — and they aren’t here. That usually means the tab was closed, this link was opened on a different device or browser, or private browsing cleared them. If your payment went through you’ll still get your enrollment email, so check your inbox before paying again. Otherwise, start the enrollment over."
75
+ restartPath={restartPath ?? `/i/courses/${slug}`}
76
+ restartLabel="Start over"
77
+ />
78
+ </ConfirmationStage>
79
+ );
80
+ }
42
81
  return (
43
82
  <ConfirmationStage>
44
83
  <p style={{ color: text }}>We couldn’t find your enrollment.</p>
@@ -5,6 +5,7 @@ import { useEventCheckout } from "../headless/event/useEventCheckout";
5
5
  import { useForgeTheme, type ForgeTheme } from "../theme/ForgeThemeProvider";
6
6
  import { useAmountFormatter } from "../format/useFormatCurrency";
7
7
  import { PriceDisplay, summarizeTaxQuote } from "../format/PriceDisplay";
8
+ import { clampChosenAmount, isPayWhatYouWant, pwywDefaultAmount, pwywMaximum } from "../format/pwyw";
8
9
  import { usePricesIncludeTax } from "../../data/queries/useWebsite";
9
10
  import { Loading } from "./Loading";
10
11
  import { usePaymentRenderer, type PaymentRenderProps } from "../payment/ForgePaymentProvider";
@@ -282,6 +283,12 @@ function TicketStep({
282
283
  {fmt(Number(ticket.compareAtPrice))}
283
284
  </span>
284
285
  )}
286
+ {/* On a PWYW tier `price` is the FLOOR, so it is labelled as
287
+ one — showing it bare would read as a fixed price the
288
+ buyer is about to be charged. */}
289
+ {isPayWhatYouWant(ticket) && (
290
+ <span style={{ fontSize: 14, fontWeight: 500, opacity: 0.7, marginRight: 6 }}>from</span>
291
+ )}
285
292
  <PriceDisplay amount={Number(ticket.price)} pricesIncludeTax={pricesIncludeTax} formatAmount={fmt} mutedColor={theme.colors.text} captionStyle={{ opacity: 0.65 }} />
286
293
  </div>
287
294
  </div>
@@ -305,6 +312,20 @@ function TicketStep({
305
312
  </button>
306
313
  </div>
307
314
  </div>
315
+ {/* The amount box appears only once a seat is actually selected —
316
+ before that there is nothing to price, and a row of empty
317
+ inputs down the tier list reads as a form to fill in rather
318
+ than a choice to make. */}
319
+ {isPayWhatYouWant(ticket) && selectedQty > 0 && !isSoldOut && !isExpired && (
320
+ <PwywAmountField
321
+ ticket={ticket}
322
+ quantity={selectedQty}
323
+ value={c.pwywAmounts[ticket.id]}
324
+ onChange={(amount) => c.setTicketAmount(ticket.id, amount)}
325
+ fmt={fmt}
326
+ theme={theme}
327
+ />
328
+ )}
308
329
  {ticket.description && (
309
330
  <p dangerouslySetInnerHTML={{ __html: ticket.description }} style={{ marginTop: 12, fontSize: 14 }} />
310
331
  )}
@@ -669,6 +690,99 @@ const closeButtonStyle = (t: ForgeTheme): CSSProperties => ({
669
690
  opacity: 0.7,
670
691
  });
671
692
 
693
+ /**
694
+ * The buyer's "what will you pay?" box for one pay-what-you-want tier.
695
+ *
696
+ * Holds its own STRING state rather than binding the number straight through.
697
+ * Two reasons, both of which are broken inputs if you skip them: an empty box
698
+ * has to stay empty while the buyer retypes (a number-bound input snaps it back
699
+ * to the floor on every keystroke), and "1" on the way to "15" must not be
700
+ * clamped up to the minimum the instant it is typed.
701
+ *
702
+ * Correction happens on BLUR, when the buyer has finished — and the server
703
+ * clamps again regardless, so nothing here is load-bearing for money.
704
+ */
705
+ function PwywAmountField({
706
+ ticket,
707
+ quantity,
708
+ value,
709
+ onChange,
710
+ fmt,
711
+ theme,
712
+ }: {
713
+ ticket: ITicket;
714
+ quantity: number;
715
+ value: number | undefined;
716
+ onChange: (amount: number | null) => void;
717
+ fmt: (amount: number) => string;
718
+ theme: ForgeTheme;
719
+ }) {
720
+ const floor = Number(ticket.price);
721
+ const maximum = pwywMaximum(ticket);
722
+ const suggested = pwywDefaultAmount(ticket);
723
+ const [draft, setDraft] = useState<string>(String(value ?? suggested));
724
+
725
+ // Follow the tier's own default when the buyer hasn't typed anything — an
726
+ // operator's suggested amount arriving late (or the selection being cleared
727
+ // and remade) should re-seed the box rather than leave a stale figure.
728
+ useEffect(() => {
729
+ if (value == null) setDraft(String(suggested));
730
+ }, [value, suggested]);
731
+
732
+ const parsed = Number(draft);
733
+ const effective = Number.isFinite(parsed) ? clampChosenAmount(ticket, parsed) : floor;
734
+ const belowFloor = draft.trim() !== "" && Number.isFinite(parsed) && parsed < floor;
735
+ const aboveMax = maximum != null && Number.isFinite(parsed) && parsed > maximum;
736
+
737
+ const commit = () => {
738
+ if (draft.trim() === "" || !Number.isFinite(parsed)) {
739
+ onChange(null);
740
+ setDraft(String(suggested));
741
+ return;
742
+ }
743
+ const clamped = clampChosenAmount(ticket, parsed);
744
+ onChange(clamped);
745
+ setDraft(String(clamped));
746
+ };
747
+
748
+ return (
749
+ <div style={{ marginTop: 12 }}>
750
+ <label
751
+ htmlFor={`pwyw-${ticket.id}`}
752
+ style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 6 }}
753
+ >
754
+ Name your price
755
+ <span style={{ fontWeight: 400, opacity: 0.7 }}>
756
+ {" "}
757
+ — minimum {fmt(floor)}
758
+ {maximum != null ? `, up to ${fmt(maximum)}` : ""}
759
+ </span>
760
+ </label>
761
+ <input
762
+ id={`pwyw-${ticket.id}`}
763
+ type="number"
764
+ inputMode="decimal"
765
+ min={floor}
766
+ {...(maximum != null ? { max: maximum } : {})}
767
+ step="0.01"
768
+ value={draft}
769
+ onChange={(e) => setDraft(e.target.value)}
770
+ onBlur={commit}
771
+ style={{ ...inputStyle(theme), maxWidth: 200 }}
772
+ />
773
+ <p style={{ fontSize: 12, opacity: 0.7, marginTop: 6 }}>
774
+ {belowFloor
775
+ ? `The minimum is ${fmt(floor)} — we'll use that.`
776
+ : aboveMax
777
+ ? `The most you can pay is ${fmt(maximum!)} — we'll use that.`
778
+ : quantity > 1
779
+ ? `${fmt(effective)} each · ${fmt(effective * quantity)} for ${quantity}`
780
+ : "Per ticket."}
781
+ </p>
782
+ </div>
783
+ );
784
+ }
785
+
672
786
  const stepperStyle = (t: ForgeTheme, filled: boolean): CSSProperties => ({
673
787
  width: 40,
674
788
  height: 40,