@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.
Files changed (56) hide show
  1. package/package.json +1 -1
  2. package/src/data/queries/_tests/eventWaitlist.spec.ts +122 -0
  3. package/src/data/queries/_tests/passTransfers.spec.ts +89 -0
  4. package/src/data/queries/_tests/walletPass.spec.ts +159 -0
  5. package/src/data/queries/useCheckouts.ts +84 -1
  6. package/src/data/queries/useCoachingAvailability.ts +18 -3
  7. package/src/data/queries/useCourses.ts +42 -1
  8. package/src/data/queries/useEventWaitlist.ts +429 -0
  9. package/src/data/queries/useEvents.ts +15 -1
  10. package/src/data/queries/useMyBookings.ts +211 -0
  11. package/src/data/queries/useMyTickets.ts +154 -0
  12. package/src/data/queries/usePassTransfers.ts +318 -0
  13. package/src/data/queries/usePaymentFlow.ts +12 -0
  14. package/src/data/queries/useWalletPass.ts +236 -0
  15. package/src/data/queries/useWebsite.ts +6 -0
  16. package/src/index.ts +14 -0
  17. package/src/server/_tests/siteBootstrap.spec.ts +131 -0
  18. package/src/server/index.ts +122 -6
  19. package/src/types/diagnostics.ts +49 -0
  20. package/src/types/models.ts +66 -0
  21. package/src/ui/headless/calendar/useAddToCalendar.ts +194 -0
  22. package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +169 -0
  23. package/src/ui/headless/checkout/bundleCoupon.ts +96 -0
  24. package/src/ui/headless/checkout/useCheckout.ts +156 -8
  25. package/src/ui/headless/coaching/useCoachingBooking.ts +53 -3
  26. package/src/ui/headless/coupon/_tests/couponFailureMessage.spec.ts +84 -0
  27. package/src/ui/headless/coupon/useCouponField.ts +164 -0
  28. package/src/ui/headless/course/useCourseCheckout.ts +113 -18
  29. package/src/ui/headless/event/useEventCheckout.ts +53 -2
  30. package/src/ui/headless/index.ts +15 -0
  31. package/src/ui/index.ts +26 -0
  32. package/src/ui/shell/PoweredBy.tsx +62 -0
  33. package/src/ui/shell/PreviewDiagnostics.tsx +80 -0
  34. package/src/ui/shell/TribeNestApp.tsx +39 -1
  35. package/src/ui/shell/diagnosticsGating.spec.ts +102 -0
  36. package/src/ui/shell/diagnosticsGating.ts +90 -0
  37. package/src/ui/shell/shellGating.spec.ts +60 -1
  38. package/src/ui/shell/shellGating.ts +47 -0
  39. package/src/ui/styled/AccountDashboard.tsx +598 -1
  40. package/src/ui/styled/AddToCalendar.tsx +104 -0
  41. package/src/ui/styled/Checkout.tsx +45 -14
  42. package/src/ui/styled/CoachingBooking.tsx +28 -8
  43. package/src/ui/styled/CoachingConfirmation.tsx +12 -0
  44. package/src/ui/styled/CourseCheckout.tsx +49 -18
  45. package/src/ui/styled/DiscountCode.tsx +206 -0
  46. package/src/ui/styled/EventConfirmation.tsx +68 -22
  47. package/src/ui/styled/EventDetail.tsx +24 -5
  48. package/src/ui/styled/EventTickets.tsx +49 -5
  49. package/src/ui/styled/EventWaitlist.tsx +448 -0
  50. package/src/ui/styled/TicketTransfer.tsx +393 -0
  51. package/src/ui/styled/WalletPassButtons.tsx +208 -0
  52. package/src/ui/styled/_tests/DiscountCode.spec.tsx +272 -0
  53. package/src/ui/styled/_tests/EventConfirmation.spec.tsx +154 -0
  54. package/src/ui/styled/_tests/WalletPassButtons.spec.tsx +223 -0
  55. package/src/utils/_tests/ticketOrderOutcome.spec.ts +126 -0
  56. package/src/utils/ticketOrderOutcome.ts +125 -0
@@ -1,4 +1,4 @@
1
- import { AlertTriangle, ArrowRight, Calendar, MapPin, Ticket } from "lucide-react";
1
+ import { AlertTriangle, ArrowRight, Calendar, MapPin, RotateCcw, Ticket } from "lucide-react";
2
2
  import { useForgeTheme } from "../theme/ForgeThemeProvider";
3
3
  import { readableTextOn } from "../theme/contrast";
4
4
  import { useAmountFormatter } from "../format/useFormatCurrency";
@@ -6,20 +6,8 @@ import { Loading } from "./Loading";
6
6
  import { ConfirmationStage, ConfirmationCard, CheckSeal, WarnSeal, Perforation, ConfirmationRow, alpha } from "./Confirmation";
