@tribe-nest/forge 3.21.0 → 3.23.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 (97) hide show
  1. package/package.json +2 -1
  2. package/src/data/queries/_tests/eventWaitlist.spec.ts +38 -0
  3. package/src/data/queries/_tests/passTransfers.spec.ts +100 -4
  4. package/src/data/queries/useAuthActions.ts +1 -1
  5. package/src/data/queries/useCheckouts.ts +5 -0
  6. package/src/data/queries/useEventWaitlist.ts +123 -5
  7. package/src/data/queries/useEvents.ts +82 -4
  8. package/src/data/queries/useMembership.ts +8 -2
  9. package/src/data/queries/useMyBookings.ts +37 -0
  10. package/src/data/queries/useMyTickets.ts +112 -0
  11. package/src/data/queries/useOrders.ts +10 -0
  12. package/src/data/queries/usePassTransfers.ts +73 -13
  13. package/src/data/queries/usePaymentFlow.ts +84 -18
  14. package/src/data/queries/useShipping.ts +5 -0
  15. package/src/data/queries/useSubscriptions.ts +50 -4
  16. package/src/index.ts +5 -0
  17. package/src/server/index.ts +52 -0
  18. package/src/server/platform.ts +1 -1
  19. package/src/server/platformEvents.generated.ts +33 -33
  20. package/src/server/pwa.ts +2 -2
  21. package/src/types/models.ts +203 -5
  22. package/src/types/paystack-inline.d.ts +45 -0
  23. package/src/ui/format/_tests/attendees.spec.ts +231 -0
  24. package/src/ui/format/_tests/membershipGate.spec.ts +220 -0
  25. package/src/ui/format/_tests/ticketAvailability.spec.ts +283 -0
  26. package/src/ui/format/attendees.ts +187 -0
  27. package/src/ui/format/membershipGate.ts +209 -0
  28. package/src/ui/format/ticketAvailability.ts +226 -0
  29. package/src/ui/headless/calendar/_tests/useAddToCalendar.spec.ts +83 -0
  30. package/src/ui/headless/calendar/useAddToCalendar.ts +46 -5
  31. package/src/ui/headless/checkout/_tests/inventoryHold.spec.ts +111 -0
  32. package/src/ui/headless/checkout/bundleCoupon.ts +14 -6
  33. package/src/ui/headless/checkout/inventoryHold.ts +83 -0
  34. package/src/ui/headless/checkout/useCheckout.ts +144 -8
  35. package/src/ui/headless/checkout/useInventoryHold.ts +104 -0
  36. package/src/ui/headless/coaching/useCoachingBooking.ts +4 -0
  37. package/src/ui/headless/course/useCourseCheckout.ts +4 -0
  38. package/src/ui/headless/donation/Donation.tsx +10 -0
  39. package/src/ui/headless/donation/DonationContext.tsx +27 -1
  40. package/src/ui/headless/event/useEventCheckout.ts +203 -2
  41. package/src/ui/headless/event/usePresaleCode.ts +181 -0
  42. package/src/ui/headless/index.ts +25 -0
  43. package/src/ui/headless/invoice/useInvoicePayment.ts +7 -2
  44. package/src/ui/headless/membership/useMembershipCheckout.ts +63 -2
  45. package/src/ui/headless/membership/useMembershipGateNotice.ts +83 -0
  46. package/src/ui/headless/offer/Offer.tsx +19 -1
  47. package/src/ui/headless/offer/OfferContext.tsx +69 -1
  48. package/src/ui/headless/paymentLink/usePaymentLinkPayment.ts +7 -1
  49. package/src/ui/headless/work/useWorkPortal.ts +3 -3
  50. package/src/ui/index.ts +64 -0
  51. package/src/ui/shell/diagnosticsGating.ts +3 -3
  52. package/src/ui/styled/AccountDashboard.tsx +143 -12
  53. package/src/ui/styled/AddToCalendar.tsx +34 -10
  54. package/src/ui/styled/AudioPlayer.tsx +1 -1
  55. package/src/ui/styled/BundleConfirmation.tsx +1 -1
  56. package/src/ui/styled/Checkout.tsx +42 -32
  57. package/src/ui/styled/CheckoutConfirmation.tsx +2 -2
  58. package/src/ui/styled/CoachingBooking.tsx +11 -2
  59. package/src/ui/styled/CoachingConfirmation.tsx +7 -3
  60. package/src/ui/styled/CohortPage.tsx +1 -1
  61. package/src/ui/styled/ContactForm.tsx +1 -1
  62. package/src/ui/styled/CourseCheckout.tsx +10 -1
  63. package/src/ui/styled/CourseConfirmation.tsx +2 -2
  64. package/src/ui/styled/CourseDetail.tsx +30 -1
  65. package/src/ui/styled/DiscountCode.tsx +1 -1
  66. package/src/ui/styled/EmailListForm.tsx +1 -1
  67. package/src/ui/styled/EventConfirmation.tsx +3 -1
  68. package/src/ui/styled/EventDetail.tsx +53 -22
  69. package/src/ui/styled/EventSeriesDetail.tsx +1 -1
  70. package/src/ui/styled/EventTickets.tsx +284 -19
  71. package/src/ui/styled/EventWaitlist.tsx +23 -3
  72. package/src/ui/styled/HoldNotice.tsx +192 -0
  73. package/src/ui/styled/InvoicePayment.tsx +14 -5
  74. package/src/ui/styled/MembershipCheckout.tsx +20 -11
  75. package/src/ui/styled/MembershipGateNotice.tsx +159 -0
  76. package/src/ui/styled/OfferButton.tsx +24 -1
  77. package/src/ui/styled/PaymentLinkPayment.tsx +12 -4
  78. package/src/ui/styled/PaystackPayButton.tsx +66 -0
  79. package/src/ui/styled/PresaleCode.tsx +174 -0
  80. package/src/ui/styled/ProductDetail.tsx +75 -5
  81. package/src/ui/styled/ProductGrid.tsx +26 -0
  82. package/src/ui/styled/ReviewForm.tsx +2 -2
  83. package/src/ui/styled/TicketTransfer.tsx +71 -42
  84. package/src/ui/styled/_tests/AddToCalendar.spec.tsx +88 -0
  85. package/src/ui/styled/_tests/EventConfirmation.spec.tsx +5 -1
  86. package/src/ui/styled/_tests/PresaleCode.spec.tsx +106 -0
  87. package/src/ui/styled/community/CommunityFeed.tsx +1 -1
  88. package/src/ui/styled/community/CommunityPostDetail.tsx +1 -1
  89. package/src/utils/_tests/paystackCheckout.spec.ts +266 -0
  90. package/src/utils/_tests/paystackCheckoutBlocked.spec.ts +51 -0
  91. package/src/utils/_tests/presaleCode.spec.ts +168 -0
  92. package/src/utils/_tests/structuredData.spec.ts +275 -0
  93. package/src/utils/membershipAccess.ts +3 -3
  94. package/src/utils/paystackCheckout.ts +277 -0
  95. package/src/utils/presaleCode.ts +96 -0
  96. package/src/utils/structuredData.ts +361 -27
  97. package/src/utils/ticketOrderOutcome.ts +1 -1
