@tribe-nest/forge 2.2.0 → 3.4.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 (54) hide show
  1. package/package.json +1 -1
  2. package/src/client/createForgeClient.ts +85 -2
  3. package/src/client/tokenStorage.ts +31 -0
  4. package/src/contexts/AppAuthContext.tsx +100 -15
  5. package/src/contexts/PublicAuthContext.tsx +46 -9
  6. package/src/data/queries/useCheckouts.ts +84 -1
  7. package/src/data/queries/useCoachingAvailability.ts +18 -3
  8. package/src/data/queries/useCourseAccess.ts +1 -1
  9. package/src/data/queries/useCourses.ts +42 -1
  10. package/src/data/queries/useEvents.ts +15 -1
  11. package/src/data/queries/usePaymentFlow.ts +12 -0
  12. package/src/data/queries/useWebsite.ts +6 -0
  13. package/src/index.ts +19 -2
  14. package/src/provider/ForgeProvider.tsx +30 -1
  15. package/src/server/_tests/platformEvents.spec.ts +315 -0
  16. package/src/server/index.ts +17 -0
  17. package/src/server/jobs.ts +41 -10
  18. package/src/server/platform.ts +234 -9
  19. package/src/server/platformEvents.generated.ts +422 -0
  20. package/src/types/models.ts +110 -3
  21. package/src/ui/headless/auth/useSignupForm.ts +69 -4
  22. package/src/ui/headless/calendar/useAddToCalendar.ts +194 -0
  23. package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +169 -0
  24. package/src/ui/headless/checkout/bundleCoupon.ts +96 -0
  25. package/src/ui/headless/checkout/useCheckout.ts +156 -8
  26. package/src/ui/headless/coaching/useCoachingBooking.ts +53 -3
  27. package/src/ui/headless/coupon/_tests/couponFailureMessage.spec.ts +84 -0
  28. package/src/ui/headless/coupon/useCouponField.ts +164 -0
  29. package/src/ui/headless/course/useCourseCheckout.ts +113 -18
  30. package/src/ui/headless/event/useEventCheckout.ts +53 -2
  31. package/src/ui/headless/index.ts +15 -0
  32. package/src/ui/headless/work/useWorkPortal.ts +24 -21
  33. package/src/ui/index.ts +7 -0
  34. package/src/ui/shell/PoweredBy.tsx +60 -0
  35. package/src/ui/shell/TribeNestApp.tsx +15 -1
  36. package/src/ui/shell/shellGating.spec.ts +21 -1
  37. package/src/ui/shell/shellGating.ts +14 -0
  38. package/src/ui/styled/AddToCalendar.tsx +104 -0
  39. package/src/ui/styled/Checkout.tsx +45 -14
  40. package/src/ui/styled/CoachingBooking.tsx +28 -8
  41. package/src/ui/styled/CoachingConfirmation.tsx +12 -0
  42. package/src/ui/styled/CourseCheckout.tsx +49 -18
  43. package/src/ui/styled/DiscountCode.tsx +206 -0
  44. package/src/ui/styled/EventConfirmation.tsx +68 -22
  45. package/src/ui/styled/EventDetail.tsx +18 -5
  46. package/src/ui/styled/EventTickets.tsx +49 -5
  47. package/src/ui/styled/SignupForm.tsx +86 -35
  48. package/src/ui/styled/_tests/DiscountCode.spec.tsx +272 -0
  49. package/src/ui/styled/_tests/EventConfirmation.spec.tsx +154 -0
  50. package/src/ui/styled/work/WorkInviteAccept.tsx +54 -5
  51. package/src/utils/_tests/safeRedirect.spec.ts +117 -0
  52. package/src/utils/_tests/ticketOrderOutcome.spec.ts +126 -0
  53. package/src/utils/safeRedirect.ts +41 -0
  54. 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,7 @@ 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 { AddToCalendar } from "./AddToCalendar";
11
12
 
