@tribe-nest/forge 3.53.0 → 3.57.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 (74) hide show
  1. package/package.json +1 -1
  2. package/src/contexts/AppAuthContext.tsx +3 -2
  3. package/src/contexts/AudioPlayerContext.tsx +3 -2
  4. package/src/contexts/CartContext.tsx +3 -2
  5. package/src/contexts/PublicAuthContext.tsx +3 -2
  6. package/src/data/queries/useCoachingProducts.ts +15 -1
  7. package/src/data/queries/useCourses.ts +15 -1
  8. package/src/data/queries/useEvents.ts +26 -1
  9. package/src/data/queries/useMembership.ts +176 -4
  10. package/src/data/queries/useMyTickets.ts +49 -0
  11. package/src/data/queries/usePaymentFlow.ts +8 -0
  12. package/src/data/queries/useProducts.ts +15 -1
  13. package/src/data/queries/useSubscriptions.ts +21 -1
  14. package/src/i18n/de.json +116 -1
  15. package/src/i18n/en.json +116 -1
  16. package/src/i18n/index.ts +3 -2
  17. package/src/index.ts +22 -0
  18. package/src/provider/ForgeAppProvider.tsx +8 -1
  19. package/src/provider/ForgeProvider.tsx +3 -2
  20. package/src/provider/SiteConfigProvider.tsx +3 -2
  21. package/src/runtime/RemotePage.tsx +110 -0
  22. package/src/runtime/hostRuntime.ts +84 -0
  23. package/src/runtime/pages.spec.ts +41 -0
  24. package/src/runtime/pages.ts +183 -0
  25. package/src/runtime/pagesClient.ts +127 -0
  26. package/src/runtime/registry.spec.ts +45 -0
  27. package/src/runtime/registry.ts +102 -0
  28. package/src/server/index.ts +74 -0
  29. package/src/server/jobs.ts +4 -1
  30. package/src/server/platformEvents.generated.ts +152 -0
  31. package/src/types/models.ts +185 -0
  32. package/src/ui/headless/event/_tests/ticketApproval.spec.ts +137 -0
  33. package/src/ui/headless/event/_tests/useEventCheckoutApproval.spec.tsx +250 -0
  34. package/src/ui/headless/event/ticketApproval.ts +109 -0
  35. package/src/ui/headless/event/useEventCheckout.ts +74 -4
  36. package/src/ui/headless/index.ts +34 -0
  37. package/src/ui/headless/membership/_tests/membershipApplication.spec.ts +85 -0
  38. package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +2 -0
  39. package/src/ui/headless/membership/_tests/membershipTrial.spec.ts +61 -0
  40. package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +253 -0
  41. package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +241 -0
  42. package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +165 -0
  43. package/src/ui/headless/membership/membershipApplication.ts +109 -0
  44. package/src/ui/headless/membership/membershipQuestionnaire.ts +44 -0
  45. package/src/ui/headless/membership/membershipTrial.ts +53 -0
  46. package/src/ui/headless/membership/useMembershipCheckout.ts +227 -8
  47. package/src/ui/index.ts +27 -0
  48. package/src/ui/payment/ForgePaymentProvider.tsx +29 -0
  49. package/src/ui/payment/ForgeStripePayment.tsx +110 -5
  50. package/src/ui/payment/_tests/ForgeStripePaymentTrial.spec.tsx +101 -0
  51. package/src/ui/payment/_tests/stripeConfirmOutcome.spec.ts +61 -0
  52. package/src/ui/payment/_tests/stripeIntentKind.spec.ts +43 -0
  53. package/src/ui/payment/stripeConfirmOutcome.ts +50 -0
  54. package/src/ui/payment/stripeIntentKind.ts +32 -0
  55. package/src/ui/shell/TribeNestApp.tsx +6 -0
  56. package/src/ui/styled/AccountDashboard.tsx +239 -15
  57. package/src/ui/styled/DocumentSigningPage.tsx +516 -0
  58. package/src/ui/styled/EventConfirmation.tsx +106 -9
  59. package/src/ui/styled/EventTickets.tsx +107 -13
  60. package/src/ui/styled/LoginForm.tsx +5 -3
  61. package/src/ui/styled/MembershipCheckout.tsx +596 -256
  62. package/src/ui/styled/MembershipTierCallout.tsx +17 -3
  63. package/src/ui/styled/MembershipTiers.tsx +148 -6
  64. package/src/ui/styled/ProductGrid.tsx +14 -2
  65. package/src/ui/styled/ReviewRequestPage.tsx +214 -0
  66. package/src/ui/styled/SignupForm.tsx +4 -2
  67. package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +105 -0
  68. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +106 -0
  69. package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +139 -0
  70. package/src/ui/styled/_tests/membershipTiersTrial.spec.tsx +97 -0
  71. package/src/ui/styled/forge-utilities.css +310 -0
  72. package/src/ui/theme/ForgeThemeProvider.tsx +3 -2
  73. package/src/utils/_tests/ticketOrderOutcome.spec.ts +82 -0
  74. package/src/utils/ticketOrderOutcome.ts +57 -1