@@ -0,0 +1,83 @@
1
+ import { useMemo } from "react";
2
+ import type { PublicMembershipGate } from "../../../types/models";
3
+ import { useGetMembershipTiers } from "../../../data/queries/useMembership";
4
+ import {
5
+ buildMembershipGateNotice,
6
+ isMembershipGateLocked,
7
+ type MembershipGateNotice,
8
+ type MembershipGateRefusal,
9
+ } from "../../format/membershipGate";
10
+
11
+ export interface UseMembershipGateNoticeInput {
12
+ /** The gate announced by the read (`ticket.membershipGate`, `product.membershipGate`, …). */
13
+ gate?: PublicMembershipGate | null;
14
+ /** A `MEMBERSHIP_GATE` refusal from a failed purchase. Wins over `gate`. */
15
+ refusal?: MembershipGateRefusal | null;
16
+ /** What the thing is, in the buyer's words — "This ticket", "This course". */
17
+ itemLabel?: string;
18
+ /** Where a signed-out visitor signs in. Code sites mount theirs under `/i`. */
19
+ loginPath?: string;
20
+ /** Where a signed-in non-member goes to join — the tier listing. */
21
+ membershipPath?: string;
22
+ }
23
+
24
+ /**
25
+ * The members-only notice for one gated thing, tier names and all.
26
+ *
27
+ * ## Why the names are fetched here and not sent by the API
28
+ *
29
+ * A gate comes down as `requiredTierIds`, and "you need tier 8f3c-…" is not a
30
+ * sentence anyone can act on. The public tier list is already loaded by every
31
+ * storefront that sells memberships, is cached by React Query under one key per
32
+ * profile, and — unlike an id embedded in a ticket payload — stays correct when
33
+ * the artist renames a tier. So the ids are resolved against it here, once, for
34
+ * every surface.
35
+ *
36
+ * The names are best-effort by design: the query can be in flight, and a gating
37
+ * tier can be one the public list does not carry (an archived or unlisted tier
38
+ * still gates). `buildMembershipGateNotice` therefore degrades to "is for
39
+ * members" rather than showing an id or rendering nothing — a badge with slightly
40
+ * vaguer wording is a working funnel; a blank space is the bug this closes.
41
+ *
42
+ * Returns `null` when nothing is gated, which is also what every surface sees
43
+ * while the gates feature switch is off.
44
+ */
45
+ export function useMembershipGateNotice(input: UseMembershipGateNoticeInput): MembershipGateNotice | null {
46
+ const locked = isMembershipGateLocked(input.gate) || !!input.refusal;
47
+ // Only pulled once something is actually locked — an ungated storefront must
48
+ // not acquire a request it never needed.
49
+ const { data: tiers } = useGetMembershipTiers({ enabled: locked });
50
+
51
+ const requiredTierIds = input.refusal?.requiredTierIds ?? input.gate?.requiredTierIds ?? [];
52
+
53
+ return useMemo(() => {
54
+ if (!locked) return null;
55
+ const byId = new Map((tiers ?? []).map((tier) => [tier.id, tier.name]));
56
+ return buildMembershipGateNotice({
57
+ gate: input.gate,
58
+ refusal: input.refusal,
59
+ tierNames: requiredTierIds.map((id) => byId.get(id) ?? "").filter(Boolean),
60
+ itemLabel: input.itemLabel,
61
+ loginPath: input.loginPath,
62
+ membershipPath: input.membershipPath,
63
+ currentPath: currentPath(),
64
+ });
65
+ // `requiredTierIds` is a fresh array each render; its CONTENTS are the input.
66
+ // eslint-disable-next-line react-hooks/exhaustive-deps
67
+ }, [
68
+ locked,
69
+ tiers,
70
+ requiredTierIds.join(","),
71
+ input.gate?.reason,
72
+ input.refusal?.reason,
73
+ input.itemLabel,
74
+ input.loginPath,
75
+ input.membershipPath,
76
+ ]);
77
+ }
78
+
79
+ /** Where the buyer is standing, so signing in returns them to it. Empty during SSR. */
80
+ function currentPath(): string {
81
+ if (typeof window === "undefined") return "";
82
+ return `${window.location.pathname}${window.location.search}`;
83
+ }
@@ -9,12 +9,19 @@ import {
9
9
  DialogTitle,
10
10
  } from "../dialog";
