@tribe-nest/forge 3.2.0 → 3.9.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/data/queries/_tests/eventWaitlist.spec.ts +122 -0
- package/src/data/queries/_tests/passTransfers.spec.ts +89 -0
- package/src/data/queries/_tests/walletPass.spec.ts +159 -0
- package/src/data/queries/useCheckouts.ts +84 -1
- package/src/data/queries/useCoachingAvailability.ts +18 -3
- package/src/data/queries/useCourses.ts +42 -1
- package/src/data/queries/useEventWaitlist.ts +429 -0
- package/src/data/queries/useEvents.ts +15 -1
- package/src/data/queries/useMyBookings.ts +211 -0
- package/src/data/queries/useMyTickets.ts +154 -0
- package/src/data/queries/usePassTransfers.ts +318 -0
- package/src/data/queries/usePaymentFlow.ts +12 -0
- package/src/data/queries/useWalletPass.ts +236 -0
- package/src/data/queries/useWebsite.ts +6 -0
- package/src/index.ts +14 -0
- package/src/server/_tests/siteBootstrap.spec.ts +131 -0
- package/src/server/index.ts +122 -6
- package/src/types/diagnostics.ts +49 -0
- package/src/types/models.ts +66 -0
- package/src/ui/headless/calendar/useAddToCalendar.ts +194 -0
- package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +169 -0
- package/src/ui/headless/checkout/bundleCoupon.ts +96 -0
- package/src/ui/headless/checkout/useCheckout.ts +156 -8
- package/src/ui/headless/coaching/useCoachingBooking.ts +53 -3
- package/src/ui/headless/coupon/_tests/couponFailureMessage.spec.ts +84 -0
- package/src/ui/headless/coupon/useCouponField.ts +164 -0
- package/src/ui/headless/course/useCourseCheckout.ts +113 -18
- package/src/ui/headless/event/useEventCheckout.ts +53 -2
- package/src/ui/headless/index.ts +15 -0
- package/src/ui/index.ts +26 -0
- package/src/ui/shell/PoweredBy.tsx +62 -0
- package/src/ui/shell/PreviewDiagnostics.tsx +80 -0
- package/src/ui/shell/TribeNestApp.tsx +39 -1
- package/src/ui/shell/diagnosticsGating.spec.ts +102 -0
- package/src/ui/shell/diagnosticsGating.ts +90 -0
- package/src/ui/shell/shellGating.spec.ts +60 -1
- package/src/ui/shell/shellGating.ts +47 -0
- package/src/ui/styled/AccountDashboard.tsx +598 -1
- package/src/ui/styled/AddToCalendar.tsx +104 -0
- package/src/ui/styled/Checkout.tsx +45 -14
- package/src/ui/styled/CoachingBooking.tsx +28 -8
- package/src/ui/styled/CoachingConfirmation.tsx +12 -0
- package/src/ui/styled/CourseCheckout.tsx +49 -18
- package/src/ui/styled/DiscountCode.tsx +206 -0
- package/src/ui/styled/EventConfirmation.tsx +68 -22
- package/src/ui/styled/EventDetail.tsx +24 -5
- package/src/ui/styled/EventTickets.tsx +49 -5
- package/src/ui/styled/EventWaitlist.tsx +448 -0
- package/src/ui/styled/TicketTransfer.tsx +393 -0
- package/src/ui/styled/WalletPassButtons.tsx +208 -0
- package/src/ui/styled/_tests/DiscountCode.spec.tsx +272 -0
- package/src/ui/styled/_tests/EventConfirmation.spec.tsx +154 -0
- package/src/ui/styled/_tests/WalletPassButtons.spec.tsx +223 -0
- package/src/utils/_tests/ticketOrderOutcome.spec.ts +126 -0
- package/src/utils/ticketOrderOutcome.ts +125 -0
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
import { useEffect, useState, type CSSProperties } from "react";
|
|
2
|
+
import { BellRing, Clock, Users } from "lucide-react";
|
|
3
|
+
import type { IEvent, ITicket } from "../../types/models";
|
|
4
|
+
import { usePublicAuth } from "../../contexts/PublicAuthContext";
|
|
5
|
+
import { useEvent } from "../../data/queries/useEvents";
|
|
6
|
+
import {
|
|
7
|
+
isEventWaitlistOpen,
|
|
8
|
+
isTicketSoldOut,
|
|
9
|
+
useEventWaitlistPlaces,
|
|
10
|
+
useJoinEventWaitlist,
|
|
11
|
+
useLeaveEventWaitlist,
|
|
12
|
+
type WaitlistEntry,
|
|
13
|
+
} from "../../data/queries/useEventWaitlist";
|
|
14
|
+
import { useThemeTokens, type ResolvedThemeTokens } from "../theme/ForgeThemeProvider";
|
|
15
|
+
import { Loading } from "./Loading";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Events 2.6 — the buyer-facing waitlist, on a sold-out event.
|
|
19
|
+
*
|
|
20
|
+
* Two jobs, and they are deliberately independent:
|
|
21
|
+
*
|
|
22
|
+
* JOIN is offered only where the tier has nothing left to sell. A queue for
|
|
23
|
+
* something you could simply buy costs a sale — the joiner waits for a
|
|
24
|
+
* release that never happens, because nothing froze in the first place.
|
|
25
|
+
* PLACE is shown for every live entry, sold out or not. A seat freeing up is
|
|
26
|
+
* exactly what makes the tier buyable again, so the moment an offer
|
|
27
|
+
* matters is the moment the join form disappears.
|
|
28
|
+
*
|
|
29
|
+
* Nothing here re-derives eligibility, position, or the offer window. Those come
|
|
30
|
+
* off the server on every read; mirroring them would give two answers to one
|
|
31
|
+
* question and the wrong one is what the buyer would act on. Same reasoning as
|
|
32
|
+
* the ticket-cancellation policy in `AccountDashboard`.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
export interface EventWaitlistProps {
|
|
36
|
+
/**
|
|
37
|
+
* The event, already loaded. Preferred: the waitlist API addresses events by
|
|
38
|
+
* UUID only, so a slug alone costs an extra round trip.
|
|
39
|
+
*/
|
|
40
|
+
event?: IEvent;
|
|
41
|
+
/** Slug or id, when the host has not already loaded the event. */
|
|
42
|
+
slug?: string;
|
|
43
|
+
/** Section heading. */
|
|
44
|
+
title?: string;
|
|
45
|
+
className?: string;
|
|
46
|
+
style?: CSSProperties;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const alpha = (hex: string, a: number) => hex + Math.round(a * 255).toString(16).padStart(2, "0");
|
|
50
|
+
|
|
51
|
+
export function EventWaitlist({ event: initialEvent, slug, title = "Waitlist", className, style }: EventWaitlistProps) {
|
|
52
|
+
const t = useThemeTokens();
|
|
53
|
+
const { user } = usePublicAuth();
|
|
54
|
+
const { data: fetched } = useEvent(initialEvent ? undefined : slug);
|
|
55
|
+
const event = initialEvent ?? fetched;
|
|
56
|
+
|
|
57
|
+
const { liveEntries: live, isLoading } = useEventWaitlistPlaces(event?.id, user?.id);
|
|
58
|
+
|
|
59
|
+
if (!event) return null;
|
|
60
|
+
|
|
61
|
+
const soldOutTiers = isEventWaitlistOpen(event)
|
|
62
|
+
? event.tickets.filter((ticket) => isTicketSoldOut(ticket) && !live.some((e) => e.eventTicketId === ticket.id))
|
|
63
|
+
: [];
|
|
64
|
+
|
|
65
|
+
// Nothing to queue for and nothing queued — say nothing at all.
|
|
66
|
+
if (soldOutTiers.length === 0 && live.length === 0) return null;
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<section
|
|
70
|
+
className={className}
|
|
71
|
+
style={{
|
|
72
|
+
border: `1px solid ${alpha(t.primary, 0.25)}`,
|
|
73
|
+
borderRadius: t.cornerRadius,
|
|
74
|
+
background: t.surface,
|
|
75
|
+
color: t.text,
|
|
76
|
+
padding: 20,
|
|
77
|
+
...style,
|
|
78
|
+
}}
|
|
79
|
+
>
|
|
80
|
+
<h2
|
|
81
|
+
style={{
|
|
82
|
+
display: "flex",
|
|
83
|
+
alignItems: "center",
|
|
84
|
+
gap: 8,
|
|
85
|
+
fontSize: 18,
|
|
86
|
+
fontWeight: 700,
|
|
87
|
+
margin: "0 0 4px",
|
|
88
|
+
fontFamily: t.headingFontFamily,
|
|
89
|
+
}}
|
|
90
|
+
>
|
|
91
|
+
<Users size={18} style={{ color: t.primary }} />
|
|
92
|
+
{title}
|
|
93
|
+
</h2>
|
|
94
|
+
<p style={{ fontSize: 14, opacity: 0.75, margin: "0 0 16px" }}>
|
|
95
|
+
Sold out. Join the queue and we'll email you if a ticket is released.
|
|
96
|
+
</p>
|
|
97
|
+
|
|
98
|
+
{isLoading && live.length === 0 && soldOutTiers.length === 0 ? (
|
|
99
|
+
<Loading />
|
|
100
|
+
) : (
|
|
101
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
|
102
|
+
{live.map((entry) => (
|
|
103
|
+
<WaitlistPlaceCard
|
|
104
|
+
key={entry.id}
|
|
105
|
+
entry={entry}
|
|
106
|
+
eventId={event.id}
|
|
107
|
+
ticket={event.tickets.find((ticket) => ticket.id === entry.eventTicketId)}
|
|
108
|
+
t={t}
|
|
109
|
+
/>
|
|
110
|
+
))}
|
|
111
|
+
{soldOutTiers.map((ticket) => (
|
|
112
|
+
<JoinCard key={ticket.id} eventId={event.id} ticket={ticket} t={t} signedIn={!!user} />
|
|
113
|
+
))}
|
|
114
|
+
</div>
|
|
115
|
+
)}
|
|
116
|
+
</section>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── A place already held ─────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* One live entry: its place, or the offer that replaced it.
|
|
124
|
+
*
|
|
125
|
+
* `position` is `null` for a notified entry by design — an offer is not a queue
|
|
126
|
+
* place — so the two states are rendered as different things rather than as a
|
|
127
|
+
* position that happens to be missing.
|
|
128
|
+
*/
|
|
129
|
+
function WaitlistPlaceCard({
|
|
130
|
+
entry,
|
|
131
|
+
eventId,
|
|
132
|
+
ticket,
|
|
133
|
+
t,
|
|
134
|
+
}: {
|
|
135
|
+
entry: WaitlistEntry;
|
|
136
|
+
eventId: string;
|
|
137
|
+
ticket?: ITicket;
|
|
138
|
+
t: ResolvedThemeTokens;
|
|
139
|
+
}) {
|
|
140
|
+
const leave = useLeaveEventWaitlist(eventId);
|
|
141
|
+
const [error, setError] = useState<string | null>(null);
|
|
142
|
+
const offerOpen = entry.offer.offerOpen;
|
|
143
|
+
|
|
144
|
+
const onLeave = async () => {
|
|
145
|
+
setError(null);
|
|
146
|
+
try {
|
|
147
|
+
await leave.mutateAsync({ entryId: entry.id });
|
|
148
|
+
} catch (err) {
|
|
149
|
+
setError(readError(err, "We could not remove you from the waitlist. Please try again."));
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<div
|
|
155
|
+
style={{
|
|
156
|
+
border: `1px solid ${alpha(offerOpen ? t.primary : t.primary, offerOpen ? 0.6 : 0.2)}`,
|
|
157
|
+
borderRadius: t.cornerRadius,
|
|
158
|
+
padding: 16,
|
|
159
|
+
}}
|
|
160
|
+
>
|
|
161
|
+
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
|
|
162
|
+
<div>
|
|
163
|
+
<div style={{ fontWeight: 600 }}>{ticket?.title ?? "Ticket"}</div>
|
|
164
|
+
<div style={{ fontSize: 13, opacity: 0.7 }}>
|
|
165
|
+
{entry.quantity} {entry.quantity === 1 ? "ticket" : "tickets"} requested
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
{offerOpen ? (
|
|
169
|
+
<span style={{ display: "flex", alignItems: "center", gap: 6, fontWeight: 700, color: t.primary }}>
|
|
170
|
+
<BellRing size={16} />
|
|
171
|
+
Tickets available
|
|
172
|
+
</span>
|
|
173
|
+
) : entry.position != null ? (
|
|
174
|
+
<span style={{ fontSize: 14, opacity: 0.85 }}>
|
|
175
|
+
Position <strong style={{ color: t.primary }}>#{entry.position}</strong>
|
|
176
|
+
</span>
|
|
177
|
+
) : (
|
|
178
|
+
<span style={{ fontSize: 14, opacity: 0.85 }}>You're on the list</span>
|
|
179
|
+
)}
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
{offerOpen && <OfferDeadline entry={entry} t={t} />}
|
|
183
|
+
|
|
184
|
+
{error && <p style={{ color: "#ef4444", fontSize: 13, marginTop: 12 }}>{error}</p>}
|
|
185
|
+
|
|
186
|
+
<button
|
|
187
|
+
type="button"
|
|
188
|
+
onClick={() => void onLeave()}
|
|
189
|
+
disabled={leave.isPending}
|
|
190
|
+
style={{
|
|
191
|
+
marginTop: 12,
|
|
192
|
+
padding: "8px 14px",
|
|
193
|
+
borderRadius: t.cornerRadius,
|
|
194
|
+
border: `1px solid ${alpha(t.primary, 0.4)}`,
|
|
195
|
+
background: "transparent",
|
|
196
|
+
color: t.text,
|
|
197
|
+
cursor: leave.isPending ? "wait" : "pointer",
|
|
198
|
+
fontWeight: 600,
|
|
199
|
+
}}
|
|
200
|
+
>
|
|
201
|
+
{leave.isPending ? "Leaving…" : "Leave waitlist"}
|
|
202
|
+
</button>
|
|
203
|
+
</div>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The deadline on an open offer — the whole point of being notified.
|
|
209
|
+
*
|
|
210
|
+
* The absolute instant is the authoritative statement and is always shown; the
|
|
211
|
+
* countdown beside it starts from the server's own `secondsRemaining` and ticks
|
|
212
|
+
* locally, so a slow clock can shorten what the buyer sees but never lengthen it.
|
|
213
|
+
*/
|
|
214
|
+
function OfferDeadline({ entry, t }: { entry: WaitlistEntry; t: ResolvedThemeTokens }) {
|
|
215
|
+
const [remaining, setRemaining] = useState(entry.offer.secondsRemaining ?? 0);
|
|
216
|
+
|
|
217
|
+
useEffect(() => {
|
|
218
|
+
setRemaining(entry.offer.secondsRemaining ?? 0);
|
|
219
|
+
}, [entry.offer.secondsRemaining]);
|
|
220
|
+
|
|
221
|
+
// One interval for the whole open window — restarted only when the offer
|
|
222
|
+
// crosses between "time left" and "no time left", not on every tick.
|
|
223
|
+
const ticking = remaining > 0;
|
|
224
|
+
useEffect(() => {
|
|
225
|
+
if (!ticking) return;
|
|
226
|
+
const id = setInterval(() => setRemaining((value) => Math.max(0, value - 1)), 1000);
|
|
227
|
+
return () => clearInterval(id);
|
|
228
|
+
}, [ticking]);
|
|
229
|
+
|
|
230
|
+
const deadline = entry.offer.claimExpiresAt
|
|
231
|
+
? new Date(entry.offer.claimExpiresAt).toLocaleString(undefined, {
|
|
232
|
+
weekday: "short",
|
|
233
|
+
day: "numeric",
|
|
234
|
+
month: "short",
|
|
235
|
+
hour: "numeric",
|
|
236
|
+
minute: "2-digit",
|
|
237
|
+
})
|
|
238
|
+
: null;
|
|
239
|
+
|
|
240
|
+
return (
|
|
241
|
+
<div
|
|
242
|
+
style={{
|
|
243
|
+
marginTop: 12,
|
|
244
|
+
padding: "10px 12px",
|
|
245
|
+
borderRadius: t.cornerRadius,
|
|
246
|
+
background: alpha(t.primary, 0.08),
|
|
247
|
+
border: `1px solid ${alpha(t.primary, 0.3)}`,
|
|
248
|
+
fontSize: 14,
|
|
249
|
+
}}
|
|
250
|
+
>
|
|
251
|
+
<div style={{ display: "flex", alignItems: "center", gap: 8, fontWeight: 600 }}>
|
|
252
|
+
<Clock size={14} style={{ color: t.primary }} />
|
|
253
|
+
{remaining > 0 ? `Buy within ${formatCountdown(remaining)}` : "This offer has closed"}
|
|
254
|
+
</div>
|
|
255
|
+
{deadline && (
|
|
256
|
+
<div style={{ opacity: 0.8, marginTop: 4 }}>
|
|
257
|
+
{remaining > 0 ? `Your tickets are held until ${deadline}.` : `Your offer expired at ${deadline}.`} Tickets are
|
|
258
|
+
not reserved — buy from the ticket options above to secure them.
|
|
259
|
+
</div>
|
|
260
|
+
)}
|
|
261
|
+
</div>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ── Joining ──────────────────────────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The join form for one sold-out tier.
|
|
269
|
+
*
|
|
270
|
+
* The email field appears only for an anonymous visitor: the server takes a
|
|
271
|
+
* signed-in caller's address from their account and ignores any sent, so asking
|
|
272
|
+
* for one would invite them to type an address that cannot take effect.
|
|
273
|
+
*
|
|
274
|
+
* A re-submission is NOT an error — the server returns the existing place with
|
|
275
|
+
* its original quantity, which is why the success copy says "you're on the list"
|
|
276
|
+
* rather than "joined", and why the requested quantity is read back from the
|
|
277
|
+
* response instead of from the form.
|
|
278
|
+
*/
|
|
279
|
+
function JoinCard({
|
|
280
|
+
eventId,
|
|
281
|
+
ticket,
|
|
282
|
+
t,
|
|
283
|
+
signedIn,
|
|
284
|
+
}: {
|
|
285
|
+
eventId: string;
|
|
286
|
+
ticket: ITicket;
|
|
287
|
+
t: ResolvedThemeTokens;
|
|
288
|
+
signedIn: boolean;
|
|
289
|
+
}) {
|
|
290
|
+
const join = useJoinEventWaitlist(eventId);
|
|
291
|
+
const [open, setOpen] = useState(false);
|
|
292
|
+
const [email, setEmail] = useState("");
|
|
293
|
+
const [firstName, setFirstName] = useState("");
|
|
294
|
+
const [lastName, setLastName] = useState("");
|
|
295
|
+
const [quantity, setQuantity] = useState(1);
|
|
296
|
+
const [error, setError] = useState<string | null>(null);
|
|
297
|
+
|
|
298
|
+
const onSubmit = async (e: React.FormEvent) => {
|
|
299
|
+
e.preventDefault();
|
|
300
|
+
setError(null);
|
|
301
|
+
if (!signedIn && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
302
|
+
return setError("Please enter a valid email address.");
|
|
303
|
+
}
|
|
304
|
+
try {
|
|
305
|
+
await join.mutateAsync({
|
|
306
|
+
eventTicketId: ticket.id,
|
|
307
|
+
email: signedIn ? undefined : email,
|
|
308
|
+
firstName: firstName || undefined,
|
|
309
|
+
lastName: lastName || undefined,
|
|
310
|
+
quantity,
|
|
311
|
+
});
|
|
312
|
+
} catch (err) {
|
|
313
|
+
setError(readError(err, "We could not add you to the waitlist. Please try again."));
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const input: CSSProperties = {
|
|
318
|
+
width: "100%",
|
|
319
|
+
padding: 10,
|
|
320
|
+
borderRadius: t.cornerRadius,
|
|
321
|
+
border: `1px solid ${alpha(t.primary, 0.3)}`,
|
|
322
|
+
background: "transparent",
|
|
323
|
+
color: t.text,
|
|
324
|
+
fontFamily: t.fontFamily,
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
return (
|
|
328
|
+
<div style={{ border: `1px solid ${alpha(t.primary, 0.2)}`, borderRadius: t.cornerRadius, padding: 16 }}>
|
|
329
|
+
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
|
|
330
|
+
<div>
|
|
331
|
+
<div style={{ fontWeight: 600 }}>{ticket.title}</div>
|
|
332
|
+
<div style={{ fontSize: 13, opacity: 0.7 }}>Sold out</div>
|
|
333
|
+
</div>
|
|
334
|
+
{!open && (
|
|
335
|
+
<button
|
|
336
|
+
type="button"
|
|
337
|
+
onClick={() => setOpen(true)}
|
|
338
|
+
style={{
|
|
339
|
+
padding: "8px 14px",
|
|
340
|
+
borderRadius: t.cornerRadius,
|
|
341
|
+
border: "none",
|
|
342
|
+
background: t.primary,
|
|
343
|
+
color: t.textPrimary,
|
|
344
|
+
fontWeight: 600,
|
|
345
|
+
cursor: "pointer",
|
|
346
|
+
}}
|
|
347
|
+
>
|
|
348
|
+
Join waitlist
|
|
349
|
+
</button>
|
|
350
|
+
)}
|
|
351
|
+
</div>
|
|
352
|
+
|
|
353
|
+
{open && (
|
|
354
|
+
<form onSubmit={onSubmit} style={{ marginTop: 12, display: "flex", flexDirection: "column", gap: 10 }}>
|
|
355
|
+
{!signedIn && (
|
|
356
|
+
<input
|
|
357
|
+
type="email"
|
|
358
|
+
placeholder="Email"
|
|
359
|
+
value={email}
|
|
360
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
361
|
+
style={input}
|
|
362
|
+
aria-label="Email"
|
|
363
|
+
/>
|
|
364
|
+
)}
|
|
365
|
+
<div style={{ display: "flex", gap: 8 }}>
|
|
366
|
+
<input
|
|
367
|
+
placeholder="First name (optional)"
|
|
368
|
+
value={firstName}
|
|
369
|
+
onChange={(e) => setFirstName(e.target.value)}
|
|
370
|
+
style={input}
|
|
371
|
+
aria-label="First name"
|
|
372
|
+
/>
|
|
373
|
+
<input
|
|
374
|
+
placeholder="Last name (optional)"
|
|
375
|
+
value={lastName}
|
|
376
|
+
onChange={(e) => setLastName(e.target.value)}
|
|
377
|
+
style={input}
|
|
378
|
+
aria-label="Last name"
|
|
379
|
+
/>
|
|
380
|
+
</div>
|
|
381
|
+
<label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 14 }}>
|
|
382
|
+
How many tickets?
|
|
383
|
+
<input
|
|
384
|
+
type="number"
|
|
385
|
+
min={1}
|
|
386
|
+
max={20}
|
|
387
|
+
value={quantity}
|
|
388
|
+
onChange={(e) => setQuantity(Math.max(1, Math.min(20, Number(e.target.value) || 1)))}
|
|
389
|
+
style={{ ...input, width: 80 }}
|
|
390
|
+
/>
|
|
391
|
+
</label>
|
|
392
|
+
{error && <p style={{ color: "#ef4444", fontSize: 13, margin: 0 }}>{error}</p>}
|
|
393
|
+
<div style={{ display: "flex", gap: 8 }}>
|
|
394
|
+
<button
|
|
395
|
+
type="submit"
|
|
396
|
+
disabled={join.isPending}
|
|
397
|
+
style={{
|
|
398
|
+
padding: "8px 14px",
|
|
399
|
+
borderRadius: t.cornerRadius,
|
|
400
|
+
border: "none",
|
|
401
|
+
background: t.primary,
|
|
402
|
+
color: t.textPrimary,
|
|
403
|
+
fontWeight: 600,
|
|
404
|
+
cursor: join.isPending ? "wait" : "pointer",
|
|
405
|
+
}}
|
|
406
|
+
>
|
|
407
|
+
{join.isPending ? "Joining…" : "Join waitlist"}
|
|
408
|
+
</button>
|
|
409
|
+
<button
|
|
410
|
+
type="button"
|
|
411
|
+
onClick={() => setOpen(false)}
|
|
412
|
+
style={{
|
|
413
|
+
padding: "8px 14px",
|
|
414
|
+
borderRadius: t.cornerRadius,
|
|
415
|
+
border: `1px solid ${alpha(t.primary, 0.3)}`,
|
|
416
|
+
background: "transparent",
|
|
417
|
+
color: t.text,
|
|
418
|
+
cursor: "pointer",
|
|
419
|
+
}}
|
|
420
|
+
>
|
|
421
|
+
Cancel
|
|
422
|
+
</button>
|
|
423
|
+
</div>
|
|
424
|
+
<p style={{ fontSize: 12, opacity: 0.65, margin: 0 }}>
|
|
425
|
+
We'll only email you about tickets for this event.
|
|
426
|
+
</p>
|
|
427
|
+
</form>
|
|
428
|
+
)}
|
|
429
|
+
</div>
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ── Shared ───────────────────────────────────────────────────────────────────
|
|
434
|
+
|
|
435
|
+
/** Whole hours and minutes while there is time; seconds once it is close. */
|
|
436
|
+
export function formatCountdown(totalSeconds: number): string {
|
|
437
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
438
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
439
|
+
const seconds = totalSeconds % 60;
|
|
440
|
+
if (hours > 0) return `${hours}h ${minutes}m`;
|
|
441
|
+
if (minutes > 0) return `${minutes}m ${seconds}s`;
|
|
442
|
+
return `${seconds}s`;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** The server's own refusal, which is the only correct explanation to show. */
|
|
446
|
+
function readError(err: unknown, fallback: string): string {
|
|
447
|
+
return (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? fallback;
|
|
448
|
+
}
|