12
13
  export interface EventDetailProps {
13
14
  /** Event slug (resolves the event). */
@@ -63,6 +64,10 @@ export function EventDetail({
63
64
 
64
65
  const cover = event.media?.find((m) => m.type === "image")?.url ?? event.media?.[0]?.url;
65
66
  const minPrice = event.tickets.length ? Math.min(...event.tickets.map((t) => t.price)) : 0;
67
+ const locationText =
68
+ event.type === "virtual" || !event.address
69
+ ? "Virtual"
70
+ : [event.address.name, event.address.street, event.address.city, event.address.country].filter(Boolean).join(", ");
66
71
 
67
72
  return (
68
73
  <div
@@ -96,14 +101,22 @@ export function EventDetail({
96
101
  </span>
97
102
  <span style={{ display: "flex", alignItems: "flex-start", gap: 8 }}>
98
103
  <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(", ")}
104
+ {locationText}
104
105
  </span>
105
106
  </div>
106
107
 
108
+ {/* Nothing to diarise for an event that has been called off. */}
109
+ {event.status !== "cancelled" && (
110
+ <AddToCalendar
111
+ title={event.title}
112
+ start={event.dateTime}
113
+ end={event.endDateTime}
114
+ description={event.description}
115
+ location={locationText}
116
+ style={{ marginTop: 16 }}
117
+ />
118
+ )}
119
+
107
120
  {event.description && (
108
121
  <>
109
122
  <h2 style={{ fontSize: 20, fontWeight: 700, margin: "24px 0 8px", fontFamily: t.headingFontFamily }}>About this event</h2>
@@ -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 ? (
@@ -52,51 +52,102 @@ export function SignupForm({ onSuccess, loginHref, membershipTierId, couponCode
52
52
 
53
53
  return (
54
54
  <div style={card}>
55
- <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 24 }}>Create your account</h1>
55
+ <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 24 }}>
56
+ {form.awaitingCode ? "Check your email" : "Create your account"}
57
+ </h1>
56
58
  {form.error && <p style={{ color: "#ef4444", marginBottom: 16 }}>{form.error}</p>}
57
59
  <form
58
60
  onSubmit={(e) => {
59
61
  e.preventDefault();
60
- form.submit();
62
+ // Signup is two steps: create, then redeem the emailed code.
63
+ if (form.awaitingCode) form.submitCode();
64
+ else form.submit();
61
65
  }}
62
66
  style={{ display: "flex", flexDirection: "column", gap: 16 }}
63
67
  >
64
- <div style={{ display: "flex", gap: 12 }}>
65
- {field(
66
- "First name",
67
- <input value={form.firstName} onChange={(e) => form.setFirstName(e.target.value)} style={input} placeholder="First name" />,
68
- )}
69
- {field(
70
- "Last name",
71
- <input value={form.lastName} onChange={(e) => form.setLastName(e.target.value)} style={input} placeholder="Last name" />,
72
- )}
73
- </div>
74
- {field(
75
- "Email",
76
- <input type="email" value={form.email} onChange={(e) => form.setEmail(e.target.value)} style={input} placeholder="Email" />,
68
+ {form.awaitingCode ? (
69
+ <>
70
+ <p style={{ fontSize: 14, margin: 0 }}>
71
+ We emailed a 6-digit code to <strong>{form.email}</strong>. Enter it to finish creating your account.
72
+ </p>
73
+ {field(
74
+ "Verification code",
75
+ <input
76
+ inputMode="numeric"
77
+ autoComplete="one-time-code"
78
+ value={form.code}
79
+ onChange={(e) => form.setCode(e.target.value)}
80
+ style={input}
81
+ placeholder="123456"
82
+ />,
83
+ )}
84
+ </>
85
+ ) : (
86
+ <>
87
+ <div style={{ display: "flex", gap: 12 }}>
88
+ {field(
89
+ "First name",
90
+ <input
91
+ value={form.firstName}
92
+ onChange={(e) => form.setFirstName(e.target.value)}
93
+ style={input}
94
+ placeholder="First name"
95
+ />,
96
+ )}
97
+ {field(
98
+ "Last name",
99
+ <input
100
+ value={form.lastName}
101
+ onChange={(e) => form.setLastName(e.target.value)}
102
+ style={input}
103
+ placeholder="Last name"
104
+ />,
105
+ )}
106
+ </div>
107
+ {field(
108
+ "Email",
109
+ <input
110
+ type="email"
111
+ value={form.email}
112
+ onChange={(e) => form.setEmail(e.target.value)}
113
+ style={input}
114
+ placeholder="Email"
115
+ />,
116
+ )}
117
+ {field(
118
+ "Password",
119
+ <input
120
+ type="password"
121
+ value={form.password}
122
+ onChange={(e) => form.setPassword(e.target.value)}
123
+ style={input}
124
+ placeholder="At least 8 characters"
125
+ />,
126
+ )}
127
+ <label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 13 }}>
128
+ <input
129
+ type="checkbox"
130
+ checked={form.acceptedTerms}
131
+ onChange={(e) => form.setAcceptedTerms(e.target.checked)}
132
+ style={{ accentColor: theme.colors.primary }}
133
+ />
134
+ I accept the Terms and Privacy Policy
135
+ </label>
136
+ </>
77
137
  )}
78
- {field(
79
- "Password",
80
- <input
81
- type="password"
82
- value={form.password}
83
- onChange={(e) => form.setPassword(e.target.value)}
84
- style={input}
85
- placeholder="At least 8 characters"
86
- />,
87
- )}
88
- <label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 13 }}>
89
- <input
90
- type="checkbox"
91
- checked={form.acceptedTerms}
92
- onChange={(e) => form.setAcceptedTerms(e.target.checked)}
93
- style={{ accentColor: theme.colors.primary }}
94
- />
95
- I accept the Terms and Privacy Policy
96
- </label>
97
138
  <button type="submit" disabled={form.isSubmitting} style={button}>
98
- {form.isSubmitting ? "Creating account…" : "Sign up"}
139
+ {form.isSubmitting ? "Working…" : form.awaitingCode ? "Verify and continue" : "Sign up"}
99
140
  </button>
141
+ {form.awaitingCode && (
142
+ <button
143
+ type="button"
144
+ onClick={() => form.resendCode()}
145
+ disabled={form.isSubmitting}
146
+ style={{ background: "none", border: "none", color: theme.colors.primary, cursor: "pointer", fontSize: 13 }}
147
+ >
148
+ Resend code
149
+ </button>
150
+ )}
100
151
  </form>
101
152
  <p style={{ marginTop: 16, fontSize: 14, textAlign: "center" }}>
102
153
  Already have an account?{" "}