11
11
  import { OfferProvider, useOfferContext, type OfferProviderProps } from "./OfferContext";
12
+ import type { PaystackCheckoutOutcome } from "../../../utils/paystackCheckout";
12
13
 
13
14
  /** Render-prop payload for `<Offer.PaymentStep>` (redirect-mode payment). */
14
15
  export interface OfferPaymentRenderProps {
16
+ /** STRIPE: the PaymentIntent client secret. Undefined on Paystack. */
15
17
  clientSecret?: string;
16
18
  returnUrl: string;
17
19
  amount: number;
20
+ /** PAYSTACK: render a pay button instead of a card form. */
21
+ isPaystack: boolean;
22
+ canOpenPaystack: boolean;
23
+ /** Opens (or re-opens, after a dismissal) the Paystack modal. */
24
+ openPaystackCheckout: () => Promise<PaystackCheckoutOutcome>;
18
25
  }
19
26
 
20
27
  type OfferComponent = ((props: OfferProviderProps) => React.ReactElement) & {
@@ -82,7 +89,18 @@ Offer.PaymentStep = function OfferPaymentStep({ children }) {
82
89
  const ctx = useOfferContext();
83
90
  if (ctx.step !== "payment") return null;
84
91
  if (typeof children === "function") {
85
- return <>{children({ clientSecret: ctx.clientSecret, returnUrl: ctx.returnUrl, amount: ctx.totalPrice })}</>;
92
+ return (
93
+ <>
94
+ {children({
95
+ clientSecret: ctx.clientSecret,
96
+ returnUrl: ctx.returnUrl,
97
+ amount: ctx.totalPrice,
98
+ isPaystack: ctx.isPaystack,
99
+ canOpenPaystack: ctx.canOpenPaystack,
100
+ openPaystackCheckout: ctx.openPaystackCheckout,
101
+ })}
102
+ </>
103
+ );
86
104
  }
87
105
  return <>{children}</>;
88
106
  };
@@ -3,6 +3,9 @@ import type { IPublicProduct, IPublicProductVariant } from "../../../types/model
3
3
  import { useGetProduct } from "../../../data/queries/useProducts";
4
4
  import { useCreateOrder } from "../../../data/queries/useOrders";
5
5
  import { usePaymentFlow } from "../../../data/queries/usePaymentFlow";
6
+ import { holdExpiredMessage, isHoldExpiredError } from "../checkout/inventoryHold";
7
+ import { useInventoryHold, type InventoryHoldState } from "../checkout/useInventoryHold";
8
+ import type { PaystackCheckoutOutcome } from "../../../utils/paystackCheckout";
6
9
 
7
10
  export type OfferStep = "details" | "payment";
8
11
 
@@ -34,10 +37,26 @@ export interface OfferContextValue {
34
37
 
35
38
  isProcessing: boolean;
36
39
  error: string | null;
37
- /** Payment intent once the order is created (Stripe). */
40
+ /** Payment intent once the order is created (STRIPE only). */
38
41
  clientSecret?: string;
42
+ /** True once the charge started on Paystack rather than Stripe. */
43
+ isPaystack: boolean;
44
+ /** True once there is a Paystack session a pay button can (re-)open. */
45
+ canOpenPaystack: boolean;
46
+ /**
47
+ * Re-open the Paystack modal on the SAME charge. A buyer who closed it has
48
+ * not failed, and the order is still sitting there waiting to be paid.
49
+ */
50
+ openPaystackCheckout: () => Promise<PaystackCheckoutOutcome>;
39
51
  returnUrl: string;
40
52
  continueToPayment: () => Promise<void>;
53
+ /** The stock reservation held while the buyer pays. All-null when none was taken. */
54
+ hold: InventoryHoldState;
55
+ /** The server's 409 `INVENTORY_HOLD_EXPIRED` message — never sold-out. */
56
+ holdExpiredError: string | null;
57
+ /** Re-take the reservation on the same order. */
58
+ retryHold: () => Promise<void>;
59
+ isRetryingHold: boolean;
41
60
  }
42
61
 
43
62
  const OfferContext = createContext<OfferContextValue | null>(null);
@@ -72,6 +91,10 @@ export function OfferProvider({ productId, children, finalisePath }: OfferProvid
72
91
  const [confirmEmail, setConfirmEmail] = useState("");
73
92
  const [error, setError] = useState<string | null>(null);
74
93
  const [returnUrl, setReturnUrl] = useState("");
94
+ const [orderId, setOrderId] = useState<string | null>(null);
95
+ /** The window the ORDER reported, until start-payment restarts it. */
96
+ const [orderHoldExpiresAt, setOrderHoldExpiresAt] = useState<string | null>(null);
97
+ const [holdExpiredError, setHoldExpiredError] = useState<string | null>(null);
75
98
 
76
99
  const selectedVariant = useMemo(
77
100
  () => variants.find((v) => v.id === selectedVariantId) ?? variants[0],
@@ -82,6 +105,7 @@ export function OfferProvider({ productId, children, finalisePath }: OfferProvid
82
105
 
83
106
  const continueToPayment = async () => {
84
107
  setError(null);
108
+ setHoldExpiredError(null);
85
109
  if (!product || !selectedVariant) return;
86
110
  if (!firstName.trim() || !lastName.trim()) {
87
111
  setError("Please enter your name.");
@@ -114,6 +138,10 @@ export function OfferProvider({ productId, children, finalisePath }: OfferProvid
114
138
  });
115
139
  const ru = `${typeof window !== "undefined" ? window.location.origin : ""}${finalisePath ?? "/checkout/finalise"}?orderId=${created.orderId}`;
116
140
  setReturnUrl(ru);
141
+ setOrderId(created.orderId);
142
+ // Absent/null for a digital item, a free order, or a profile with holds
143
+ // switched off — every one of which must render no countdown at all.
144
+ setOrderHoldExpiresAt(created.holdExpiresAt ?? null);
117
145
  // Free checkout — skip payment.
118
146
  if (created.subTotal === 0 && typeof window !== "undefined") {
119
147
  window.location.href = ru;
@@ -124,11 +152,44 @@ export function OfferProvider({ productId, children, finalisePath }: OfferProvid
124
152
  if (result.provider && result.provider !== "stripe") return;
125
153
  setStep("payment");
126
154
  } catch (e) {
155
+ // A lapsed reservation gets its own state so the buyer is offered the
156
+ // retry that re-acquires the same unit, rather than a bare error that
157
+ // reads like the item is gone.
158
+ if (isHoldExpiredError(e)) {
159
+ setHoldExpiredError(
160
+ holdExpiredMessage(e, "Your reservation expired before payment finished. Please try again."),
161
+ );
162
+ return;
163
+ }
127
164
  const message = (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
128
165
  setError(message || "Something went wrong.");
129
166
  }
130
167
  };
131
168
 
169
+ /** Take the same unit again on the same order. */
170
+ const retryHold = async () => {
171
+ if (!orderId || !returnUrl) return;
172
+ setHoldExpiredError(null);
173
+ try {
174
+ const result = await flow.start({ orderId, returnUrl });
175
+ if (result.provider && result.provider !== "stripe") return; // Paystack redirected
176
+ setStep("payment");
177
+ } catch (e) {
178
+ if (isHoldExpiredError(e)) {
179
+ setHoldExpiredError(
180
+ holdExpiredMessage(e, "Your reservation expired before payment finished. Please try again."),
181
+ );
182
+ return;
183
+ }
184
+ const message = (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
185
+ setError(message || "Something went wrong.");
186
+ }
187
+ };
188
+
189
+ // start-payment restarts the clock and reports the newer instant, so it wins
190
+ // over the create-time one.
191
+ const hold = useInventoryHold(flow.result?.holdExpiresAt ?? orderHoldExpiresAt);
192
+
132
193
  const value: OfferContextValue = {
133
194
  product,
134
195
  isLoading,
@@ -154,8 +215,15 @@ export function OfferProvider({ productId, children, finalisePath }: OfferProvid
154
215
  isProcessing: createOrder.isPending || flow.isStarting,
155
216
  error,
156
217
  clientSecret: flow.result?.paymentSecret,
218
+ isPaystack: flow.isPaystack,
219
+ canOpenPaystack: flow.canOpenPaystack,
220
+ openPaystackCheckout: flow.openPaystackCheckout,
157
221
  returnUrl,
158
222
  continueToPayment,
223
+ hold,
224
+ holdExpiredError,
225
+ retryHold,
226
+ isRetryingHold: flow.isStarting,
159
227
  };
160
228
 
161
229
  return <OfferContext.Provider value={value}>{children}</OfferContext.Provider>;
@@ -11,7 +11,9 @@ export interface UsePaymentLinkPaymentOptions {
11
11
  /**
12
12
  * Headless payment-link checkout: loads the link and starts its payment.
13
13
  * Composes `usePaymentLink` + `usePaymentFlow`. Stripe UI renders from
14
- * `clientSecret`; finalize on the return page with `usePaymentLinkFinalize`.
14
+ * `clientSecret`; Paystack opens its inline modal (and `openPaystackCheckout`
15
+ * re-opens it after a dismissal). Finalize on the return page with
16
+ * `usePaymentLinkFinalize`.
15
17
  */
16
18
  export function usePaymentLinkPayment(paymentLinkId?: string, opts: UsePaymentLinkPaymentOptions = {}) {
17
19
  const link = usePaymentLink(paymentLinkId);
@@ -34,6 +36,10 @@ export function usePaymentLinkPayment(paymentLinkId?: string, opts: UsePaymentLi
34
36
  clientSecret: flow.clientSecret,
35
37
  paymentId: flow.paymentId,
36
38
  provider: flow.provider,
39
+ /** Paystack: the started inline-checkout session and the way back into it. */
40
+ isPaystack: flow.isPaystack,
41
+ canOpenPaystack: flow.canOpenPaystack,
42
+ openPaystackCheckout: flow.openPaystackCheckout,
37
43
  /** Authoritative sales-tax quote from start-payment (display only). */
38
44
  taxQuote: flow.result?.taxQuote ?? null,
39
45
  isStarting: flow.isStarting,
@@ -343,10 +343,10 @@ export function formatWorkMoney(amount: number, currency = "USD"): string {
343
343
  return `${symbol}${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
344
344
  }
345
345
 
346
- /** ISO date → short human date, or "" when absent. */
346
+ /** ISO date → short human date, or "Not set" when absent. */
347
347
  export function formatWorkDate(value: string | null | undefined): string {
348
- if (!value) return "";
348
+ if (!value) return "Not set";
349
349
  const d = new Date(value);
350
- if (Number.isNaN(d.getTime())) return "";
350
+ if (Number.isNaN(d.getTime())) return "Not set";
351
351
  return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
352
352
  }
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,9 @@ 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";
115
+ export { PaystackPayButton, type PaystackPayButtonProps } from "./styled/PaystackPayButton";
112
116
  export {
113
117
  ConfirmationStage,
114
118
  ConfirmationCard,
@@ -170,6 +174,66 @@ export {
170
174
  resolveUnitPrice,
171
175
  ticketSubtotals,
172
176
  } from "./format/pwyw";
177
+ // When a tier may be BOUGHT, for a site rendering its own ticket UI. Both halves
178
+ // close a "discoverable only by failing" journey: `ticketSeatsAvailable`
179
+ // subtracts `held` the way the server does, so a fan is never shown seats that
180
+ // are already in someone else's checkout; `ticketSaleWindow` surfaces a
181
+ // scheduled on-sale, which shipped enforced and drawn nowhere. Presentation
182
+ // only — `createOrder` re-checks both.
183
+ export {
184
+ ticketSeatsAvailable,
185
+ ticketSeatsUnsold,
186
+ ticketSeatState,
187
+ ticketSeatLabel,
188
+ ticketSeatNotice,
189
+ ticketSaleWindow,
190
+ isTicketScheduled,
191
+ formatOnSaleAt,
192
+ onSaleBadge,
193
+ onSaleNotice,
194
+ maxAddableTickets,
195
+ type TicketSeatState,
196
+ type TicketSaleWindow,
197
+ } from "./format/ticketAvailability";
198
+ // Per-attendee names at checkout. Exported for a site rendering its own ticket
199
+ // UI — and used by `apps/client`, which has its own event checkout: the request
200
+ // shape is positional per tier and a length mismatch is refused server-side, so
201
+ // neither stack derives it for itself.
202
+ export {
203
+ MAX_ATTENDEE_NAME_LENGTH,
204
+ normalizeAttendeeName,
205
+ collectsAttendees,
206
+ buyerFullName,
207
+ buildAttendeeSlots,
208
+ setAttendeeName,
209
+ validateAttendeeNames,
210
+ attendeesPayload,
211
+ type AttendeeNames,
212
+ type AttendeeSlot,
213
+ type AttendeeCheck,
214
+ type AttendeeProblem,
215
+ } from "./format/attendees";
216
+ // Members-only gates (S.4). The wording and the resolving ACTION are decided
217
+ // purely, in one place, because BOTH rendering stacks draw this — `apps/client`
218
+ // imports these directly, exactly as it does the PWYW arithmetic. A members-only
219
+ // badge that says one thing on an artist's PWA and another on their website is
220
+ // the same bug twice.
221
+ export {
222
+ MEMBERSHIP_GATE_CODE,
223
+ MEMBERSHIP_GATE_LOGIN_PATH,
224
+ MEMBERSHIP_GATE_MEMBERSHIP_PATH,
225
+ parseMembershipGateError,
226
+ isMembershipGateLocked,
227
+ buildMembershipGateNotice,
228
+ joinTierNames,
229
+ membershipSignInHref,
230
+ type MembershipGateNotice as MembershipGateNoticeModel,
231
+ type MembershipGateRefusal,
232
+ type MembershipGateReason,
233
+ type PublicMembershipGate,
234
+ type BuildMembershipGateNoticeInput,
235
+ } from "./format/membershipGate";
236
+ export { MembershipGateNotice, type MembershipGateNoticeProps } from "./styled/MembershipGateNotice";
173
237
  export { usePricesIncludeTax } from "../data/queries/useWebsite";
174
238
 
175
239
  // PWA (installable code sites) — SW registration + install/push UX (Tier 2).
@@ -65,13 +65,13 @@ const WHAT_LABEL: Record<string, string> = {
65
65
 
66
66
  /** Human copy for the banner. Pure — no React, no formatting concerns. */
67
67
  export function previewDiagnosticsMessage(d: ForgeSsrDiagnostics): PreviewDiagnosticsMessage {
68
- const details = d.failures.map((f) => `Couldn't load the ${WHAT_LABEL[f.what] ?? f.what} ${f.error}.`);
68
+ const details = d.failures.map((f) => `Couldn't load the ${WHAT_LABEL[f.what] ?? f.what}: ${f.error}.`);
69
69
 
70
70
  if (d.probe) {
71
71
  details.push(
72
72
  d.probe.ok
73
73
  ? `The API answered ${d.probe.url} in ${d.probe.ms}ms, so this looks like a per-route failure rather than a network one.`
74
- : `Couldn't reach ${d.probe.url} either ${d.probe.error ?? `HTTP ${d.probe.status}`} after ${d.probe.ms}ms. This preview has no route to the API.`,
74
+ : `Couldn't reach ${d.probe.url} either: ${d.probe.error ?? `HTTP ${d.probe.status}`} after ${d.probe.ms}ms. This preview has no route to the API.`,
75
75
  );
76
76
  }
77
77
  if (d.apiUrl) details.push(`API: ${d.apiUrl}`);
@@ -84,7 +84,7 @@ export function previewDiagnosticsMessage(d: ForgeSsrDiagnostics): PreviewDiagno
84
84
  // The sentence that would have saved the debugging session this was built
85
85
  // after: the defaults on screen are a symptom, not the setting.
86
86
  consequence:
87
- "You're seeing Forge's default theme (white background, purple accent) and empty content because the data never arrived not because of how your site is configured. Your published site is unaffected.",
87
+ "You're seeing Forge's default theme (white background, purple accent) and empty content because the data never arrived, not because of how your site is configured. Your published site is unaffected.",
88
88
  details,
89
89
  };
90
90
  }
@@ -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";
@@ -225,6 +227,7 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
225
227
  const { formatAmount, formatDate, currency, onNavigateMembership } = ctx;
226
228
  const cancelMembership = useCancelMembership();
227
229
  const [isCancelling, setIsCancelling] = useState(false);
230
+ const [cancelError, setCancelError] = useState<string | null>(null);
228
231
 
229
232
  const membership = user?.membership;
230
233
  // Two separate questions, deliberately answered separately:
@@ -236,12 +239,20 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
236
239
  const openBillingPortal = useOpenBillingPortal();
237
240
  const [billingError, setBillingError] = useState<string | null>(null);
238
241
 
242
+ // A failure here used to be swallowed whole: the button said "Cancelling…",
243
+ // then said "Cancel" again, and the member was never told anything. That is
244
+ // how a free-tier member — whose cancel the server rejected outright — could
245
+ // press this forever with no effect and no explanation.
239
246
  const onCancel = async () => {
240
247
  if (!membership?.id) return;
248
+ setCancelError(null);
241
249
  setIsCancelling(true);
242
250
  try {
243
251
  await cancelMembership.mutateAsync({ membershipId: membership.id });
244
252
  window.location.reload();
253
+ } catch (error) {
254
+ const detail = (error as { response?: { data?: { message?: string } } })?.response?.data?.message;
255
+ setCancelError(detail || "We couldn't cancel your membership. Please try again or contact us.");
245
256
  } finally {
246
257
  setIsCancelling(false);
247
258
  }
@@ -300,6 +311,11 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
300
311
  </p>
301
312
  )}
302
313
  {billingError && <p style={{ fontSize: 13, opacity: 0.9 }}>{billingError}</p>}
314
+ {cancelError && (
315
+ <p role="alert" style={{ fontSize: 13, opacity: 0.9, color: t.primary }}>
316
+ {cancelError}
317
+ </p>
318
+ )}
303
319
  </div>
304
320
  ) : (
305
321
  <p style={{ opacity: 0.8 }}>You don&apos;t have an active membership.</p>
@@ -313,8 +329,9 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
313
329
  )}
314
330
  {/* Cancelling stays available for anyone who still HAS access — a
315
331
  past_due member must not be trapped in a subscription they can see
316
- but not leave. */}
317
- {access.hasAccess && (
332
+ but not leave. Hidden once it is already scheduled ("Ending"), where
333
+ the only honest label would be "Cancel again". */}
334
+ {access.hasAccess && !access.cancelAtPeriodEnd && (
318
335
  <button onClick={onCancel} disabled={isCancelling} style={ghostButton}>
319
336
  {isCancelling ? "Cancelling…" : "Cancel"}
320
337
  </button>
@@ -340,6 +357,14 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
340
357
  * come from the server, which reads the snapshot on the order; mirroring the
341
358
  * date arithmetic in the UI would give two answers to the same question and the
342
359
  * wrong one would be the one the buyer sees.
360
+ *
361
+ * ## Two kinds of row
362
+ *
363
+ * `role` says whether the caller PAID for this order or merely HOLDS a pass out
364
+ * of it — a ticket somebody transferred to them, which until now reached them
365
+ * nowhere at all. A holder's row is scrubbed server-side (no totals, no line
366
+ * prices, no other seats, no cancel), and this renders that difference rather
367
+ * than printing "$0.00" over somebody else's purchase.
343
368
  */
344
369
  function TicketsTab({ ctx }: { ctx: TabContext }) {
345
370
  const { user } = usePublicAuth();
@@ -381,6 +406,8 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
381
406
  <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
382
407
  {tickets.map((ticket) => {
383
408
  const cancelled = !!ticket.selfCancelledAt;
409
+ // Absent on an older API build, where every row WAS a purchase.
410
+ const gifted = ticket.role === "holder";
384
411
  return (
385
412
  <div
386
413
  key={ticket.id}
@@ -392,6 +419,12 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
392
419
  <div style={{ fontSize: 13, opacity: 0.7 }}>{formatDate(ticket.eventDateTime)}</div>
393
420
  </div>
394
421
  {cancelled && <span style={{ fontSize: 13, color: t.primary }}>Cancelled</span>}
422
+ {/* Says WHY this is in the list at all. Without it a
423
+ transferred ticket looks like a purchase the buyer cannot
424
+ remember making, with no prices on it. */}
425
+ {!cancelled && gifted && (
426
+ <span style={{ fontSize: 13, color: t.primary }}>Sent to you</span>
427
+ )}
395
428
  </div>
396
429
 
397
430
  {ticket.items.map((item) => (
@@ -399,14 +432,34 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
399
432
  <span>
400
433
  {item.ticketTitle ?? "Ticket"} × {item.quantity}
401
434
  </span>
402
- <span>{formatAmount(Number(item.price) * item.quantity, ticket.currency || currency)}</span>
435
+ {/* A holder is not shown a price, because the server did
436
+ not send one — `Number(null)` is 0, and a confident
437
+ "$0.00" on somebody else's purchase is a lie. */}
438
+ {!gifted && (
439
+ <span>{formatAmount(Number(item.price) * item.quantity, ticket.currency || currency)}</span>
440
+ )}
403
441
  </div>
404
442
  ))}
405
443
 
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>
444
+ {!gifted && (
445
+ <div style={{ display: "flex", justifyContent: "space-between", fontWeight: 700, marginTop: 12, paddingTop: 12, borderTop: `1px solid ${t.primary}20` }}>
446
+ <span>Total</span>
447
+ <span style={{ color: t.primary }}>{formatAmount(Number(ticket.totalAmount), ticket.currency || currency)}</span>
448
+ </div>
449
+ )}
450
+
451
+ {/* Diarise it — including the Apple option, which needs the
452
+ platform's own `.ics` and so could not exist before. Null
453
+ for a called-off show, and the component draws nothing. */}
454
+ <AddToCalendar
455
+ variant="compact"
456
+ title={ticket.eventTitle}
457
+ start={ticket.eventDateTime}
458
+ end={ticket.eventEndDateTime}
459
+ icsUrl={ticket.calendarUrl}
460
+ webcalUrl={ticket.calendarWebcalUrl}
461
+ style={{ marginTop: 12 }}
462
+ />
410
463
 
411
464
  {/*
412
465
  Wallet passes (2.3). Renders NOTHING unless the API offers a
@@ -419,7 +472,7 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
419
472
  Eligibility (cancelled order, rotating QR) is the server's
420
473
  call, exactly as it is for cancellation above.
421
474
  */}
422
- <WalletPassButtons passIds={myTicketPassIds(ticket)} variant="compact" style={{ marginTop: 12 }} />
475
+ <WalletPassButtons passIds={myTicketHeldPassIds(ticket)} variant="compact" style={{ marginTop: 12 }} />
423
476
 
424
477
  {/*
425
478
  Transfer (2.2). The email a transfer sends points at
@@ -432,8 +485,19 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
432
485
  404. Whether THIS pass may be handed on is the server's call,
433
486
  answered on submit in its own words — not hidden behind a
434
487
  guess here.
488
+
489
+ HELD passes only: the endpoints resolve the current holder, so
490
+ a control on a named guest's seat or on a ticket already
491
+ claimed by somebody else could answer nothing but 404. The
492
+ pass objects go through too, because each carries the caller's
493
+ own `pendingTransfer` — which is what lets a sender withdraw
494
+ from a device that is not the one they sent from.
435
495
  */}
436
- <TicketTransferPanel passIds={myTicketPassIds(ticket)} style={{ marginTop: 12 }} />
496
+ <TicketTransferPanel
497
+ passIds={myTicketHeldPassIds(ticket)}
498
+ passes={myTicketPasses(ticket)}
499
+ style={{ marginTop: 12 }}
500
+ />
437
501
 
438
502
  {/* The terms they agreed to, in the artist's own words where given. */}
439
503
  {ticket.cancellation.description && (
@@ -576,7 +640,7 @@ function WaitlistPlaceRow({
576
640
  fontSize: 14,
577
641
  }}
578
642
  >
579
- <strong>Buy within {formatCountdown(place.offer.secondsRemaining ?? 0)}</strong> your offer closes{" "}
643
+ <strong>Buy within {formatCountdown(place.offer.secondsRemaining ?? 0)}.</strong> Your offer closes{" "}
580
644
  {deadline.toLocaleString(undefined, {
581
645
  weekday: "short",
582
646
  day: "numeric",
@@ -672,7 +736,60 @@ function formatSessionWindow(booking: MyBooking): string {
672
736
  });
673
737
  const from = start.toLocaleString(undefined, { hour: "numeric", minute: "2-digit", timeZone: zone });
674
738
  const to = end.toLocaleString(undefined, { hour: "numeric", minute: "2-digit", timeZone: zone });
675
- return `${day}, ${from} ${to}${zone ? ` (${zone})` : ""}`;
739
+ return `${day}, ${from} to ${to}${zone ? ` (${zone})` : ""}`;
740
+ }
741
+
742
+ /**
743
+ * WHERE the session is — the line this portal existed without.
744
+ *
745
+ * A client who bought a coaching call could see the time, the price and an
746
+ * add-to-calendar button, and nothing at all about how to attend; the answer
747
+ * arrived, if it arrived, in a separate email from the coach.
748
+ *
749
+ * The two kinds are not interchangeable, so they do not render the same. An
750
+ * online session's link is an anchor — at 9:59 for a 10:00 call it is the only
751
+ * thing on this row anyone wants — shown in full rather than as the word "Join",
752
+ * because the client may be reading this on a phone and about to type it into
753
+ * a laptop. An in-person address is plain text: it is the coach's own words,
754
+ * often "Studio B, ring the buzzer" rather than anything a maps provider could
755
+ * resolve, and linking it would guess at something they did not say.
756
+ *
757
+ * `location` is already resolved server-side (booking override over product
758
+ * default) and is null unless the session is confirmed, so there is no rule to
759
+ * apply here — which is the same discipline `canReschedule` follows two blocks
760
+ * down.
761
+ */
762
+ function BookingLocationLine({
763
+ location,
764
+ primary,
765
+ }: {
766
+ location: MyBooking["location"];
767
+ primary: string;
768
+ }) {
769
+ if (!location) return null;
770
+
771
+ if (location.type === "online") {
772
+ return (
773
+ <div style={{ fontSize: 13, marginTop: 4 }}>
774
+ <span style={{ opacity: 0.7 }}>Join: </span>
775
+ <a
776
+ href={location.url}
777
+ target="_blank"
778
+ rel="noreferrer"
779
+ style={{ color: primary, textDecoration: "underline", wordBreak: "break-all" }}
780
+ >
781
+ {location.url}
782
+ </a>
783
+ </div>
784
+ );
785
+ }
786
+
787
+ return (
788
+ <div style={{ fontSize: 13, opacity: 0.7, marginTop: 4 }}>
789
+ <span style={{ fontWeight: 600 }}>Where: </span>
790
+ {location.address}
791
+ </div>
792
+ );
676
793
  }
677
794
 
678
795
  function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
@@ -724,6 +841,7 @@ function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
724
841
  {booking.coachingProductDurationMinutes != null && (
725
842
  <div style={{ fontSize: 13, opacity: 0.7 }}>{booking.coachingProductDurationMinutes} minutes</div>
726
843
  )}
844
+ <BookingLocationLine location={booking.location} primary={t.primary} />
727
845
  </div>
728
846
  <div style={{ textAlign: "right" }}>
729
847
  {cancelled && <div style={{ fontSize: 13, color: t.primary }}>Cancelled</div>}
@@ -735,6 +853,19 @@ function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
735
853
  </div>
736
854
  </div>
737
855
 
856
+ {/* Diarise it — including Apple, which needs the platform's own `.ics`.
857
+ Null unless the session is confirmed, and the component draws nothing. */}
858
+ <AddToCalendar
859
+ variant="compact"
860
+ title={booking.coachingProductTitle}
861
+ start={booking.sessionStartTime}
862
+ end={booking.sessionEndTime}
863
+ defaultDurationMinutes={booking.coachingProductDurationMinutes ?? undefined}
864
+ icsUrl={booking.calendarUrl}
865
+ webcalUrl={booking.calendarWebcalUrl}
866
+ style={{ marginTop: 12 }}
867
+ />
868
+
738
869
  {/* The terms they booked under, in the artist's own words where given. */}
739
870
  {booking.cancellation.description && (
740
871
  <div style={{ fontSize: 13, opacity: 0.75, marginTop: 12 }}>{booking.cancellation.description}</div>