@@ -0,0 +1,102 @@
1
+ import { createContext, type Context } from "react";
2
+
3
+ /**
4
+ * The runtime a built creator site shares with the central pages bundle.
5
+ *
6
+ * A site and the hosted `/i/*` pages are two separately built bundles that have
7
+ * to behave as ONE app. If each carried its own React, its own router and its
8
+ * own copies of Forge's contexts, a fan would hold two carts, be logged in on
9
+ * one half of the page and anonymous on the other, and hooks would throw the
10
+ * moment a bundled block tried to read a provider the host had mounted.
11
+ *
12
+ * So the host publishes its runtime on a global object and the bundle reads it
13
+ * rather than carrying its own. First writer wins, always: the site boots
14
+ * first, registers, and the bundle then finds what is already there.
15
+ *
16
+ * That rule is what makes a CONTEXT shareable, which is the part worth
17
+ * understanding. `createContext` called twice produces two unrelated objects,
18
+ * and a provider mounted from one is invisible to a consumer reading the other.
19
+ * `createSharedContext` gives both bundles the same object instead, so the
20
+ * host's `<CartProvider>` is the cart the bundle's `useCart()` reads.
21
+ *
22
+ * ## The contract
23
+ *
24
+ * What lives here is a contract between a site built at one moment and a bundle
25
+ * published later, so it is ADDITIVE FOR EVER. A key, once published under a
26
+ * contract, never changes meaning and is never removed. Adding one is safe:
27
+ * an older bundle simply does not ask for it. Removing or repurposing one is a
28
+ * contract bump (`rc1` to `rc2`), which leaves old sites on the old bundle
29
+ * until their next build.
30
+ *
31
+ * Nothing INSIDE the bundle is covered by this. Forge's UI blocks ship in the
32
+ * bundle precisely so a block fix needs no contract change and no rebuild.
33
+ */
34
+
35
+ /**
36
+ * Names the set of things the registry exposes. It rides in the bundle URL
37
+ * (`forge-pages/<contract>/latest`), so a site always asks for the bundle that
38
+ * matches the runtime it was built with.
39
+ */
40
+ export const PAGES_RUNTIME_CONTRACT = "rc1";
41
+
42
+ /**
43
+ * A single global, not a module-level variable. A module-level one would give
44
+ * each bundle its own copy, which is the exact failure this exists to prevent.
45
+ * The name is deliberately awkward to discourage anyone reading it directly.
46
+ */
47
+ const GLOBAL_KEY = "__tribenestPagesRuntime__";
48
+
49
+ type Registry = Record<string, unknown>;
50
+
51
+ function registry(): Registry {
52
+ const scope = globalThis as unknown as Record<string, Registry | undefined>;
53
+ const existing = scope[GLOBAL_KEY];
54
+ if (existing) return existing;
55
+ const created: Registry = {};
56
+ scope[GLOBAL_KEY] = created;
57
+ return created;
58
+ }
59
+
60
+ /**
61
+ * Publish a value under `key`, or return the one already published.
62
+ *
63
+ * `create` runs only when nobody has claimed the key yet, so the host's React
64
+ * and the host's QueryClient are the ones everybody ends up with, whichever
65
+ * bundle happens to call first. Registration is therefore safe to run more than
66
+ * once and in any order.
67
+ */
68
+ export function shareRuntimeValue<T>(key: string, create: () => T): T {
69
+ const reg = registry();
70
+ if (!(key in reg)) reg[key] = create();
71
+ return reg[key] as T;
72
+ }
73
+
74
+ /** Read a published value, or `undefined` when the host never published it. */
75
+ export function readRuntimeValue<T>(key: string): T | undefined {
76
+ return registry()[key] as T | undefined;
77
+ }
78
+
79
+ /**
80
+ * A React context that is the SAME object in every bundle that asks for it.
81
+ *
82
+ * Use this instead of `createContext` for anything a hosted page consumes.
83
+ * `defaultValue` is only used by the bundle that gets there first; the others
84
+ * receive that same context and its default, which is correct, since a context
85
+ * with two different defaults across bundles would be two contexts again.
86
+ */
87
+ export function createSharedContext<T>(key: string, defaultValue: T): Context<T> {
88
+ return shareRuntimeValue(`context:${key}`, () => createContext<T>(defaultValue));
89
+ }
90
+
91
+ /**
92
+ * Which of `required` the host never published.
93
+ *
94
+ * The bundle calls this BEFORE it renders anything. A missing key means the
95
+ * site was built against a runtime older than this bundle expects, and the
96
+ * honest response is the reload notice: rendering anyway produces a blank page
97
+ * or a thrown hook, and neither tells anyone what went wrong.
98
+ */
99
+ export function missingRuntimeKeys(required: readonly string[]): string[] {
100
+ const reg = registry();
101
+ return required.filter((key) => !(key in reg));
102
+ }
@@ -46,6 +46,7 @@ import type {
46
46
  CollectionQueryParams,
47
47
  CollectionQuery,
48
48
  CollectionSearchResult,
49
+ PaginatedData,
49
50
  } from "../types/models";
