@tribe-nest/forge 3.4.0 → 3.11.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/useCoachingAvailability.ts +14 -3
- package/src/data/queries/useEventSeries.ts +42 -0
- package/src/data/queries/useEventWaitlist.ts +429 -0
- package/src/data/queries/useEvents.ts +10 -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/useSubscriptions.ts +53 -0
- package/src/data/queries/useWalletPass.ts +236 -0
- package/src/index.ts +9 -0
- package/src/server/_tests/siteBootstrap.spec.ts +131 -0
- package/src/server/index.ts +142 -6
- package/src/types/diagnostics.ts +49 -0
- package/src/types/models.ts +146 -0
- package/src/ui/format/bookingFee.ts +98 -0
- package/src/ui/headless/coaching/useCoachingBooking.ts +19 -0
- package/src/ui/headless/event/useEventCheckout.ts +61 -2
- package/src/ui/headless/membership/MembershipGate.tsx +7 -2
- package/src/ui/index.ts +29 -0
- package/src/ui/shell/PoweredBy.tsx +6 -4
- package/src/ui/shell/PreviewDiagnostics.tsx +80 -0
- package/src/ui/shell/TribeNestApp.tsx +24 -0
- package/src/ui/shell/diagnosticsGating.spec.ts +102 -0
- package/src/ui/shell/diagnosticsGating.ts +90 -0
- package/src/ui/shell/shellGating.spec.ts +40 -1
- package/src/ui/shell/shellGating.ts +33 -0
- package/src/ui/styled/AccountDashboard.tsx +643 -5
- package/src/ui/styled/CancellationTerms.tsx +70 -0
- package/src/ui/styled/CoachingBooking.tsx +10 -0
- package/src/ui/styled/CoachingDetail.tsx +4 -0
- package/src/ui/styled/EventDetail.tsx +18 -0
- package/src/ui/styled/EventSeriesDetail.tsx +222 -0
- package/src/ui/styled/EventTickets.tsx +58 -0
- 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/WalletPassButtons.spec.tsx +223 -0
- package/src/utils/membershipAccess.ts +182 -0
|
@@ -2,10 +2,24 @@ import { useEffect, useState } from "react";
|
|
|
2
2
|
import {
|
|
3
3
|
usePublicAuth,
|
|
4
4
|
useUserOrders,
|
|
5
|
+
useMyTickets,
|
|
6
|
+
useCancelMyTicket,
|
|
7
|
+
myTicketPassIds,
|
|
8
|
+
type MyTicket,
|
|
9
|
+
useMyWaitlistPlaces,
|
|
10
|
+
useLeaveEventWaitlist,
|
|
11
|
+
type WaitlistPlace,
|
|
12
|
+
useMyBookings,
|
|
13
|
+
useRescheduleBooking,
|
|
14
|
+
useCancelMyBooking,
|
|
15
|
+
useCoachingAvailability,
|
|
16
|
+
type MyBooking,
|
|
5
17
|
useSavedPosts,
|
|
6
18
|
useNotificationPreferences,
|
|
7
19
|
useUpdateNotificationPreference,
|
|
8
20
|
useCancelMembership,
|
|
21
|
+
useMembershipAccess,
|
|
22
|
+
useOpenBillingPortal,
|
|
9
23
|
useUpdateAccount,
|
|
10
24
|
useChangePassword,
|
|
11
25
|
useExportAccountData,
|
|
@@ -19,8 +33,25 @@ import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
|
19
33
|
import { useSiteConfig } from "../../data/queries/useWebsite";
|
|
20
34
|
import { useAmountFormatter } from "../format/useFormatCurrency";
|
|
21
35
|
import { Loading } from "./Loading";
|
|
36
|
+
import { formatCountdown } from "./EventWaitlist";
|
|
37
|
+
import { WalletPassButtons } from "./WalletPassButtons";
|
|
38
|
+
import { TicketTransferPanel } from "./TicketTransfer";
|
|
22
39
|
|
|
23
|
-
export const ACCOUNT_TABS = [
|
|
40
|
+
export const ACCOUNT_TABS = [
|
|
41
|
+
"membership",
|
|
42
|
+
"orders",
|
|
43
|
+
"tickets",
|
|
44
|
+
// Next to tickets because it is the same question one step earlier: what am I
|
|
45
|
+
// holding for this artist's shows? (2.6)
|
|
46
|
+
"waitlist",
|
|
47
|
+
// Sessions bought from this artist (S.6 / bookings § 1.4). A booking is not an
|
|
48
|
+
// "order" — the Orders tab covers product orders only — so it needs its own
|
|
49
|
+
// tab or it is unreachable, which is what it was.
|
|
50
|
+
"bookings",
|
|
51
|
+
"saved",
|
|
52
|
+
"account",
|
|
53
|
+
"notifications",
|
|
54
|
+
] as const;
|
|
24
55
|
export type AccountTabKey = (typeof ACCOUNT_TABS)[number];
|
|
25
56
|
|
|
26
57
|
export interface AccountDashboardProps {
|
|
@@ -175,6 +206,9 @@ export function AccountDashboard({
|
|
|
175
206
|
|
|
176
207
|
{tab === "membership" && <MembershipTab ctx={ctx} />}
|
|
177
208
|
{tab === "orders" && <OrdersTab ctx={ctx} />}
|
|
209
|
+
{tab === "tickets" && <TicketsTab ctx={ctx} />}
|
|
210
|
+
{tab === "waitlist" && <WaitlistTab ctx={ctx} />}
|
|
211
|
+
{tab === "bookings" && <BookingsTab ctx={ctx} />}
|
|
178
212
|
{tab === "saved" && <SavedTab />}
|
|
179
213
|
{tab === "account" && <AccountTab ctx={ctx} />}
|
|
180
214
|
{tab === "notifications" && <NotificationsTab />}
|
|
@@ -192,7 +226,14 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
|
|
|
192
226
|
const [isCancelling, setIsCancelling] = useState(false);
|
|
193
227
|
|
|
194
228
|
const membership = user?.membership;
|
|
195
|
-
|
|
229
|
+
// Two separate questions, deliberately answered separately:
|
|
230
|
+
// `access.hasAccess` → are the benefits live (true through past_due/grace)
|
|
231
|
+
// `message` → what the member is told, including a failed payment
|
|
232
|
+
// The old `status === "active"` answered both at once and got both wrong for
|
|
233
|
+
// anyone whose card bounced.
|
|
234
|
+
const { access, message } = useMembershipAccess();
|
|
235
|
+
const openBillingPortal = useOpenBillingPortal();
|
|
236
|
+
const [billingError, setBillingError] = useState<string | null>(null);
|
|
196
237
|
|
|
197
238
|
const onCancel = async () => {
|
|
198
239
|
if (!membership?.id) return;
|
|
@@ -205,6 +246,17 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
|
|
|
205
246
|
}
|
|
206
247
|
};
|
|
207
248
|
|
|
249
|
+
const onUpdatePayment = async () => {
|
|
250
|
+
setBillingError(null);
|
|
251
|
+
try {
|
|
252
|
+
const { url } = await openBillingPortal.mutateAsync({});
|
|
253
|
+
window.location.href = url;
|
|
254
|
+
} catch (error) {
|
|
255
|
+
const detail = (error as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
|
256
|
+
setBillingError(detail || "We couldn't open the billing page. Please contact us.");
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
|
|
208
260
|
return (
|
|
209
261
|
<div style={card}>
|
|
210
262
|
<h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>Current Membership</h2>
|
|
@@ -212,7 +264,19 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
|
|
|
212
264
|
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
|
213
265
|
<div>
|
|
214
266
|
<h3 style={{ fontWeight: 700 }}>{membership.membershipTier.name}</h3>
|
|
215
|
-
<p
|
|
267
|
+
<p
|
|
268
|
+
style={{
|
|
269
|
+
fontSize: 13,
|
|
270
|
+
opacity: 0.85,
|
|
271
|
+
fontWeight: message.tone === "warning" ? 700 : 400,
|
|
272
|
+
color: message.tone === "warning" ? t.primary : undefined,
|
|
273
|
+
}}
|
|
274
|
+
>
|
|
275
|
+
{message.label}
|
|
276
|
+
</p>
|
|
277
|
+
{message.detail && (
|
|
278
|
+
<p style={{ fontSize: 13, opacity: 0.8, marginTop: 4, maxWidth: 480 }}>{message.detail}</p>
|
|
279
|
+
)}
|
|
216
280
|
{!!membership.subscriptionAmount && (
|
|
217
281
|
<p style={{ fontSize: 14, marginTop: 4 }}>
|
|
218
282
|
{formatAmount(membership.subscriptionAmount, membership.subscriptionCurrency || currency)} / {membership.billingCycle}
|
|
@@ -231,16 +295,25 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
|
|
|
231
295
|
)}
|
|
232
296
|
{membership.endDate && (
|
|
233
297
|
<p style={{ fontSize: 14, opacity: 0.8 }}>
|
|
234
|
-
{
|
|
298
|
+
{access.billingState === "active" ? "Renews" : "Ends"} on {formatDate(membership.endDate)}
|
|
235
299
|
</p>
|
|
236
300
|
)}
|
|
301
|
+
{billingError && <p style={{ fontSize: 13, opacity: 0.9 }}>{billingError}</p>}
|
|
237
302
|
</div>
|
|
238
303
|
) : (
|
|
239
304
|
<p style={{ opacity: 0.8 }}>You don't have an active membership.</p>
|
|
240
305
|
)}
|
|
241
306
|
|
|
242
307
|
<div style={{ display: "flex", gap: 12, justifyContent: "flex-end", marginTop: 24 }}>
|
|
243
|
-
{
|
|
308
|
+
{message.showUpdatePayment && (
|
|
309
|
+
<button onClick={onUpdatePayment} disabled={openBillingPortal.isPending} style={button}>
|
|
310
|
+
{openBillingPortal.isPending ? "Opening…" : "Update payment method"}
|
|
311
|
+
</button>
|
|
312
|
+
)}
|
|
313
|
+
{/* Cancelling stays available for anyone who still HAS access — a
|
|
314
|
+
past_due member must not be trapped in a subscription they can see
|
|
315
|
+
but not leave. */}
|
|
316
|
+
{access.hasAccess && (
|
|
244
317
|
<button onClick={onCancel} disabled={isCancelling} style={ghostButton}>
|
|
245
318
|
{isCancelling ? "Cancelling…" : "Cancel"}
|
|
246
319
|
</button>
|
|
@@ -253,6 +326,571 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
|
|
|
253
326
|
);
|
|
254
327
|
}
|
|
255
328
|
|
|
329
|
+
// ---- Tickets tab ------------------------------------------------------------
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* The buyer's event tickets, and cancelling one inside the policy they were
|
|
333
|
+
* sold under (S.6).
|
|
334
|
+
*
|
|
335
|
+
* Before this, a ticket buyer had no purchase surface at all — the confirmation
|
|
336
|
+
* email and a one-shot finalise page were the only artefacts of the sale.
|
|
337
|
+
*
|
|
338
|
+
* Eligibility is NOT recomputed here. `cancellation.canCancel` and its reason
|
|
339
|
+
* come from the server, which reads the snapshot on the order; mirroring the
|
|
340
|
+
* date arithmetic in the UI would give two answers to the same question and the
|
|
341
|
+
* wrong one would be the one the buyer sees.
|
|
342
|
+
*/
|
|
343
|
+
function TicketsTab({ ctx }: { ctx: TabContext }) {
|
|
344
|
+
const { user } = usePublicAuth();
|
|
345
|
+
const { t, card } = useCardStyles();
|
|
346
|
+
const { formatAmount, formatDate, currency } = ctx;
|
|
347
|
+
const { data, isLoading } = useMyTickets(user?.id);
|
|
348
|
+
const cancelTicket = useCancelMyTicket();
|
|
349
|
+
const [pendingId, setPendingId] = useState<string | null>(null);
|
|
350
|
+
const [error, setError] = useState<string | null>(null);
|
|
351
|
+
|
|
352
|
+
const tickets = data?.data ?? [];
|
|
353
|
+
|
|
354
|
+
const onCancel = async (ticket: MyTicket) => {
|
|
355
|
+
setError(null);
|
|
356
|
+
setPendingId(ticket.id);
|
|
357
|
+
try {
|
|
358
|
+
await cancelTicket.mutateAsync({ orderId: ticket.id });
|
|
359
|
+
} catch (err) {
|
|
360
|
+
const message =
|
|
361
|
+
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
|
|
362
|
+
"We could not cancel this ticket. Please try again.";
|
|
363
|
+
setError(message);
|
|
364
|
+
} finally {
|
|
365
|
+
setPendingId(null);
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
return (
|
|
370
|
+
<div style={card}>
|
|
371
|
+
<h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>My Tickets</h2>
|
|
372
|
+
{error && (
|
|
373
|
+
<div style={{ marginBottom: 16, padding: 12, borderRadius: t.cornerRadius, border: `1px solid ${t.primary}40`, fontSize: 14 }}>
|
|
374
|
+
{error}
|
|
375
|
+
</div>
|
|
376
|
+
)}
|
|
377
|
+
{isLoading ? (
|
|
378
|
+
<Loading />
|
|
379
|
+
) : tickets.length > 0 ? (
|
|
380
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
|
381
|
+
{tickets.map((ticket) => {
|
|
382
|
+
const cancelled = !!ticket.selfCancelledAt;
|
|
383
|
+
return (
|
|
384
|
+
<div
|
|
385
|
+
key={ticket.id}
|
|
386
|
+
style={{ border: `1px solid ${t.primary}20`, borderRadius: t.cornerRadius, padding: 16, opacity: cancelled ? 0.6 : 1 }}
|
|
387
|
+
>
|
|
388
|
+
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 12, gap: 12 }}>
|
|
389
|
+
<div>
|
|
390
|
+
<div style={{ fontWeight: 600 }}>{ticket.eventTitle}</div>
|
|
391
|
+
<div style={{ fontSize: 13, opacity: 0.7 }}>{formatDate(ticket.eventDateTime)}</div>
|
|
392
|
+
</div>
|
|
393
|
+
{cancelled && <span style={{ fontSize: 13, color: t.primary }}>Cancelled</span>}
|
|
394
|
+
</div>
|
|
395
|
+
|
|
396
|
+
{ticket.items.map((item) => (
|
|
397
|
+
<div key={item.id} style={{ display: "flex", justifyContent: "space-between", fontSize: 14, padding: "4px 0" }}>
|
|
398
|
+
<span>
|
|
399
|
+
{item.ticketTitle ?? "Ticket"} × {item.quantity}
|
|
400
|
+
</span>
|
|
401
|
+
<span>{formatAmount(Number(item.price) * item.quantity, ticket.currency || currency)}</span>
|
|
402
|
+
</div>
|
|
403
|
+
))}
|
|
404
|
+
|
|
405
|
+
<div style={{ display: "flex", justifyContent: "space-between", fontWeight: 700, marginTop: 12, paddingTop: 12, borderTop: `1px solid ${t.primary}20` }}>
|
|
406
|
+
<span>Total</span>
|
|
407
|
+
<span style={{ color: t.primary }}>{formatAmount(Number(ticket.totalAmount), ticket.currency || currency)}</span>
|
|
408
|
+
</div>
|
|
409
|
+
|
|
410
|
+
{/*
|
|
411
|
+
Wallet passes (2.3). Renders NOTHING unless the API offers a
|
|
412
|
+
real pass for a real pass id — which, with no signing
|
|
413
|
+
credentials configured, is every ticket today. Deliberately
|
|
414
|
+
not wrapped in a condition of its own: the component owns that
|
|
415
|
+
decision, and a second copy of the rule here is how one of the
|
|
416
|
+
two ends up out of date and leaks the feature's existence.
|
|
417
|
+
|
|
418
|
+
Eligibility (cancelled order, rotating QR) is the server's
|
|
419
|
+
call, exactly as it is for cancellation above.
|
|
420
|
+
*/}
|
|
421
|
+
<WalletPassButtons passIds={myTicketPassIds(ticket)} variant="compact" style={{ marginTop: 12 }} />
|
|
422
|
+
|
|
423
|
+
{/*
|
|
424
|
+
Transfer (2.2). The email a transfer sends points at
|
|
425
|
+
`/i/tickets/claim`, so this affordance and that route ship
|
|
426
|
+
together — a send with nowhere to land is undeliverable in
|
|
427
|
+
practice.
|
|
428
|
+
|
|
429
|
+
Renders nothing when the payload carries no `TN-…` pass ids
|
|
430
|
+
(an older API build), because every pass-scoped endpoint would
|
|
431
|
+
404. Whether THIS pass may be handed on is the server's call,
|
|
432
|
+
answered on submit in its own words — not hidden behind a
|
|
433
|
+
guess here.
|
|
434
|
+
*/}
|
|
435
|
+
<TicketTransferPanel passIds={myTicketPassIds(ticket)} style={{ marginTop: 12 }} />
|
|
436
|
+
|
|
437
|
+
{/* The terms they agreed to, in the artist's own words where given. */}
|
|
438
|
+
{ticket.cancellation.description && (
|
|
439
|
+
<div style={{ fontSize: 13, opacity: 0.75, marginTop: 12 }}>{ticket.cancellation.description}</div>
|
|
440
|
+
)}
|
|
441
|
+
{ticket.cancellation.terms && (
|
|
442
|
+
<div style={{ fontSize: 13, opacity: 0.75, marginTop: 4 }}>{ticket.cancellation.terms}</div>
|
|
443
|
+
)}
|
|
444
|
+
|
|
445
|
+
{!cancelled && ticket.cancellation.canCancel && (
|
|
446
|
+
<button
|
|
447
|
+
onClick={() => void onCancel(ticket)}
|
|
448
|
+
disabled={pendingId === ticket.id}
|
|
449
|
+
style={{
|
|
450
|
+
marginTop: 12,
|
|
451
|
+
padding: "8px 14px",
|
|
452
|
+
borderRadius: t.cornerRadius,
|
|
453
|
+
border: `1px solid ${t.primary}40`,
|
|
454
|
+
background: "transparent",
|
|
455
|
+
color: t.text,
|
|
456
|
+
cursor: pendingId === ticket.id ? "wait" : "pointer",
|
|
457
|
+
fontWeight: 600,
|
|
458
|
+
}}
|
|
459
|
+
>
|
|
460
|
+
{pendingId === ticket.id ? "Cancelling…" : "Cancel ticket"}
|
|
461
|
+
</button>
|
|
462
|
+
)}
|
|
463
|
+
</div>
|
|
464
|
+
);
|
|
465
|
+
})}
|
|
466
|
+
</div>
|
|
467
|
+
) : (
|
|
468
|
+
<p style={{ fontSize: 14, opacity: 0.7 }}>You have no tickets yet.</p>
|
|
469
|
+
)}
|
|
470
|
+
</div>
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ---- Waitlist tab -----------------------------------------------------------
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Every live place the buyer holds on a sold-out tier (2.6).
|
|
478
|
+
*
|
|
479
|
+
* Two states, and they are genuinely different things:
|
|
480
|
+
*
|
|
481
|
+
* WAITING — a 1-based `position` in the queue.
|
|
482
|
+
* NOTIFIED — an OFFER with a deadline, and `position: null`. An offer is not a
|
|
483
|
+
* queue place, so it is never rendered as one; the deadline is the
|
|
484
|
+
* whole point and gets the emphasis.
|
|
485
|
+
*
|
|
486
|
+
* Nothing is recomputed here: position, offer state and the claim deadline all
|
|
487
|
+
* arrive from the server on each read, the same rule the tickets tab follows for
|
|
488
|
+
* cancellation eligibility.
|
|
489
|
+
*/
|
|
490
|
+
function WaitlistTab({ ctx }: { ctx: TabContext }) {
|
|
491
|
+
const { user } = usePublicAuth();
|
|
492
|
+
const { t, card } = useCardStyles();
|
|
493
|
+
const { formatDate } = ctx;
|
|
494
|
+
const { places, isLoading } = useMyWaitlistPlaces(user?.id);
|
|
495
|
+
|
|
496
|
+
return (
|
|
497
|
+
<div style={card}>
|
|
498
|
+
<h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>My Waitlists</h2>
|
|
499
|
+
{isLoading ? (
|
|
500
|
+
<Loading />
|
|
501
|
+
) : places.length > 0 ? (
|
|
502
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
|
503
|
+
{places.map((place) => (
|
|
504
|
+
<WaitlistPlaceRow key={place.id} place={place} formatDate={formatDate} />
|
|
505
|
+
))}
|
|
506
|
+
</div>
|
|
507
|
+
) : (
|
|
508
|
+
<p style={{ fontSize: 14, opacity: 0.7 }}>You're not on any waitlists.</p>
|
|
509
|
+
)}
|
|
510
|
+
</div>
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function WaitlistPlaceRow({
|
|
515
|
+
place,
|
|
516
|
+
formatDate,
|
|
517
|
+
}: {
|
|
518
|
+
place: WaitlistPlace;
|
|
519
|
+
formatDate: (value: string | number | Date) => string;
|
|
520
|
+
}) {
|
|
521
|
+
const { t } = useCardStyles();
|
|
522
|
+
const leave = useLeaveEventWaitlist(place.eventId);
|
|
523
|
+
const [error, setError] = useState<string | null>(null);
|
|
524
|
+
|
|
525
|
+
const onLeave = async () => {
|
|
526
|
+
setError(null);
|
|
527
|
+
try {
|
|
528
|
+
await leave.mutateAsync({ entryId: place.id });
|
|
529
|
+
} catch (err) {
|
|
530
|
+
const message =
|
|
531
|
+
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
|
|
532
|
+
"We could not remove you from this waitlist. Please try again.";
|
|
533
|
+
setError(message);
|
|
534
|
+
}
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
const deadline = place.offer.claimExpiresAt ? new Date(place.offer.claimExpiresAt) : null;
|
|
538
|
+
|
|
539
|
+
return (
|
|
540
|
+
<div
|
|
541
|
+
style={{
|
|
542
|
+
border: `1px solid ${t.primary}${place.offer.offerOpen ? "80" : "20"}`,
|
|
543
|
+
borderRadius: t.cornerRadius,
|
|
544
|
+
padding: 16,
|
|
545
|
+
}}
|
|
546
|
+
>
|
|
547
|
+
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
|
|
548
|
+
<div>
|
|
549
|
+
<div style={{ fontWeight: 600 }}>{place.event?.title ?? "Event"}</div>
|
|
550
|
+
<div style={{ fontSize: 13, opacity: 0.7 }}>
|
|
551
|
+
{place.ticket?.title ?? "Ticket"} · {place.quantity} {place.quantity === 1 ? "ticket" : "tickets"}
|
|
552
|
+
</div>
|
|
553
|
+
{place.event?.dateTime && (
|
|
554
|
+
<div style={{ fontSize: 13, opacity: 0.7 }}>{formatDate(place.event.dateTime)}</div>
|
|
555
|
+
)}
|
|
556
|
+
</div>
|
|
557
|
+
{place.offer.offerOpen ? (
|
|
558
|
+
<span style={{ fontWeight: 700, color: t.primary }}>Tickets available</span>
|
|
559
|
+
) : place.position != null ? (
|
|
560
|
+
<span style={{ fontSize: 14, opacity: 0.85 }}>
|
|
561
|
+
Position <strong style={{ color: t.primary }}>#{place.position}</strong>
|
|
562
|
+
</span>
|
|
563
|
+
) : (
|
|
564
|
+
<span style={{ fontSize: 14, opacity: 0.85 }}>You're on the list</span>
|
|
565
|
+
)}
|
|
566
|
+
</div>
|
|
567
|
+
|
|
568
|
+
{place.offer.offerOpen && deadline && (
|
|
569
|
+
<div
|
|
570
|
+
style={{
|
|
571
|
+
marginTop: 12,
|
|
572
|
+
padding: "10px 12px",
|
|
573
|
+
borderRadius: t.cornerRadius,
|
|
574
|
+
border: `1px solid ${t.primary}4d`,
|
|
575
|
+
fontSize: 14,
|
|
576
|
+
}}
|
|
577
|
+
>
|
|
578
|
+
<strong>Buy within {formatCountdown(place.offer.secondsRemaining ?? 0)}</strong> — your offer closes{" "}
|
|
579
|
+
{deadline.toLocaleString(undefined, {
|
|
580
|
+
weekday: "short",
|
|
581
|
+
day: "numeric",
|
|
582
|
+
month: "short",
|
|
583
|
+
hour: "numeric",
|
|
584
|
+
minute: "2-digit",
|
|
585
|
+
})}
|
|
586
|
+
. Tickets are not reserved until you buy them.
|
|
587
|
+
</div>
|
|
588
|
+
)}
|
|
589
|
+
|
|
590
|
+
{error && <p style={{ color: "#ef4444", fontSize: 13, marginTop: 12 }}>{error}</p>}
|
|
591
|
+
|
|
592
|
+
<button
|
|
593
|
+
onClick={() => void onLeave()}
|
|
594
|
+
disabled={leave.isPending}
|
|
595
|
+
style={{
|
|
596
|
+
marginTop: 12,
|
|
597
|
+
padding: "8px 14px",
|
|
598
|
+
borderRadius: t.cornerRadius,
|
|
599
|
+
border: `1px solid ${t.primary}40`,
|
|
600
|
+
background: "transparent",
|
|
601
|
+
color: t.text,
|
|
602
|
+
cursor: leave.isPending ? "wait" : "pointer",
|
|
603
|
+
fontWeight: 600,
|
|
604
|
+
}}
|
|
605
|
+
>
|
|
606
|
+
{leave.isPending ? "Leaving…" : "Leave waitlist"}
|
|
607
|
+
</button>
|
|
608
|
+
</div>
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// ---- Bookings tab -----------------------------------------------------------
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* The buyer's coaching sessions, and moving one (S.6 / bookings § 1.4).
|
|
616
|
+
*
|
|
617
|
+
* Session buyers had no purchase surface at all: `GET /public/coaching/bookings/mine`
|
|
618
|
+
* and the reschedule action shipped with nothing that could reach them.
|
|
619
|
+
*
|
|
620
|
+
* A booking is deliberately NOT folded into the Orders tab — that tab reads
|
|
621
|
+
* `/public/orders` and covers product orders only, so a session would never
|
|
622
|
+
* appear there however hard one looked.
|
|
623
|
+
*
|
|
624
|
+
* Nothing is recomputed here. `canReschedule` and `rescheduleReason` arrive on
|
|
625
|
+
* every row, and the slot grid the picker offers is the server's own
|
|
626
|
+
* availability read — which already excludes taken slots and hours the coach is
|
|
627
|
+
* busy on any of their services.
|
|
628
|
+
*
|
|
629
|
+
* `cancellation` comes from the POLICY SNAPSHOT taken on the booking at
|
|
630
|
+
* purchase, never the coach's current setting, and `canCancel` is the only
|
|
631
|
+
* thing that decides whether a cancel control is drawn. The cancel is
|
|
632
|
+
* idempotent server-side (one conditional UPDATE claims it before any provider
|
|
633
|
+
* call), so a double-click refunds once.
|
|
634
|
+
*/
|
|
635
|
+
function BookingsTab({ ctx }: { ctx: TabContext }) {
|
|
636
|
+
const { user } = usePublicAuth();
|
|
637
|
+
const { t, card } = useCardStyles();
|
|
638
|
+
const { data, isLoading } = useMyBookings(user?.id);
|
|
639
|
+
|
|
640
|
+
const bookings = data?.data ?? [];
|
|
641
|
+
|
|
642
|
+
return (
|
|
643
|
+
<div style={card}>
|
|
644
|
+
<h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>My Sessions</h2>
|
|
645
|
+
{isLoading ? (
|
|
646
|
+
<Loading />
|
|
647
|
+
) : bookings.length > 0 ? (
|
|
648
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
|
649
|
+
{bookings.map((booking) => (
|
|
650
|
+
<BookingRow key={booking.id} booking={booking} ctx={ctx} />
|
|
651
|
+
))}
|
|
652
|
+
</div>
|
|
653
|
+
) : (
|
|
654
|
+
<p style={{ fontSize: 14, opacity: 0.7 }}>You have no sessions booked.</p>
|
|
655
|
+
)}
|
|
656
|
+
</div>
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** The session's window, in the coach's timezone where one is set. */
|
|
661
|
+
function formatSessionWindow(booking: MyBooking): string {
|
|
662
|
+
const zone = booking.coachingProductTimezone ?? undefined;
|
|
663
|
+
const start = new Date(booking.sessionStartTime);
|
|
664
|
+
const end = new Date(booking.sessionEndTime);
|
|
665
|
+
const day = start.toLocaleString(undefined, {
|
|
666
|
+
weekday: "short",
|
|
667
|
+
day: "numeric",
|
|
668
|
+
month: "short",
|
|
669
|
+
year: "numeric",
|
|
670
|
+
timeZone: zone,
|
|
671
|
+
});
|
|
672
|
+
const from = start.toLocaleString(undefined, { hour: "numeric", minute: "2-digit", timeZone: zone });
|
|
673
|
+
const to = end.toLocaleString(undefined, { hour: "numeric", minute: "2-digit", timeZone: zone });
|
|
674
|
+
return `${day}, ${from} – ${to}${zone ? ` (${zone})` : ""}`;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
|
|
678
|
+
const { t } = useCardStyles();
|
|
679
|
+
const { formatAmount, currency } = ctx;
|
|
680
|
+
const cancelBooking = useCancelMyBooking();
|
|
681
|
+
const [picking, setPicking] = useState(false);
|
|
682
|
+
const [error, setError] = useState<string | null>(null);
|
|
683
|
+
const cancelled = booking.status === "canceled" || !!booking.selfCancelledAt;
|
|
684
|
+
|
|
685
|
+
const actionButton: React.CSSProperties = {
|
|
686
|
+
padding: "8px 14px",
|
|
687
|
+
borderRadius: t.cornerRadius,
|
|
688
|
+
border: `1px solid ${t.primary}40`,
|
|
689
|
+
background: "transparent",
|
|
690
|
+
color: t.text,
|
|
691
|
+
cursor: "pointer",
|
|
692
|
+
fontWeight: 600,
|
|
693
|
+
};
|
|
694
|
+
|
|
695
|
+
const onCancel = async () => {
|
|
696
|
+
setError(null);
|
|
697
|
+
try {
|
|
698
|
+
await cancelBooking.mutateAsync({ bookingId: booking.id });
|
|
699
|
+
} catch (err) {
|
|
700
|
+
// The server names the rule that stopped it (outside the notice window,
|
|
701
|
+
// already cancelled, a refund that cannot be made automatically). Its
|
|
702
|
+
// wording, not ours.
|
|
703
|
+
const message =
|
|
704
|
+
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
|
|
705
|
+
"We could not cancel this session. Please try again.";
|
|
706
|
+
setError(message);
|
|
707
|
+
}
|
|
708
|
+
};
|
|
709
|
+
|
|
710
|
+
return (
|
|
711
|
+
<div
|
|
712
|
+
style={{
|
|
713
|
+
border: `1px solid ${t.primary}20`,
|
|
714
|
+
borderRadius: t.cornerRadius,
|
|
715
|
+
padding: 16,
|
|
716
|
+
opacity: cancelled ? 0.6 : 1,
|
|
717
|
+
}}
|
|
718
|
+
>
|
|
719
|
+
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
|
|
720
|
+
<div>
|
|
721
|
+
<div style={{ fontWeight: 600 }}>{booking.coachingProductTitle}</div>
|
|
722
|
+
<div style={{ fontSize: 13, opacity: 0.7 }}>{formatSessionWindow(booking)}</div>
|
|
723
|
+
{booking.coachingProductDurationMinutes != null && (
|
|
724
|
+
<div style={{ fontSize: 13, opacity: 0.7 }}>{booking.coachingProductDurationMinutes} minutes</div>
|
|
725
|
+
)}
|
|
726
|
+
</div>
|
|
727
|
+
<div style={{ textAlign: "right" }}>
|
|
728
|
+
{cancelled && <div style={{ fontSize: 13, color: t.primary }}>Cancelled</div>}
|
|
729
|
+
{booking.totalAmount != null && (
|
|
730
|
+
<div style={{ fontWeight: 700, color: t.primary }}>
|
|
731
|
+
{formatAmount(booking.totalAmount, booking.currency || currency)}
|
|
732
|
+
</div>
|
|
733
|
+
)}
|
|
734
|
+
</div>
|
|
735
|
+
</div>
|
|
736
|
+
|
|
737
|
+
{/* The terms they booked under, in the artist's own words where given. */}
|
|
738
|
+
{booking.cancellation.description && (
|
|
739
|
+
<div style={{ fontSize: 13, opacity: 0.75, marginTop: 12 }}>{booking.cancellation.description}</div>
|
|
740
|
+
)}
|
|
741
|
+
{booking.cancellation.terms && (
|
|
742
|
+
<div style={{ fontSize: 13, opacity: 0.75, marginTop: 4 }}>{booking.cancellation.terms}</div>
|
|
743
|
+
)}
|
|
744
|
+
|
|
745
|
+
{/*
|
|
746
|
+
The action row. Both controls are drawn from the SERVER's flags —
|
|
747
|
+
`canReschedule` and `cancellation.canCancel` — never from eligibility
|
|
748
|
+
re-derived in the browser, which would give two answers to one question.
|
|
749
|
+
*/}
|
|
750
|
+
{error && (
|
|
751
|
+
<p style={{ color: "#ef4444", fontSize: 13, marginTop: 12 }} role="alert">
|
|
752
|
+
{error}
|
|
753
|
+
</p>
|
|
754
|
+
)}
|
|
755
|
+
|
|
756
|
+
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginTop: 12 }}>
|
|
757
|
+
{booking.canReschedule && !picking && (
|
|
758
|
+
<button onClick={() => setPicking(true)} style={actionButton}>
|
|
759
|
+
Reschedule
|
|
760
|
+
</button>
|
|
761
|
+
)}
|
|
762
|
+
{booking.cancellation.canCancel && (
|
|
763
|
+
<button
|
|
764
|
+
onClick={() => void onCancel()}
|
|
765
|
+
disabled={cancelBooking.isPending}
|
|
766
|
+
style={{ ...actionButton, cursor: cancelBooking.isPending ? "wait" : "pointer" }}
|
|
767
|
+
>
|
|
768
|
+
{cancelBooking.isPending ? "Cancelling…" : "Cancel session"}
|
|
769
|
+
</button>
|
|
770
|
+
)}
|
|
771
|
+
</div>
|
|
772
|
+
|
|
773
|
+
{picking && <RescheduleSlotPicker booking={booking} onDone={() => setPicking(false)} />}
|
|
774
|
+
</div>
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Pick another time for a session already bought.
|
|
780
|
+
*
|
|
781
|
+
* The grid is `GET /public/coaching/products/:id/availability` over the next
|
|
782
|
+
* fortnight — the same read the original purchase used, which already drops
|
|
783
|
+
* taken slots and hours the coach is busy on any of their services. It is not
|
|
784
|
+
* filtered further here: a slot this list offers and the server then refuses
|
|
785
|
+
* (two buyers racing for one hour, decided by a database EXCLUDE constraint)
|
|
786
|
+
* comes back as the server's own 409 message, which is the honest answer.
|
|
787
|
+
*/
|
|
788
|
+
function RescheduleSlotPicker({ booking, onDone }: { booking: MyBooking; onDone: () => void }) {
|
|
789
|
+
const { t } = useCardStyles();
|
|
790
|
+
const reschedule = useRescheduleBooking();
|
|
791
|
+
const [error, setError] = useState<string | null>(null);
|
|
792
|
+
const [pendingSlotId, setPendingSlotId] = useState<string | null>(null);
|
|
793
|
+
|
|
794
|
+
// A fortnight from now. Computed once per mount rather than per render, so the
|
|
795
|
+
// query key does not change on every tick and refetch forever.
|
|
796
|
+
const [window] = useState(() => {
|
|
797
|
+
const from = new Date();
|
|
798
|
+
const to = new Date(from.getTime() + 14 * 24 * 3600_000);
|
|
799
|
+
return { from: from.toISOString(), to: to.toISOString() };
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
const { data: slots, isLoading } = useCoachingAvailability(booking.coachingProductId, window.from, window.to);
|
|
803
|
+
const zone = booking.coachingProductTimezone ?? undefined;
|
|
804
|
+
|
|
805
|
+
const onPick = async (slotId: string) => {
|
|
806
|
+
setError(null);
|
|
807
|
+
setPendingSlotId(slotId);
|
|
808
|
+
try {
|
|
809
|
+
await reschedule.mutateAsync({ bookingId: booking.id, slotId });
|
|
810
|
+
onDone();
|
|
811
|
+
} catch (err) {
|
|
812
|
+
const message =
|
|
813
|
+
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
|
|
814
|
+
"We could not move this session. Please try another time.";
|
|
815
|
+
setError(message);
|
|
816
|
+
} finally {
|
|
817
|
+
setPendingSlotId(null);
|
|
818
|
+
}
|
|
819
|
+
};
|
|
820
|
+
|
|
821
|
+
const open = (slots ?? []).filter((slot) => slot.id !== booking.coachingBookingSlotId);
|
|
822
|
+
|
|
823
|
+
return (
|
|
824
|
+
<div style={{ marginTop: 12, padding: 12, borderRadius: t.cornerRadius, border: `1px solid ${t.primary}4d` }}>
|
|
825
|
+
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12 }}>
|
|
826
|
+
<strong style={{ fontSize: 14 }}>Choose a new time</strong>
|
|
827
|
+
<button
|
|
828
|
+
onClick={onDone}
|
|
829
|
+
style={{
|
|
830
|
+
padding: "4px 10px",
|
|
831
|
+
borderRadius: t.cornerRadius,
|
|
832
|
+
border: `1px solid ${t.primary}40`,
|
|
833
|
+
background: "transparent",
|
|
834
|
+
color: t.text,
|
|
835
|
+
cursor: "pointer",
|
|
836
|
+
fontSize: 13,
|
|
837
|
+
}}
|
|
838
|
+
>
|
|
839
|
+
Close
|
|
840
|
+
</button>
|
|
841
|
+
</div>
|
|
842
|
+
|
|
843
|
+
{error && (
|
|
844
|
+
<p style={{ color: "#ef4444", fontSize: 13, marginTop: 10 }} role="alert">
|
|
845
|
+
{error}
|
|
846
|
+
</p>
|
|
847
|
+
)}
|
|
848
|
+
|
|
849
|
+
{isLoading ? (
|
|
850
|
+
<Loading />
|
|
851
|
+
) : open.length > 0 ? (
|
|
852
|
+
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: 12 }}>
|
|
853
|
+
{open.map((slot) => {
|
|
854
|
+
const start = new Date(slot.startTime);
|
|
855
|
+
const busy = pendingSlotId === slot.id;
|
|
856
|
+
return (
|
|
857
|
+
<button
|
|
858
|
+
key={slot.id}
|
|
859
|
+
onClick={() => void onPick(slot.id)}
|
|
860
|
+
disabled={reschedule.isPending}
|
|
861
|
+
style={{
|
|
862
|
+
padding: "8px 12px",
|
|
863
|
+
borderRadius: t.cornerRadius,
|
|
864
|
+
border: `1px solid ${t.primary}40`,
|
|
865
|
+
background: "transparent",
|
|
866
|
+
color: t.text,
|
|
867
|
+
cursor: reschedule.isPending ? "wait" : "pointer",
|
|
868
|
+
fontSize: 13,
|
|
869
|
+
}}
|
|
870
|
+
>
|
|
871
|
+
{busy
|
|
872
|
+
? "Moving…"
|
|
873
|
+
: start.toLocaleString(undefined, {
|
|
874
|
+
weekday: "short",
|
|
875
|
+
day: "numeric",
|
|
876
|
+
month: "short",
|
|
877
|
+
hour: "numeric",
|
|
878
|
+
minute: "2-digit",
|
|
879
|
+
timeZone: zone,
|
|
880
|
+
})}
|
|
881
|
+
</button>
|
|
882
|
+
);
|
|
883
|
+
})}
|
|
884
|
+
</div>
|
|
885
|
+
) : (
|
|
886
|
+
<p style={{ fontSize: 13, opacity: 0.7, marginTop: 12 }}>
|
|
887
|
+
No other times are open in the next two weeks.
|
|
888
|
+
</p>
|
|
889
|
+
)}
|
|
890
|
+
</div>
|
|
891
|
+
);
|
|
892
|
+
}
|
|
893
|
+
|
|
256
894
|
// ---- Orders tab -------------------------------------------------------------
|
|
257
895
|
|
|
258
896
|
function OrdersTab({ ctx }: { ctx: TabContext }) {
|