@tribe-nest/forge 3.20.0 → 3.22.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 (50) hide show
  1. package/package.json +1 -1
  2. package/src/data/queries/_tests/passTransfers.spec.ts +100 -4
  3. package/src/data/queries/useAccountSettings.ts +15 -2
  4. package/src/data/queries/useAuthActions.ts +51 -7
  5. package/src/data/queries/useEvents.ts +66 -4
  6. package/src/data/queries/useMembership.ts +8 -2
  7. package/src/data/queries/useMyBookings.ts +12 -0
  8. package/src/data/queries/useMyTickets.ts +112 -0
  9. package/src/data/queries/useOrders.ts +10 -0
  10. package/src/data/queries/usePassTransfers.ts +73 -13
  11. package/src/server/index.ts +52 -0
  12. package/src/types/models.ts +151 -0
  13. package/src/ui/format/_tests/attendees.spec.ts +231 -0
  14. package/src/ui/format/_tests/membershipGate.spec.ts +220 -0
  15. package/src/ui/format/attendees.ts +187 -0
  16. package/src/ui/format/membershipGate.ts +209 -0
  17. package/src/ui/headless/calendar/_tests/useAddToCalendar.spec.ts +83 -0
  18. package/src/ui/headless/calendar/useAddToCalendar.ts +46 -5
  19. package/src/ui/headless/checkout/_tests/inventoryHold.spec.ts +111 -0
  20. package/src/ui/headless/checkout/inventoryHold.ts +83 -0
  21. package/src/ui/headless/checkout/useCheckout.ts +72 -0
  22. package/src/ui/headless/checkout/useInventoryHold.ts +104 -0
  23. package/src/ui/headless/event/useEventCheckout.ts +133 -2
  24. package/src/ui/headless/event/usePresaleCode.ts +181 -0
  25. package/src/ui/headless/index.ts +25 -0
  26. package/src/ui/headless/membership/useMembershipGateNotice.ts +83 -0
  27. package/src/ui/headless/offer/OfferContext.tsx +55 -0
  28. package/src/ui/index.ts +42 -0
  29. package/src/ui/styled/AccountDashboard.tsx +70 -8
  30. package/src/ui/styled/AddToCalendar.tsx +34 -10
  31. package/src/ui/styled/Checkout.tsx +18 -1
  32. package/src/ui/styled/CoachingConfirmation.tsx +4 -0
  33. package/src/ui/styled/CourseDetail.tsx +30 -1
  34. package/src/ui/styled/EventConfirmation.tsx +2 -0
  35. package/src/ui/styled/EventDetail.tsx +53 -22
  36. package/src/ui/styled/EventTickets.tsx +156 -5
  37. package/src/ui/styled/HoldNotice.tsx +192 -0
  38. package/src/ui/styled/MembershipGateNotice.tsx +159 -0
  39. package/src/ui/styled/OfferButton.tsx +23 -0
  40. package/src/ui/styled/PresaleCode.tsx +174 -0
  41. package/src/ui/styled/ProductDetail.tsx +75 -5
  42. package/src/ui/styled/ProductGrid.tsx +26 -0
  43. package/src/ui/styled/TicketTransfer.tsx +69 -40
  44. package/src/ui/styled/_tests/AddToCalendar.spec.tsx +88 -0
  45. package/src/ui/styled/_tests/EventConfirmation.spec.tsx +5 -1
  46. package/src/ui/styled/_tests/PresaleCode.spec.tsx +106 -0
  47. package/src/utils/_tests/presaleCode.spec.ts +168 -0
  48. package/src/utils/_tests/structuredData.spec.ts +275 -0
  49. package/src/utils/presaleCode.ts +96 -0
  50. package/src/utils/structuredData.ts +361 -27
package/src/ui/index.ts CHANGED
@@ -51,6 +51,7 @@ export {
51
51
  DiscountSummaryLines,
52
52
  type DiscountSummaryLinesProps,
53
53
  } from "./styled/DiscountCode";