50
51
  import { collectionParamsToQuery, splitCollectionQuery } from "../data/collectionParams";
51
52
  // SEO context for structured data. Pure functions over plain data, so this is
@@ -274,6 +275,18 @@ export async function fetchSiteBootstrap(opts: {
274
275
  }
275
276
 
276
277
  /** Fetch a single event by id or slug for SSR. */
278
+ /**
279
+ * Every published event, for SSR of the events list.
280
+ *
281
+ * The list page had no server fetch at all: it rendered a spinner into the
282
+ * document and filled itself in from the browser, so a crawler saw an empty
283
+ * page and a fan saw a flash of nothing. It is the same endpoint the client
284
+ * hook reads, so the markup and the first client render agree.
285
+ */
286
+ export async function fetchEventsServer(opts: { apiUrl: string; profileId?: string }): Promise<IEvent[]> {
287
+ return (await getJson<IEvent[]>(opts.apiUrl, `/public/events`, { profileId: opts.profileId })) ?? [];
288
+ }
289
+
277
290
  export function fetchEventServer(opts: {
278
291
  apiUrl: string;
279
292
  profileId?: string;
@@ -351,6 +364,67 @@ export function buildMusicLinkHead(
351
364
  }
352
365
 
353
366
  /** Fetch a single product by id or slug for SSR. */
367
+ /**
368
+ * The catalogue pages, server-rendered.
369
+ *
370
+ * Each of these list pages used to render a spinner into the document and
371
+ * fill itself in from the browser, so a crawler indexed an empty page and a
372
+ * fan saw nothing until the fetch landed. They are the pages search engines
373
+ * actually index, which makes an empty server render the expensive kind.
374
+ *
375
+ * Each mirrors its client hook exactly, same endpoint and same page size, so
376
+ * the markup and the first client render agree and nothing is fetched twice.
377
+ * An empty page is a real answer here (a creator who sells no courses), so a
378
+ * failed fetch resolves to an empty page rather than throwing.
379
+ */
380
+ const emptyPage = <T,>(): PaginatedData<T> => ({
381
+ data: [],
382
+ total: 0,
383
+ hasNextPage: false,
384
+ page: 1,
385
+ nextPage: null,
386
+ pageSize: 10,
387
+ });
388
+
389
+ export async function fetchProductsServer(opts: {
390
+ apiUrl: string;
391
+ profileId?: string;
392
+ }): Promise<PaginatedData<IPublicProduct>> {
393
+ return (
394
+ (await getJson<PaginatedData<IPublicProduct>>(opts.apiUrl, `/public/products`, {
395
+ profileId: opts.profileId,
396
+ page: "1",
397
+ limit: "10",
398
+ })) ?? emptyPage<IPublicProduct>()
399
+ );
400
+ }
401
+
402
+ export async function fetchCoursesServer(opts: {
403
+ apiUrl: string;
404
+ profileId?: string;
405
+ }): Promise<PaginatedData<PublicCourse>> {
406
+ return (
407
+ (await getJson<PaginatedData<PublicCourse>>(opts.apiUrl, `/public/courses`, {
408
+ profileId: opts.profileId,
409
+ page: "1",
410
+ limit: "10",
411
+ })) ?? emptyPage<PublicCourse>()
412
+ );
413
+ }
414
+
415
+ export async function fetchCoachingProductsServer(opts: {
416
+ apiUrl: string;
417
+ profileId?: string;
418
+ }): Promise<PaginatedData<CoachingProduct>> {
419
+ return (
420
+ (await getJson<PaginatedData<CoachingProduct>>(opts.apiUrl, `/public/coaching/products`, {
421
+ profileId: opts.profileId,
422
+ page: "1",
423
+ limit: "10",
424
+ })) ?? emptyPage<CoachingProduct>()
425
+ );
426
+ }
427
+
354
428
  export function fetchProductServer(opts: {
355
429
  apiUrl: string;
356
430
  profileId?: string;
@@ -246,7 +246,10 @@ export async function handleAppJobRun(
246
246
  headers: { "content-type": "application/json" },
247
247
  body: JSON.stringify({ token }),
248
248
  });
249
- valid = (await res.json())?.valid === true;
249
+ // Typed rather than left to inference: under Cloudflare's types `json()`
250
+ // resolves to `{}`, so the property read does not compile in a Worker.
251
+ const verified = (await res.json()) as { valid?: boolean } | null;
252
+ valid = verified?.valid === true;
250
253
  } catch {
251
254
  valid = false;
252
255
  }
@@ -220,6 +220,151 @@ export type PlatformEventMap = {
220
220
  unitPrice: number | null;
221
221
  }>;
222
222
  };
223
+ /**
224
+ * Event ticket request approved: The organiser approved a ticket request; the sale settled and the tickets were issued.
225
+ *
226
+ * Requires the `events.read` grant.
227
+ */
228
+ "event_ticket_request.approved": {
229
+ orderId: string;
230
+ eventId: string | null;
231
+ status: string | null;
232
+ total: number | null;
233
+ currency: string | null;
234
+ buyer: {
235
+ contactId: string | null;
236
+ email: string | null;
237
+ name: string | null;
238
+ };
239
+ tickets: Array<{
240
+ ticketId: string | null;
241
+ quantity: number;
242
+ unitPrice: number | null;
243
+ }>;
244
+ };
245
+ /**
246
+ * Event ticket request declined: The organiser declined a ticket request; the hold was released.
247
+ *
248
+ * Requires the `events.read` grant.
249
+ */
250
+ "event_ticket_request.declined": {
251
+ orderId: string;
252
+ eventId: string | null;
253
+ status: string | null;
254
+ total: number | null;
255
+ currency: string | null;
256
+ buyer: {
257
+ contactId: string | null;
258
+ email: string | null;
259
+ name: string | null;
260
+ };
261
+ tickets: Array<{
262
+ ticketId: string | null;
263
+ quantity: number;
264
+ unitPrice: number | null;
265
+ }>;
266
+ };
267
+ /**
268
+ * Event ticket request expired: A ticket request lapsed undecided; the hold and the seats were released.
269
+ *
270
+ * Requires the `events.read` grant.
271
+ */
272
+ "event_ticket_request.expired": {
273
+ orderId: string;
274
+ eventId: string | null;
275
+ status: string | null;
276
+ total: number | null;
277
+ currency: string | null;
278
+ buyer: {
279
+ contactId: string | null;
280
+ email: string | null;
281
+ name: string | null;
282
+ };
283
+ tickets: Array<{
284
+ ticketId: string | null;
285
+ quantity: number;
286
+ unitPrice: number | null;
287
+ }>;
288
+ };
289
+ /**
290
+ * Event ticket requested: Someone requested tickets to an event that needs the organiser's approval. Their card is held, not charged.
291
+ *
292
+ * Requires the `events.read` grant.
293
+ */
294
+ "event_ticket_request.submitted": {
295
+ orderId: string;
296
+ eventId: string | null;
297
+ status: string | null;
298
+ total: number | null;
299
+ currency: string | null;
300
+ buyer: {
301
+ contactId: string | null;
302
+ email: string | null;
303
+ name: string | null;
304
+ };
305
+ tickets: Array<{
306
+ ticketId: string | null;
307
+ quantity: number;
308
+ unitPrice: number | null;
309
+ }>;
310
+ };
311
+ /**
312
+ * Membership application approved: An application was approved. `status` is `joined`, or `payment_failed` when the saved card was declined.
313
+ *
314
+ * Requires the `membership.application_approved` grant.
315
+ */
316
+ "membership_application.approved": {
317
+ applicationId: string;
318
+ membershipTierId: string | null;
319
+ status: string | null;
320
+ billingCycle: string | null;
321
+ amount: number | null;
322
+ currency: string | null;
323
+ membershipId: string | null;
324
+ applicant: {
325
+ contactId: string | null;
326
+ email: string | null;
327
+ name: string | null;
328
+ };
329
+ };
330
+ /**
331
+ * Membership application declined: An application to join a membership tier was declined.
332
+ *
333
+ * Requires the `membership.application_declined` grant.
334
+ */
335
+ "membership_application.declined": {
336
+ applicationId: string;
337
+ membershipTierId: string | null;
338
+ status: string | null;
339
+ billingCycle: string | null;
340
+ amount: number | null;
341
+ currency: string | null;
342
+ membershipId: string | null;
343
+ applicant: {
344
+ contactId: string | null;
345
+ email: string | null;
346
+ name: string | null;
347
+ };
348
+ };
349
+ /**
350
+ * Membership application submitted: Someone applied to join a membership tier that needs approval. Nothing is charged until approved.
351
+ *
352
+ * Requires the `membership.application_submitted` grant.
353
+ */
354
+ "membership_application.submitted": {
355
+ applicationId: string;
356
+ membershipTierId: string | null;
357
+ status: string | null;
358
+ billingCycle: string | null;
359
+ amount: number | null;
360
+ currency: string | null;
361
+ membershipId: string | null;
362
+ applicant: {
363
+ contactId: string | null;
364
+ email: string | null;
365
+ name: string | null;
366
+ };
367
+ };
223
368
  /**
224
369
  * Membership tier archived: A membership tier was archived and can no longer be joined.
225
370
  *
@@ -403,6 +548,13 @@ export const PLATFORM_EVENT_PERMISSIONS: Record<PlatformEventName, string> = {
403
548
  "email.clicked": "emails.read",
404
549
  "email.opened": "emails.read",
405
550
  "event_ticket_order.paid": "events.read",
551
+ "event_ticket_request.approved": "events.read",
552
+ "event_ticket_request.declined": "events.read",
553
+ "event_ticket_request.expired": "events.read",
554
+ "event_ticket_request.submitted": "events.read",
555
+ "membership_application.approved": "membership.application_approved",
556
+ "membership_application.declined": "membership.application_declined",
557
+ "membership_application.submitted": "membership.application_submitted",
406
558
  "membership_tier.archived": "membership.tier_archived",
407
559
  "membership_tier.created": "membership.tier_created",
408
560
  "message.received": "contacts.read",
@@ -66,6 +66,12 @@ export type PublicTaxQuote = {
66
66
  * Stripe always populates `paymentSecret` and never the Paystack pair; Paystack
67
67
  * always populates `accessCode` + `checkoutUrl` and never `paymentSecret`.
68
68
  */
69
+ /** Whether a started charge is taken now or only once the organiser approves. */
70
+ export type PaymentSettlement = "immediate" | "deferred";
71
+
72
+ /** How a provider holds a deferred charge until the decision. */
73
+ export type DeferredChargeStrategy = "authorization" | "saved_instrument";
74
+
69
75
  export type PaymentStartResponse = {
70
76
  /**
71
77
  * STRIPE ONLY: the PaymentIntent client secret fed to Stripe Elements.
@@ -101,6 +107,19 @@ export type PaymentStartResponse = {
101
107
  shippingCosts?: { deliveryGroupId: string; amount: number; currency: string }[];
102
108
  /** Authoritative sales-tax quote for this charge (all wired pillars). */
103
109
  taxQuote?: PublicTaxQuote;
110
+ /**
111
+ * EVENT TICKET REQUESTS: `"deferred"` when this charge is committed now and
112
+ * settled only if the organiser approves. Absent (or `"immediate"`) on every
113
+ * ordinary sale. Drives the "Authorize" wording on the pay button.
114
+ */
115
+ settlement?: PaymentSettlement;
116
+ /**
117
+ * How the provider defers the charge. `"authorization"` places a temporary
118
+ * hold the fan can see on their statement; `"saved_instrument"` stores the
119
+ * card and takes nothing until approval. Only meaningful with
120
+ * `settlement: "deferred"`.
121
+ */
122
+ deferredStrategy?: DeferredChargeStrategy | null;
104
123
  /**
105
124
  * COHORT ENROLMENT ONLY: what THIS charge takes, when the plan is paid in
106
125
  * instalments. `totalAmount` above stays the whole plan, so a checkout that
@@ -288,6 +307,116 @@ export type MembershipTier = {
288
307
  welcomeMessageContent?: string | null;
289
308
  cancellationMessageSubject?: string | null;
290
309
  cancellationMessageContent?: string | null;
310
+ /**
311
+ * How a fan gets onto this tier (curated access). `open` is the ordinary
312
+ * checkout; `invite` needs a live invite for the signed-in email (and the
313
+ * tier is only listed when the request carried that invite's token);
314
+ * `application` means the fan applies and the business approves. Optional,
315
+ * because an older API build sends nothing, which reads as `open`.
316
+ */
317
+ joinMode?: MembershipJoinMode;
318
+ /**
319
+ * The signed-in fan's most recent open application on this tier (or their
320
+ * latest declined one), so the tier card can show where it stands instead of
321
+ * a join button. Absent when signed out or on an older API build.
322
+ */
323
+ myApplication?: MembershipTierMyApplication | null;
324
+ /**
325
+ * Free trial length in days on a paid tier (1 to 90), or null for no trial.
326
+ * A card is still collected; the first charge is on today + trialDays.
327
+ */
328
+ trialDays?: number | null;
329
+ /**
330
+ * False once the signed-in fan has had a trial with this business (one per
331
+ * fan per business, any tier). Absent when signed out: treat as eligible.
332
+ */
333
+ trialEligible?: boolean;
334
+ /**
335
+ * Questions the fan answers before paying or joining, on every join mode.
336
+ * Null or absent when the tier asks nothing.
337
+ */
338
+ questionnaire?: QuestionnaireQuestion[] | null;
339
+ };
340
+
341
+ /** How a fan gets onto a membership tier. */
342
+ export type MembershipJoinMode = "open" | "invite" | "application";
343
+
344
+ /**
345
+ * Where an application to a membership tier stands.
346
+ *
347
+ * card_pending paid tier: card step started, not confirmed. Not yet visible to the business.
348
+ * pending lodged and waiting for a decision; holds a seat
349
+ * approved approved; the subscription is being created
350
+ * payment_failed approved, but the saved card was declined; the fan can finish at checkout
351
+ * joined the membership exists
352
+ * declined not approved
353
+ * withdrawn withdrawn by the fan, or closed because the tier filled (`closedReason: "tier_full"`)
354
+ * expired the window to finish a failed payment lapsed
355
+ */
356
+ export type MembershipApplicationStatus =
357
+ | "card_pending"
358
+ | "pending"
359
+ | "approved"
360
+ | "payment_failed"
361
+ | "joined"
362
+ | "declined"
363
+ | "withdrawn"
364
+ | "expired";
365
+
366
+ /** The slice of an application the public tier list carries per tier. */
367
+ export type MembershipTierMyApplication = {
368
+ id: string;
369
+ status: MembershipApplicationStatus;
370
+ paymentWindowExpiresAt: string | null;
371
+ };
372
+
373
+ export type MembershipApplication = {
374
+ id: string;
375
+ profileId: string;
376
+ membershipTierId: string;
377
+ /** Present on list payloads. */
378
+ membershipTierName?: string;
379
+ accountId: string;
380
+ status: MembershipApplicationStatus;
381
+ /** Null on a free tier. */
382
+ billingCycle: "month" | "year" | null;
383
+ /** What will be charged per cycle, in major units. Null on a free tier. */
384
+ amount: number | null;
385
+ currency: string | null;
386
+ /** A note from an older checkout. Current checkouts ask the tier's questions instead. */
387
+ message: string | null;
388
+ /** The fan's answers to the tier's questions, as given when applying. */
389
+ questionnaire?: QuestionnaireAnswer[] | null;
390
+ /** e.g. "Visa •••• 4242", set once the card is confirmed. */
391
+ instrumentLabel: string | null;
392
+ decidedAt: string | null;
393
+ decisionNote: string | null;
394
+ /** `"tier_full"`, `"expired"`, or the provider's decline reason on `payment_failed`. */
395
+ closedReason: string | null;
396
+ /** The deadline to finish a `payment_failed` application. */
397
+ paymentWindowExpiresAt: string | null;
398
+ membershipId: string | null;
399
+ createdAt: string;
400
+ updatedAt: string;
401
+ };
402
+
403
+ /** What `POST /public/membership-applications` answers. */
404
+ export type ApplyForMembershipResult = {
405
+ application: MembershipApplication;
406
+ /** True on a paid tier until the card is confirmed. */
407
+ requiresCard: boolean;
408
+ /** A Stripe SetupIntent secret (`seti_..._secret_...`) when `requiresCard`. */
409
+ clientSecret: string | null;
410
+ };
411
+
412
+ /** What `GET /public/membership-invites/info` answers for a live invite. */
413
+ export type MembershipInviteInfo = {
414
+ membershipTierId: string;
415
+ membershipTierName: string;
416
+ profileId: string;
417
+ /** The address the invite was sent to; the fan must be signed in with it. */
418
+ email: string;
419
+ expiresAt: string;
291
420
  };
292
421
 
293
422
  /**
@@ -327,6 +456,10 @@ export interface Membership {
327
456
  subscriptionAmount: number;
328
457
  subscriptionCurrency: string;
329
458
  billingCycle: string;
459
+ /** Set when the membership started with a free trial: when the first charge is due. */
460
+ trialEndsAt?: string | null;
461
+ /** The fan's answers to the tier's questions, as given when joining. */
462
+ questionnaire?: QuestionnaireAnswer[] | null;
330
463
  }
331
464
 
332
465
  // ---- Public auth -------------------------------------------------------------
@@ -892,6 +1025,19 @@ export type QuestionnaireQuestion = {
892
1025
  optional?: boolean;
893
1026
  };
894
1027
 
1028
+ /**
1029
+ * A fan's answer to one {@link QuestionnaireQuestion}, as sent at checkout and
1030
+ * as stored on what the checkout created. `answer` is `""` for an optional
1031
+ * question left blank.
1032
+ */
1033
+ export type QuestionnaireAnswer = {
1034
+ id: string;
1035
+ question: string;
1036
+ type: string;
1037
+ options?: string[];
1038
+ answer: string;
1039
+ };
1040
+
895
1041
  /**
896
1042
  * S.6 — the cancellation terms a buyer is entitled to read BEFORE they pay.
897
1043
  *
@@ -1145,6 +1291,12 @@ export interface IEvent {
1145
1291
  createdAt: string;
1146
1292
  updatedAt: string;
1147
1293
  tickets: ITicket[];
1294
+ /**
1295
+ * At least one tier on this event needs the organiser's approval. Optional
1296
+ * on the wire; derive it from `tickets` with `eventHasApprovalTiers` rather
1297
+ * than trusting its absence.
1298
+ */
1299
+ hasApprovalTiers?: boolean;
1148
1300
  media: IMedia[];
1149
1301
  slug: string;
1150
1302
  /**
@@ -1339,6 +1491,17 @@ export type ITicket = {
1339
1491
  * {@link PublicMembershipGate}.
1340
1492
  */
1341
1493
  membershipGate?: PublicMembershipGate | null;
1494
+ /**
1495
+ * The organiser decides who gets this tier (curated access, feature 3).
1496
+ *
1497
+ * A request is lodged instead of a sale: the card is authorised, not charged,
1498
+ * the seat is held, and the organiser has a window to approve or decline.
1499
+ * Sent to every client. A site on an older Forge shows such a tier with its
1500
+ * ordinary buy button; that is accepted until the site takes the update.
1501
+ */
1502
+ requiresApproval?: boolean;
1503
+ /** How long the organiser has to decide, in hours. `null` means the platform default. */
1504
+ approvalWindowHours?: number | null;
1342
1505
  };