7
7
  import { useEventOrderFinalize } from "../../data/queries/useFinalize";
8
8
  import { useEvent } from "../../data/queries/useEvents";
9
- import { OrderStatus } from "../../types/models";
10
-
11
- // A paid (or free) order advances through fulfillment — processing → shipped /
12
- // delivered / processed. All of these mean the order succeeded, so the ticket
13
- // confirmation should treat them as "paid"; only failed/pending-payment states
14
- // are "not completed". (A free order can be `delivered` by the time this loads.)
15
- const PAID_STATES = new Set<OrderStatus>([
16
- OrderStatus.Paid,
17
- OrderStatus.Processing,
18
- OrderStatus.LabelPurchased,
19
- OrderStatus.Shipped,
20
- OrderStatus.Delivered,
21
- OrderStatus.Processed,
22
- ]);
9
+ import { AddToCalendar } from "./AddToCalendar";
10
+ import { getTicketOrderOutcome, TICKET_ORDER_REFUNDED_COPY } from "../../utils/ticketOrderOutcome";
23
11
 
24
12
  export interface EventConfirmationProps {
25
13
  slug: string;
@@ -78,7 +66,13 @@ export function EventConfirmation({
78
66
  );
79
67
  }
80
68
 
81
- const paid = PAID_STATES.has(order.status);
69
+ // `cancelled` means two different things — swept-abandoned and refund-voided —
70
+ // and the copy for one is a lie told to the other. `getTicketOrderOutcome`
71
+ // reads the order's refund columns to tell them apart; the reasoning for why
72
+ // that is a trustworthy signal lives there.
73
+ const outcome = getTicketOrderOutcome(order);
74
+ const paid = outcome === "paid";
75
+ const refunded = outcome === "refunded";
82
76
  const fmt = (n: number) => formatAmt(n, order.currency || undefined);
83
77
  const location =
84
78
  event && event.type !== "virtual" && event.address
@@ -90,7 +84,14 @@ export function EventConfirmation({
90
84
  <ConfirmationCard>
91
85
  {/* ── Ceremony ── */}
92
86
  <div style={{ padding: "38px 30px 26px", textAlign: "center" }}>
93
- {paid ? <CheckSeal /> : <WarnSeal icon={<AlertTriangle size={34} color="#f59e0b" />} />}
87
+ {paid ? (
88
+ <CheckSeal />
89
+ ) : refunded ? (
90
+ // Neutral, not alarmed: a refund is a completed outcome, not a fault.
91
+ <NeutralSeal icon={<RotateCcw size={32} color={alpha(text, 0.65)} />} text={text} />
92
+ ) : (
93
+ <WarnSeal icon={<AlertTriangle size={34} color="#f59e0b" />} />
94
+ )}
94
95
 
95
96
  <p
96
97
  className="cf-rise"
@@ -100,19 +101,21 @@ export function EventConfirmation({
100
101
  fontWeight: 700,
101
102
  letterSpacing: "0.22em",
102
103
  textTransform: "uppercase",
103
- color: paid ? primary : "#f59e0b",
104
+ color: paid ? primary : refunded ? alpha(text, 0.65) : "#f59e0b",
104
105
  animationDelay: "0.15s",
105
106
  }}
106
107
  >
107
- {paid ? "E-Ticket" : "Order " + order.status.replace(/_/g, " ")}
108
+ {paid ? "E-Ticket" : refunded ? TICKET_ORDER_REFUNDED_COPY.eyebrow : "Order " + order.status.replace(/_/g, " ")}
108
109
  </p>
109
110
  <h1 className="cf-rise" style={{ fontSize: 29, fontWeight: 800, lineHeight: 1.12, margin: "0 0 8px", animationDelay: "0.22s" }}>
110
- {paid ? "You’re going! 🎟" : "Order not completed"}
111
+ {paid ? "You’re going! 🎟" : refunded ? TICKET_ORDER_REFUNDED_COPY.heading : "Order not completed"}
111
112
  </h1>
112
113
  <p className="cf-rise" style={{ fontSize: 15, color: alpha(text, 0.7), margin: 0, animationDelay: "0.3s" }}>
113
114
  {paid
114
115
  ? `We’ve sent your tickets to ${order.email}.`
115
- : `Your order is ${order.status.replace(/_/g, " ")}. If you were charged, contact support.`}
116
+ : refunded
117
+ ? TICKET_ORDER_REFUNDED_COPY.body
118
+ : `Your order is ${order.status.replace(/_/g, " ")}. If you were charged, contact support.`}
116
119
  </p>
117
120
  </div>
118
121
 
@@ -134,6 +137,20 @@ export function EventConfirmation({
134
137
  </>
135
138
  )}
136
139
  </div>
140
+
141
+ {/* Diarise it while the confirmation is still on screen — the single
142
+ highest-intent moment there is. Not offered for a called-off event. */}
143
+ {paid && event && event.status !== "cancelled" && (
144
+ <AddToCalendar
145
+ variant="compact"
146
+ title={event.title}
147
+ start={event.dateTime}
148
+ end={event.endDateTime}
149
+ description={event.description}
150
+ location={location}
151
+ style={{ marginTop: 12 }}
152
+ />
153
+ )}
137
154
  </div>
138
155
 
139
156
  <Perforation />
@@ -173,7 +190,11 @@ export function EventConfirmation({
173
190
  borderTop: `1px solid ${alpha(text, 0.1)}`,
174
191
  }}
175
192
  >
176
- <span style={{ fontWeight: 700 }}>Total {paid ? "paid" : ""}</span>
193
+ {/* Never "Total paid" for money that has gone back, and never a bare
194
+ "Total" for money that never moved. */}
195
+ <span style={{ fontWeight: 700 }}>
196
+ {paid ? "Total paid" : refunded ? TICKET_ORDER_REFUNDED_COPY.totalLabel : "Total"}
197
+ </span>
177
198
  {/* The exact charged total (charged currency) when present — never a re-converted catalog amount. */}
178
199
  <span style={{ fontSize: 18, fontWeight: 800, color: primary }}>
179
200
  {order.totalAmountInChargedCurrency
@@ -219,6 +240,31 @@ export function EventConfirmation({
219
240
  );
220
241
  }
221
242
 
243
+ /**
244
+ * The seal for a refunded order: same geometry as `CheckSeal`/`WarnSeal`, but
245
+ * drawn in the theme's own text color rather than warning amber. A refund is a
246
+ * settled outcome, not an error, and an amber warning ring says otherwise.
247
+ */
248
+ function NeutralSeal({ icon, text }: { icon: React.ReactNode; text: string }) {
249
+ return (
250
+ <div
251
+ className="cf-pop"
252
+ style={{
253
+ width: 76,
254
+ height: 76,
255
+ margin: "0 auto",
256
+ borderRadius: "50%",
257
+ display: "grid",
258
+ placeItems: "center",
259
+ background: alpha(text, 0.06),
260
+ border: `1px solid ${alpha(text, 0.18)}`,
261
+ }}
262
+ >
263
+ {icon}
264
+ </div>
265
+ );
266
+ }
267
+
222
268
  function Line({ icon, text, muted }: { icon: React.ReactNode; text: string; muted: string }) {
223
269
  return (
224
270
  <div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 14, color: muted, padding: "3px 0" }}>
@@ -8,6 +8,8 @@ import { usePricesIncludeTax } from "../../data/queries/useWebsite";
8
8
  import { Loading } from "./Loading";
9
9
  import { EventTickets } from "./EventTickets";
10
10
  import { EventCountdown } from "./EventCountdown";
11
+ import { EventWaitlist } from "./EventWaitlist";
12
+ import { AddToCalendar } from "./AddToCalendar";
11
13
 
12
14
  export interface EventDetailProps {
13
15
  /** Event slug (resolves the event). */
@@ -63,6 +65,10 @@ export function EventDetail({
63
65
 
64
66
  const cover = event.media?.find((m) => m.type === "image")?.url ?? event.media?.[0]?.url;
65
67
  const minPrice = event.tickets.length ? Math.min(...event.tickets.map((t) => t.price)) : 0;
68
+ const locationText =
69
+ event.type === "virtual" || !event.address
70
+ ? "Virtual"
71
+ : [event.address.name, event.address.street, event.address.city, event.address.country].filter(Boolean).join(", ");
66
72
 
67
73
  return (
68
74
  <div
@@ -96,20 +102,33 @@ export function EventDetail({
96
102
  </span>
97
103
  <span style={{ display: "flex", alignItems: "flex-start", gap: 8 }}>
98
104
  <MapPin size={16} style={{ marginTop: 2, flexShrink: 0 }} />
99
- {event.type === "virtual" || !event.address
100
- ? "Virtual"
101
- : [event.address.name, event.address.street, event.address.city, event.address.country]
102
- .filter(Boolean)
103
- .join(", ")}
105
+ {locationText}
104
106
  </span>
105
107
  </div>
106
108
 
109
+ {/* Nothing to diarise for an event that has been called off. */}
110
+ {event.status !== "cancelled" && (
111
+ <AddToCalendar
112
+ title={event.title}
113
+ start={event.dateTime}
114
+ end={event.endDateTime}
115
+ description={event.description}
116
+ location={locationText}
117
+ style={{ marginTop: 16 }}
118
+ />
119
+ )}
120
+
107
121
  {event.description && (
108
122
  <>
109
123
  <h2 style={{ fontSize: 20, fontWeight: 700, margin: "24px 0 8px", fontFamily: t.headingFontFamily }}>About this event</h2>
110
124
  <div dangerouslySetInnerHTML={{ __html: event.description }} style={{ lineHeight: 1.7, opacity: 0.9 }} />
111
125
  </>
112
126
  )}
127
+
128
+ {/* Renders nothing unless a tier is sold out or this visitor already
129
+ holds a place (2.6). Passed the loaded event because the waitlist
130
+ API addresses events by id, not slug. */}
131
+ <EventWaitlist event={event} style={{ marginTop: 24 }} />
113
132
  </div>
114
133
 
115
134
  {event.tickets.length > 0 && (
@@ -9,6 +9,7 @@ import { usePricesIncludeTax } from "../../data/queries/useWebsite";
9
9
  import { Loading } from "./Loading";
10
10
  import { usePaymentRenderer, type PaymentRenderProps } from "../payment/ForgePaymentProvider";
11
11
  import { buttonStyle } from "./Button";
12
+ import { DiscountCodeField, DiscountSummaryLines } from "./DiscountCode";
12
13
  import {
13
14
  DialogRoot,
14
15
  DialogTrigger,
@@ -420,6 +421,15 @@ function DetailsStep({ c, event, fmt }: { c: Checkout; event: IEvent; fmt: (n: n
420
421
  );
421
422
  })}
422
423
 
424
+ {/* Tickets have no apply-coupon endpoint, so the code is carried into
425
+ the order that "Continue" creates — hence `deferred`. Its result is
426
+ what draws the discount line on the payment step. */}
427
+ {c.subTotal > 0 && (
428
+ <div style={{ marginTop: 4, paddingTop: 12, borderTop: `1px solid ${a(theme.colors.primary, 0.13)}` }}>
429
+ <DiscountCodeField coupon={c.coupon} fmt={fmt} mode="deferred" />
430
+ </div>
431
+ )}
432
+
423
433
  {(localError || c.error) && <p style={{ color: "#ef4444", fontSize: 14 }}>{localError ?? c.error}</p>}
424
434
  </div>
425
435
  <ActionBar
@@ -461,6 +471,35 @@ function PaymentStep({
461
471
  marginBottom: 16,
462
472
  }}
463
473
  >
474
+ {/* What the code actually did, from the order response — never a total
475
+ that silently shrank. Automatic (no-code) discounts land here too. */}
476
+ <DiscountSummaryLines
477
+ subTotal={c.subTotal}
478
+ discountAmount={c.coupon.discountAmount}
479
+ appliedCoupons={c.coupon.appliedCoupons}
480
+ fmt={fmt}
481
+ />
482
+ {c.coupon.hasDiscount && (
483
+ <div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 8 }}>
484
+ <button
485
+ type="button"
486
+ data-testid="discount-code-remove"
487
+ onClick={c.clearCoupon}
488
+ style={{
489
+ background: "transparent",
490
+ border: "none",
491
+ padding: 0,
492
+ fontSize: 13,
493
+ textDecoration: "underline",
494
+ cursor: "pointer",
495
+ color: theme.colors.text,
496
+ fontFamily: theme.fontFamily,
497
+ }}
498
+ >
499
+ Remove discount
500
+ </button>
501
+ </div>
502
+ )}
464
503
  {taxSummary?.mode === "exclusive" && (
465
504
  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
466
505
  <span style={{ fontSize: 14, opacity: 0.75 }}>Tax</span>
@@ -525,13 +564,18 @@ function ActionBar({
525
564
  border: `1px solid ${a(theme.colors.primary, 0.25)}`,
526
565
  borderRadius: theme.cornerRadius,
527
566
  marginBottom: 16,
528
- display: "flex",
529
- justifyContent: "space-between",
530
- alignItems: "center",
531
567
  }}
532
568
  >
533
- <span style={{ fontWeight: 600 }}>Total</span>
534
- <span style={{ fontSize: 18, fontWeight: 700, color: theme.colors.primary }}>{fmt(c.totalAmount)}</span>
569
+ <DiscountSummaryLines
570
+ subTotal={c.subTotal}
571
+ discountAmount={c.coupon.discountAmount}
572
+ appliedCoupons={c.coupon.appliedCoupons}
573
+ fmt={fmt}
574
+ />
575
+ <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
576
+ <span style={{ fontWeight: 600 }}>Total</span>
577
+ <span style={{ fontSize: 18, fontWeight: 700, color: theme.colors.primary }}>{fmt(c.totalAmount)}</span>
578
+ </div>
535
579
  </div>
536
580
  <div style={{ display: "flex", gap: 8, justifyContent: "space-between" }}>
537
581
  {secondary ? (