54
+ export { PresaleCodeField, type PresaleCodeFieldProps } from "./styled/PresaleCode";
54
55
  export { MembershipCheckout, type MembershipCheckoutProps } from "./styled/MembershipCheckout";
55
56
  export { ForgeStripePayment } from "./payment/ForgeStripePayment";
56
57
  export { Cart, type CartProps } from "./styled/Cart";
@@ -109,6 +110,8 @@ export { ForgeAnalytics, type ForgeAnalyticsProps } from "./analytics/ForgeAnaly
109
110
  export { PageMetaPixel, usePageMetaPixel, type PageMetaPixelProps } from "./analytics/PageMetaPixel";
110
111
  export { CookieConsent, type CookieConsentProps } from "./styled/CookieConsent";
111
112
  export { Loading, type LoadingProps } from "./styled/Loading";
113
+ /** The inventory-hold countdown + its lapsed/409 state. See `useInventoryHold`. */
114
+ export { HoldNotice, type HoldNoticeProps } from "./styled/HoldNotice";
112
115
  export {
113
116
  ConfirmationStage,
114
117
  ConfirmationCard,
@@ -170,6 +173,45 @@ export {
170
173
  resolveUnitPrice,
171
174
  ticketSubtotals,
172
175
  } from "./format/pwyw";
176
+ // Per-attendee names at checkout. Exported for a site rendering its own ticket
177
+ // UI — and used by `apps/client`, which has its own event checkout: the request
178
+ // shape is positional per tier and a length mismatch is refused server-side, so
179
+ // neither stack derives it for itself.
180
+ export {
181
+ MAX_ATTENDEE_NAME_LENGTH,
182
+ normalizeAttendeeName,
183
+ collectsAttendees,
184
+ buyerFullName,
185
+ buildAttendeeSlots,
186
+ setAttendeeName,
187
+ validateAttendeeNames,
188
+ attendeesPayload,
189
+ type AttendeeNames,
190
+ type AttendeeSlot,
191
+ type AttendeeCheck,
192
+ type AttendeeProblem,
193
+ } from "./format/attendees";
194
+ // Members-only gates (S.4). The wording and the resolving ACTION are decided
195
+ // purely, in one place, because BOTH rendering stacks draw this — `apps/client`
196
+ // imports these directly, exactly as it does the PWYW arithmetic. A members-only
197
+ // badge that says one thing on an artist's PWA and another on their website is
198
+ // the same bug twice.
199
+ export {
200
+ MEMBERSHIP_GATE_CODE,
201
+ MEMBERSHIP_GATE_LOGIN_PATH,
202
+ MEMBERSHIP_GATE_MEMBERSHIP_PATH,
203
+ parseMembershipGateError,
204
+ isMembershipGateLocked,
205
+ buildMembershipGateNotice,
206
+ joinTierNames,
207
+ membershipSignInHref,
208
+ type MembershipGateNotice as MembershipGateNoticeModel,
209
+ type MembershipGateRefusal,
210
+ type MembershipGateReason,
211
+ type PublicMembershipGate,
212
+ type BuildMembershipGateNoticeInput,
213
+ } from "./format/membershipGate";
214
+ export { MembershipGateNotice, type MembershipGateNoticeProps } from "./styled/MembershipGateNotice";
173
215
  export { usePricesIncludeTax } from "../data/queries/useWebsite";
174
216
 
175
217
  // PWA (installable code sites) — SW registration + install/push UX (Tier 2).
@@ -4,7 +4,8 @@ import {
4
4
  useUserOrders,
5
5
  useMyTickets,
6
6
  useCancelMyTicket,
7
- myTicketPassIds,
7
+ myTicketHeldPassIds,
8
+ myTicketPasses,
8
9
  type MyTicket,
9
10
  useMyWaitlistPlaces,
10
11
  useLeaveEventWaitlist,
@@ -34,6 +35,7 @@ import { useSiteConfig } from "../../data/queries/useWebsite";
34
35
  import { useAmountFormatter } from "../format/useFormatCurrency";
35
36
  import { Loading } from "./Loading";
36
37
  import { formatCountdown } from "./EventWaitlist";
38
+ import { AddToCalendar } from "./AddToCalendar";
37
39
  import { WalletPassButtons } from "./WalletPassButtons";
38
40
  import { TicketTransferPanel } from "./TicketTransfer";
39
41
  import { CartLineOptions } from "./CartLineOptions";
@@ -340,6 +342,14 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
340
342
  * come from the server, which reads the snapshot on the order; mirroring the
341
343
  * date arithmetic in the UI would give two answers to the same question and the
342
344
  * wrong one would be the one the buyer sees.
345
+ *
346
+ * ## Two kinds of row
347
+ *
348
+ * `role` says whether the caller PAID for this order or merely HOLDS a pass out
349
+ * of it — a ticket somebody transferred to them, which until now reached them
350
+ * nowhere at all. A holder's row is scrubbed server-side (no totals, no line
351
+ * prices, no other seats, no cancel), and this renders that difference rather
352
+ * than printing "$0.00" over somebody else's purchase.
343
353
  */
344
354
  function TicketsTab({ ctx }: { ctx: TabContext }) {
345
355
  const { user } = usePublicAuth();
@@ -381,6 +391,8 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
381
391
  <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
382
392
  {tickets.map((ticket) => {
383
393
  const cancelled = !!ticket.selfCancelledAt;
394
+ // Absent on an older API build, where every row WAS a purchase.
395
+ const gifted = ticket.role === "holder";
384
396
  return (
385
397
  <div
386
398
  key={ticket.id}
@@ -392,6 +404,12 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
392
404
  <div style={{ fontSize: 13, opacity: 0.7 }}>{formatDate(ticket.eventDateTime)}</div>
393
405
  </div>
394
406
  {cancelled && <span style={{ fontSize: 13, color: t.primary }}>Cancelled</span>}
407
+ {/* Says WHY this is in the list at all. Without it a
408
+ transferred ticket looks like a purchase the buyer cannot
409
+ remember making, with no prices on it. */}
410
+ {!cancelled && gifted && (
411
+ <span style={{ fontSize: 13, color: t.primary }}>Sent to you</span>
412
+ )}
395
413
  </div>
396
414
 
397
415
  {ticket.items.map((item) => (
@@ -399,14 +417,34 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
399
417
  <span>
400
418
  {item.ticketTitle ?? "Ticket"} × {item.quantity}
401
419
  </span>
402
- <span>{formatAmount(Number(item.price) * item.quantity, ticket.currency || currency)}</span>
420
+ {/* A holder is not shown a price, because the server did
421
+ not send one — `Number(null)` is 0, and a confident
422
+ "$0.00" on somebody else's purchase is a lie. */}
423
+ {!gifted && (
424
+ <span>{formatAmount(Number(item.price) * item.quantity, ticket.currency || currency)}</span>
425
+ )}
403
426
  </div>
404
427
  ))}
405
428
 
406
- <div style={{ display: "flex", justifyContent: "space-between", fontWeight: 700, marginTop: 12, paddingTop: 12, borderTop: `1px solid ${t.primary}20` }}>
407
- <span>Total</span>
408
- <span style={{ color: t.primary }}>{formatAmount(Number(ticket.totalAmount), ticket.currency || currency)}</span>
409
- </div>
429
+ {!gifted && (
430
+ <div style={{ display: "flex", justifyContent: "space-between", fontWeight: 700, marginTop: 12, paddingTop: 12, borderTop: `1px solid ${t.primary}20` }}>
431
+ <span>Total</span>
432
+ <span style={{ color: t.primary }}>{formatAmount(Number(ticket.totalAmount), ticket.currency || currency)}</span>
433
+ </div>
434
+ )}
435
+
436
+ {/* Diarise it — including the Apple option, which needs the
437
+ platform's own `.ics` and so could not exist before. Null
438
+ for a called-off show, and the component draws nothing. */}
439
+ <AddToCalendar
440
+ variant="compact"
441
+ title={ticket.eventTitle}
442
+ start={ticket.eventDateTime}
443
+ end={ticket.eventEndDateTime}
444
+ icsUrl={ticket.calendarUrl}
445
+ webcalUrl={ticket.calendarWebcalUrl}
446
+ style={{ marginTop: 12 }}
447
+ />
410
448
 
411
449
  {/*
412
450
  Wallet passes (2.3). Renders NOTHING unless the API offers a
@@ -419,7 +457,7 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
419
457
  Eligibility (cancelled order, rotating QR) is the server's
420
458
  call, exactly as it is for cancellation above.
421
459
  */}
422
- <WalletPassButtons passIds={myTicketPassIds(ticket)} variant="compact" style={{ marginTop: 12 }} />
460
+ <WalletPassButtons passIds={myTicketHeldPassIds(ticket)} variant="compact" style={{ marginTop: 12 }} />
423
461
 
424
462
  {/*
425
463
  Transfer (2.2). The email a transfer sends points at
@@ -432,8 +470,19 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
432
470
  404. Whether THIS pass may be handed on is the server's call,
433
471
  answered on submit in its own words — not hidden behind a
434
472
  guess here.
473
+
474
+ HELD passes only: the endpoints resolve the current holder, so
475
+ a control on a named guest's seat or on a ticket already
476
+ claimed by somebody else could answer nothing but 404. The
477
+ pass objects go through too, because each carries the caller's
478
+ own `pendingTransfer` — which is what lets a sender withdraw
479
+ from a device that is not the one they sent from.
435
480
  */}
436
- <TicketTransferPanel passIds={myTicketPassIds(ticket)} style={{ marginTop: 12 }} />
481
+ <TicketTransferPanel
482
+ passIds={myTicketHeldPassIds(ticket)}
483
+ passes={myTicketPasses(ticket)}
484
+ style={{ marginTop: 12 }}
485
+ />
437
486
 
438
487
  {/* The terms they agreed to, in the artist's own words where given. */}
439
488
  {ticket.cancellation.description && (
@@ -735,6 +784,19 @@ function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
735
784
  </div>
736
785
  </div>
737
786
 
787
+ {/* Diarise it — including Apple, which needs the platform's own `.ics`.
788
+ Null unless the session is confirmed, and the component draws nothing. */}
789
+ <AddToCalendar
790
+ variant="compact"
791
+ title={booking.coachingProductTitle}
792
+ start={booking.sessionStartTime}
793
+ end={booking.sessionEndTime}
794
+ defaultDurationMinutes={booking.coachingProductDurationMinutes ?? undefined}
795
+ icsUrl={booking.calendarUrl}
796
+ webcalUrl={booking.calendarWebcalUrl}
797
+ style={{ marginTop: 12 }}
798
+ />
799
+
738
800
  {/* The terms they booked under, in the artist's own words where given. */}
739
801
  {booking.cancellation.description && (
740
802
  <div style={{ fontSize: 13, opacity: 0.75, marginTop: 12 }}>{booking.cancellation.description}</div>
@@ -17,9 +17,30 @@ export interface AddToCalendarProps extends AddToCalendarInput {
17
17
  }
18
18
 
19
19
  /**
20
- * "Add to calendar" — deep links that prefill Google / Outlook / Microsoft 365
21
- * with the event, plus an `.ics` download when the host supplies one (that is
22
- * the Apple Calendar route; there is no Apple web endpoint to link to).
20
+ * "Add to calendar" — deep links that prefill Google / Outlook / Microsoft 365,
21
+ * plus the two Apple-shaped options once the host supplies the platform's
22
+ * `.ics` address.
23
+ *
24
+ * ## Why Apple needs two entries and the others need none
25
+ *
26
+ * Google and Microsoft publish TEMPLATE URLs: the whole event travels in the
27
+ * query string and no server is involved. Apple publishes nothing of the kind —
28
+ * Calendar takes a FILE. So for as long as this component had no `.ics` to
29
+ * point at, an iPhone user had no option at all, which is precisely the state
30
+ * the product shipped in.
31
+ *
32
+ * - **Apple Calendar** → `webcal://`. The scheme is what iOS and macOS route
33
+ * to the Calendar app; it must be a plain same-window navigation, so it
34
+ * carries neither `target="_blank"` (the OS handler, not a tab) nor
35
+ * `download` (there is no file to save — the OS is opening an app).
36
+ * - **Download .ics** → the https address, with `download`. Outlook desktop,
37
+ * Thunderbird, Android calendar apps and anyone who would simply rather have
38
+ * the file.
39
+ *
40
+ * Both are offered because `webcal:` has no handler on much of the desktop web,
41
+ * and a lone Apple button on Windows is a dead link. Neither appears at all
42
+ * when the host passes no URL — a site pinned to an older API build degrades to
43
+ * exactly the three-provider row it had before.
23
44
  *
24
45
  * Purely presentational: every URL comes from `useAddToCalendar`. Renders
25
46
  * nothing at all when the start time is missing or unparseable.
@@ -38,12 +59,14 @@ export function AddToCalendar({
38
59
 
39
60
  const compact = variant === "compact";
40
61
 
41
- const options: { key: string; text: string; href: string; download?: boolean }[] = [
42
- { key: "google", text: "Google", href: links.google },
43
- { key: "outlook", text: "Outlook", href: links.outlook },
44
- { key: "office365", text: "Microsoft 365", href: links.office365 },
62
+ const options: { key: string; text: string; href: string; download?: boolean; newTab?: boolean }[] = [
63
+ { key: "google", text: "Google", href: links.google, newTab: true },
64
+ { key: "outlook", text: "Outlook", href: links.outlook, newTab: true },
65
+ { key: "office365", text: "Microsoft 365", href: links.office365, newTab: true },
45
66
  ];
46
- if (links.ics) options.push({ key: "ics", text: "Apple / .ics", href: links.ics, download: true });
67
+ // Apple first among the file-based options: it is the one a phone will use.
68
+ if (links.webcal) options.push({ key: "apple", text: "Apple Calendar", href: links.webcal });
69
+ if (links.ics) options.push({ key: "ics", text: "Download .ics", href: links.ics, download: true });
47
70
 
48
71
  const chip: React.CSSProperties = {
49
72
  display: "inline-flex",
@@ -89,8 +112,9 @@ export function AddToCalendar({
89
112
  <a
90
113
  key={o.key}
91
114
  href={o.href}
92
- target="_blank"
93
- rel="noopener noreferrer"
115
+ // `webcal:` hands off to the OS — a new tab would leave a blank one
116
+ // behind, and `rel` is meaningless for a non-http scheme.
117
+ {...(o.newTab ? { target: "_blank", rel: "noopener noreferrer" } : {})}
94
118
  data-testid={`add-to-calendar-${o.key}`}
95
119
  {...(o.download ? { download: "" } : {})}
96
120
  style={chip}
@@ -12,6 +12,7 @@ import { usePricesIncludeTax } from "../../data/queries/useWebsite";
12
12
  import { usePaymentRenderer } from "../payment/ForgePaymentProvider";
13
13
  import { Loading } from "./Loading";
14
14
  import { CartLineOptions } from "./CartLineOptions";
15
+ import { HoldNotice } from "./HoldNotice";
15
16
 
16
17
  export interface CheckoutProps {
17
18
  /** Path used to build the post-payment return URL. Defaults to `/checkout/finalise`. */
@@ -814,6 +815,7 @@ function PaymentSection({
814
815
  const { formatCurrency } = useFormatCurrency();
815
816
  const renderPay = usePaymentRenderer();
816
817
  const { paymentSecret, chargedAmount, chargedCurrency, returnUrl, isPaystack, isCreatingOrder, startError } = checkout;
818
+ const { hold, holdExpiredError, isHoldExpired, retryHold, isRetryingHold } = checkout;
817
819
 
818
820
  // Paystack has no in-app form — send the buyer to the hosted checkout as soon
819
821
  // as the redirect URL (paymentSecret) is ready. The button below is a manual
@@ -880,7 +882,22 @@ function PaymentSection({
880
882
  </p>
881
883
  )}
882
884
 
883
- {isCreatingOrder && !paymentSecret && <Loading label="Preparing payment…" size={22} />}
885
+ {/* The reservation a countdown while it runs, and at zero (or on a 409)
886
+ the lapsed state with the retry that re-acquires the same units.
887
+ Renders nothing at all when no hold was taken, which is the ordinary
888
+ case for the profiles that have inventory holds switched off. */}
889
+ <HoldNotice
890
+ hold={hold}
891
+ expiredMessage={holdExpiredError || undefined}
892
+ onRetry={retryHold}
893
+ isRetrying={isRetryingHold}
894
+ secondaryAction={onBack ? { label: "Change your order", onClick: onBack } : undefined}
895
+ />
896
+
897
+ {/* Suppressed once the server has refused: there is no intent coming, so a
898
+ "Preparing payment…" spinner would sit there forever under a notice
899
+ that has already explained what happened. */}
900
+ {isCreatingOrder && !paymentSecret && !isHoldExpired && <Loading label="Preparing payment…" size={22} />}
884
901
 
885
902
  {isPaystack
886
903
  ? paymentSecret &&
@@ -173,6 +173,10 @@ export function CoachingConfirmation({
173
173
  start={data.sessionStartTime}
174
174
  end={data.sessionEndTime}
175
175
  defaultDurationMinutes={data.durationMinutes ?? undefined}
176
+ // The finalise response carries these only for a CONFIRMED
177
+ // booking, which is the same condition this block renders under.
178
+ icsUrl={data.calendarUrl}
179
+ webcalUrl={data.calendarWebcalUrl}
176
180
  style={{ marginTop: 18 }}
177
181
  />
178
182
  )}
@@ -1,5 +1,6 @@
1
1
  import type { PublicCourse } from "../../types/models";
2
2
  import { useGetCourse } from "../../data/queries/useCourses";
3
+ import { MembershipGateNotice } from "./MembershipGateNotice";
3
4
  import { useThemeTokens } from "../theme/ForgeThemeProvider";
4
5
  import { useAmountFormatter } from "../format/useFormatCurrency";
5
6
  import { PriceDisplay } from "../format/PriceDisplay";
@@ -23,6 +24,10 @@ export interface CourseDetailProps {
23
24
  finalisePath?: (slug: string, bookingId: string) => string;
24
25
  /** Host-owned navigation on FREE completion (forwarded to the enroll flow). */
25
26
  onComplete?: (info: { slug: string; bookingId: string }) => void;
27
+ /** Where a members-only course sends a signed-OUT visitor. Default `/login`. */
28
+ loginPath?: string;
29
+ /** Where a members-only course sends a signed-in NON-member. Default `/membership`. */
30
+ membershipPath?: string;
26
31
  }
27
32
 
28
33
  export function CourseDetail({
@@ -33,6 +38,8 @@ export function CourseDetail({
33
38
  initialCourse,
34
39
  finalisePath,
35
40
  onComplete,
41
+ loginPath,
42
+ membershipPath,
36
43
  }: CourseDetailProps) {
37
44
  const t = useThemeTokens();
38
45
  const fmt = useAmountFormatter(formatAmount);
@@ -42,6 +49,11 @@ export function CourseDetail({
42
49
  if (isLoading) return <Loading fullPage />;
43
50
  if (!course) return <p>Course not found.</p>;
44
51
 
52
+ /**
53
+ * Members-only course (S.4). Announced by the detail read, which is why the
54
+ * page can say so BEFORE the buyer opens a checkout that would refuse them.
55
+ */
56
+ const isGated = !!course.membershipGate && !course.membershipGate.allowed;
45
57
  const cover = course.media?.find((m) => m.type === "image")?.url ?? course.media?.[0]?.url;
46
58
  const introVideo = course.media?.find((m) => m.type === "video")?.url;
47
59
  const modules = course.modules ?? [];
@@ -129,7 +141,24 @@ export function CourseDetail({
129
141
  <PriceDisplay amount={Number(course.price)} pricesIncludeTax={pricesIncludeTax} formatAmount={fmt} mutedColor={t.text} captionStyle={{ opacity: 0.65 }} />
130
142
  )}
131
143
  </span>
132
- <CourseCheckout slug={slug} formatAmount={fmt} finalisePath={finalisePath} onComplete={onComplete} />
144
+ {/*
145
+ A live gate locks the course even for someone who already bought it —
146
+ "this course is part of Gold" is a CONDITION for access, not a
147
+ one-off unlock — so a lapsed member lands here too and needs the way
148
+ back in, not a purchase button that will be refused.
149
+ */}
150
+ {isGated ? (
151
+ <MembershipGateNotice
152
+ gate={course.membershipGate}
153
+ variant="card"
154
+ itemLabel="This course"
155
+ loginPath={loginPath}
156
+ membershipPath={membershipPath}
157
+ style={{ padding: 0, border: "none", background: "transparent" }}
158
+ />
159
+ ) : (
160
+ <CourseCheckout slug={slug} formatAmount={fmt} finalisePath={finalisePath} onComplete={onComplete} />
161
+ )}
133
162
  </aside>
134
163
  </div>
135
164
 
@@ -148,6 +148,8 @@ export function EventConfirmation({
148
148
  end={event.endDateTime}
149
149
  description={event.description}
150
150
  location={location}
151
+ icsUrl={event.calendarUrl}
152
+ webcalUrl={event.calendarWebcalUrl}
151
153
  style={{ marginTop: 12 }}
152
154
  />
153
155
  )}
@@ -1,6 +1,6 @@
1
1
  import { Calendar, MapPin } from "lucide-react";
2
2
  import type { IEvent } from "../../types/models";
3
- import { useEvent } from "../../data/queries/useEvents";
3
+ import { usePresaleCode } from "../headless/event/usePresaleCode";
4
4
  import { useThemeTokens } from "../theme/ForgeThemeProvider";
5
5
  import { useAmountFormatter } from "../format/useFormatCurrency";
6
6
  import { PriceDisplay } from "../format/PriceDisplay";
@@ -11,6 +11,7 @@ import { EventTickets } from "./EventTickets";
11
11
  import { EventCountdown } from "./EventCountdown";
12
12
  import { EventWaitlist } from "./EventWaitlist";
13
13
  import { AddToCalendar } from "./AddToCalendar";
14
+ import { PresaleCodeField } from "./PresaleCode";
14
15
 
15
16
  export interface EventDetailProps {
16
17
  /** Event slug (resolves the event). */
@@ -59,7 +60,11 @@ export function EventDetail({
59
60
  const t = useThemeTokens();
60
61
  const fmt = useAmountFormatter(formatAmount);
61
62
  const pricesIncludeTax = usePricesIncludeTax();
62
- const { data: event, isLoading } = useEvent(slug, { initialData: initialEvent });
63
+ // Read the event THROUGH the presale field: it is the same query, fetched
64
+ // with whatever code this buyer has applied, so `tickets` below already
65
+ // contains the tiers that code unlocked.
66
+ const presale = usePresaleCode(slug, { initialEvent });
67
+ const { event, isLoading } = presale;
63
68
 
64
69
  if (isLoading) return <Loading fullPage />;
65
70
  if (!event) return <p>Event not found.</p>;
@@ -116,6 +121,11 @@ export function EventDetail({
116
121
  end={event.endDateTime}
117
122
  description={event.description}
118
123
  location={locationText}
124
+ // The platform's own `.ics`, which is what makes an Apple option
125
+ // possible at all. Absent on an older API build, in which case
126
+ // the control quietly falls back to the three template providers.
127
+ icsUrl={event.calendarUrl}
128
+ webcalUrl={event.calendarWebcalUrl}
119
129
  style={{ marginTop: 16 }}
120
130
  />
121
131
  )}
@@ -133,7 +143,12 @@ export function EventDetail({
133
143
  <EventWaitlist event={event} style={{ marginTop: 24 }} />
134
144
  </div>
135
145
 
136
- {event.tickets.length > 0 && (
146
+ {/* The card also appears for an event with NO visible tickets when a
147
+ presale is the only thing on sale (1.2). Every tier hidden behind a
148
+ code makes `tickets` empty, so gating the card on the tier list alone
149
+ left a presale-only event with no box, no button and nothing to do —
150
+ which is the shape most presales are announced in. */}
151
+ {(event.tickets.length > 0 || presale.visible) && (
137
152
  <aside
138
153
  className="fixed bottom-0 left-0 right-0 z-40 w-full md:sticky md:top-4 md:bottom-auto md:left-auto md:right-auto md:w-[300px] md:shrink-0 flex flex-col gap-3"
139
154
  style={{
@@ -143,27 +158,43 @@ export function EventDetail({
143
158
  background: t.surface,
144
159
  }}
145
160
  >
146
- <span style={{ fontWeight: 600 }}>
147
- From{" "}
148
- <PriceDisplay amount={minPrice} pricesIncludeTax={pricesIncludeTax} formatAmount={fmt} mutedColor={t.text} captionStyle={{ opacity: 0.65 }} inlineCaption />
149
- </span>
150
- {/* The cheapest tier is the number a buyer anchors on, and until now
151
- it was the whole story they were given — the artist's booking fee
152
- was added at checkout and named nowhere. There is no selection
153
- here, so there is no amount to quote; the RULE is what makes the
154
- "From" figure honest. Renders nothing when no fee is charged. */}
155
- {feeNote && (
156
- <span data-testid="booking-fee-note" style={{ fontSize: 12, opacity: 0.65 }}>
157
- {feeNote}
161
+ {/* Above the price, because on a presale-only event it is the only
162
+ way to reach one. Renders nothing on an event with no coded
163
+ tier. */}
164
+ <PresaleCodeField presale={presale} />
165
+ {event.tickets.length === 0 && (
166
+ <span style={{ fontSize: 13, opacity: 0.7 }}>
167
+ Tickets for this event are presale only.
158
168
  </span>
159
169
  )}
160
- <EventTickets
161
- slug={slug}
162
- triggerText="Buy tickets"
163
- formatAmount={fmt}
164
- finalisePath={finalisePath}
165
- onComplete={onComplete}
166
- />
170
+ {event.tickets.length > 0 && (
171
+ <>
172
+ <span style={{ fontWeight: 600 }}>
173
+ From{" "}
174
+ <PriceDisplay amount={minPrice} pricesIncludeTax={pricesIncludeTax} formatAmount={fmt} mutedColor={t.text} captionStyle={{ opacity: 0.65 }} inlineCaption />
175
+ </span>
176
+ {/* The cheapest tier is the number a buyer anchors on, and until
177
+ now it was the whole story they were given — the artist's
178
+ booking fee was added at checkout and named nowhere. There is
179
+ no selection here, so there is no amount to quote; the RULE is
180
+ what makes the "From" figure honest. Renders nothing when no
181
+ fee is charged, and nothing at all with no price to qualify. */}
182
+ {feeNote && (
183
+ <span data-testid="booking-fee-note" style={{ fontSize: 12, opacity: 0.65 }}>
184
+ {feeNote}
185
+ </span>
186
+ )}
187
+ </>
188
+ )}
189
+ {event.tickets.length > 0 && (
190
+ <EventTickets
191
+ slug={slug}
192
+ triggerText="Buy tickets"
193
+ formatAmount={fmt}
194
+ finalisePath={finalisePath}
195
+ onComplete={onComplete}
196
+ />
197
+ )}
167
198
  </aside>
168
199
  )}
169
200
  </div>