1343
1506
 
1344
1507
  // ---- Invoices ----------------------------------------------------------------
@@ -1506,6 +1669,12 @@ export enum OrderStatus {
1506
1669
  Delivered = "delivered",
1507
1670
  Cancelled = "cancelled",
1508
1671
  Processed = "processed",
1672
+ /** Ticket request lodged: charge committed (authorised), seat held, nothing captured, no passes. */
1673
+ PendingApproval = "pending_approval",
1674
+ /** Transient: the organiser approved and settlement is in flight. */
1675
+ Approved = "approved",
1676
+ /** The organiser declined the request; the authorisation was released. */
1677
+ Declined = "declined",
1509
1678
  }
1510
1679
 
1511
1680
  export type IPublicOrderItem = {
@@ -1620,6 +1789,22 @@ export type ITicketOrder = {
1620
1789
  refundedAmountCents?: number | string | null;
1621
1790
  refundState?: "none" | "partial" | "full" | string | null;
1622
1791
  lastRefundedAt?: string | null;
1792
+ /**
1793
+ * Ticket-request columns (curated access, feature 3). All absent on an
1794
+ * ordinary sale and on an older API build.
1795
+ *
1796
+ * `approvalRequestExpiresAt` is the "decide by" instant a fan is shown while
1797
+ * the request is `pending_approval`. `approvalFailureReason` is set when a
1798
+ * request ended without a sale: `"expired"` on a `cancelled` row whose window
1799
+ * lapsed undecided, and the provider's reason on a `payment_failed` row the
1800
+ * organiser approved but could not settle. See `getTicketOrderOutcome` and
1801
+ * `ticketRequestFailureKind`.
1802
+ */
1803
+ approvalRequestedAt?: string | null;
1804
+ approvalRequestExpiresAt?: string | null;
1805
+ approvalDecidedAt?: string | null;
1806
+ approvalDecisionNote?: string | null;
1807
+ approvalFailureReason?: string | null;
1623
1808
  customerName: string;
1624
1809
  customerEmail: string;
1625
1810
  createdAt: string;