@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tribe-nest/forge",
3
- "version": "3.53.0",
3
+ "version": "3.57.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -1,5 +1,6 @@
1
1
  "use client";
2
- import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
2
+ import { useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
3
+ import { createSharedContext } from "../runtime/registry";
3
4
  import { useForge } from "../provider/ForgeProvider";
4
5
  import { createForgeClient } from "../client/createForgeClient";
5
6
  import {
@@ -105,7 +106,7 @@ interface AppAuthContextType {
105
106
  hasPermission: (permission: string) => boolean;
106
107
  }
107
108
 
108
- const AppAuthContext = createContext<AppAuthContextType | null>(null);
109
+ const AppAuthContext = createSharedContext<AppAuthContextType | null>("appAuth", null);
109
110
 
110
111
  export function AppAuthProvider({ children }: { children: ReactNode }) {
111
112
  const { apiUrl, appId, publishableKey } = useForge();
@@ -1,5 +1,6 @@
1
1
  "use client";
2
- import { createContext, useContext, useState, useRef, useEffect, useCallback } from "react";
2
+ import { useContext, useState, useRef, useEffect, useCallback } from "react";
3
+ import { createSharedContext } from "../runtime/registry";
3
4
  import type { ReactNode } from "react";
4
5
 
5
6
  export interface AudioTrack {
@@ -34,7 +35,7 @@ interface AudioPlayerContextType {
34
35
  queue: AudioTrack[];
35
36
  }
36
37
 
37
- const AudioPlayerContext = createContext<AudioPlayerContextType | undefined>(undefined);
38
+ const AudioPlayerContext = createSharedContext<AudioPlayerContextType | undefined>("audioPlayer", undefined);
38
39
 
39
40
  export function AudioPlayerProvider({ children }: { children: ReactNode }) {
40
41
  const [currentTrack, setCurrentTrack] = useState<AudioTrack | null>(null);
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
  import type { IBookingFee, ProductDeliveryType } from "../types/models";
3
- import { createContext, useCallback, useContext, useMemo, useState } from "react";
3
+ import { useCallback, useContext, useMemo, useState } from "react";
4
+ import { createSharedContext } from "../runtime/registry";
4
5
  import type { ReactNode } from "react";
5
6
  import { useEffect } from "react";
6
7
 
@@ -153,7 +154,7 @@ interface CartContextType {
153
154
  isReady: boolean;
154
155
  }
155
156
 
156
- const CartContext = createContext<CartContextType | undefined>(undefined);
157
+ const CartContext = createSharedContext<CartContextType | undefined>("cart", undefined);
157
158
 
158
159
  const CART_STORAGE_KEY = "tribenest-cart";
159
160
 
@@ -1,5 +1,6 @@
1
1
  "use client";
2
- import { createContext, type ReactNode, useCallback, useContext, useEffect, useReducer, useState } from "react";
2
+ import { type ReactNode, useCallback, useContext, useEffect, useReducer, useState } from "react";
3
+ import { createSharedContext } from "../runtime/registry";
3
4
  import {
4
5
  type ApiError,
5
6
  type PublicAuthUser,
@@ -107,7 +108,7 @@ const AuthReducer = (state: PublicAuthState, action: AuthActions): PublicAuthSta
107
108
  }
108
109
  };
109
110
 
110
- const PublicAuthContext = createContext<PublicAuthContextType | null>(null);
111
+ const PublicAuthContext = createSharedContext<PublicAuthContextType | null>("publicAuth", null);
111
112
 
112
113
  // ----------------------------------------------------------------------
113
114
 
@@ -7,7 +7,20 @@ export interface GetCoachingProductsParams {
7
7
  query?: string;
8
8
  }
9
9
 
10
- export function useGetCoachingProducts(params?: GetCoachingProductsParams, enabled = true) {
10
+ export function useGetCoachingProducts(
11
+ params?: GetCoachingProductsParams,
12
+ enabled = true,
13
+ options?: {
14
+ /**
15
+ * The page a server render already fetched.
16
+ *
17
+ * Seeds the cache so the first client render matches the markup and nothing
18
+ * is refetched. Must be built with the SAME params as the key below, or the
19
+ * seed belongs to a different query and is ignored.
20
+ */
21
+ initialData?: PaginatedData<CoachingProduct>;
22
+ },
23
+ ) {
11
24
  const { client, profileId } = useForge();
12
25
 
13
26
  return useQuery<PaginatedData<CoachingProduct>>({
@@ -23,6 +36,7 @@ export function useGetCoachingProducts(params?: GetCoachingProductsParams, enabl
23
36
  return res.data;
24
37
  },
25
38
  enabled: !!profileId && !!client && enabled,
39
+ initialData: options?.initialData,
26
40
  });
27
41
  }
28
42
 
@@ -90,7 +90,20 @@ export interface GetCoursesParams {
90
90
  query?: string;
91
91
  }
92
92
 
93
- export function useGetCourses(params?: GetCoursesParams, enabled = true) {
93
+ export function useGetCourses(
94
+ params?: GetCoursesParams,
95
+ enabled = true,
96
+ options?: {
97
+ /**
98
+ * The page a server render already fetched.
99
+ *
100
+ * Seeds the cache so the first client render matches the markup and nothing
101
+ * is refetched. Must be built with the SAME params as the key below, or the
102
+ * seed belongs to a different query and is ignored.
103
+ */
104
+ initialData?: PaginatedData<PublicCourse>;
105
+ },
106
+ ) {
94
107
  const { client, profileId } = useForge();
95
108
 
96
109
  return useQuery<PaginatedData<PublicCourse>>({
@@ -106,6 +119,7 @@ export function useGetCourses(params?: GetCoursesParams, enabled = true) {
106
119
  return res.data;
107
120
  },
108
121
  enabled: !!profileId && !!client && enabled,
122
+ initialData: options?.initialData,
109
123
  });
110
124
  }
111
125
 
@@ -3,7 +3,17 @@ import { useForge } from "../../provider/ForgeProvider";
3
3
  import { useMutation, useQuery } from "@tanstack/react-query";
4
4
 
5
5
  /** All published events for the profile. */
6
- export function useEvents() {
6
+ export function useEvents(options?: {
7
+ /**
8
+ * The list a server render already fetched (`fetchEventsServer`).
9
+ *
10
+ * Seeds the cache so the first client render shows the same events as the
11
+ * markup and nothing is refetched. Without it the list page renders a
12
+ * spinner on the server, which is what a crawler sees and what a fan sees
13
+ * for as long as the request takes.
14
+ */
15
+ initialData?: IEvent[];
16
+ }) {
7
17
  const { client, profileId } = useForge();
8
18
 
9
19
  return useQuery<IEvent[]>({
@@ -15,6 +25,7 @@ export function useEvents() {
15
25
  return res.data;
16
26
  },
17
27
  enabled: !!profileId && !!client,
28
+ initialData: options?.initialData,
18
29
  });
19
30
  }
20
31
 
@@ -161,6 +172,20 @@ export type CreateEventOrderResult = {
161
172
  * exists — and for a profile with inventory holds switched off.
162
173
  */
163
174
  holdExpiresAt?: string | null;
175
+ /**
176
+ * This order is a ticket REQUEST (curated access, feature 3): the tiers in it
177
+ * need the organiser's approval, so nothing is sold until they decide.
178
+ *
179
+ * With a price on it the payment step runs as today and the charge is
180
+ * deferred (`settlement: "deferred"` on start-payment). With nothing to pay
181
+ * the order is already `pending_approval` when this returns, `isFreeCheckout`
182
+ * is FALSE (no passes were minted), and there is no payment step: the
183
+ * checkout goes straight to the request-received outcome. See
184
+ * `nextStepAfterTicketOrder`.
185
+ */
186
+ awaitingApproval?: boolean;
187
+ /** When an undecided request lapses, ISO-8601. `null` on an ordinary sale. */
188
+ requestExpiresAt?: string | null;
164
189
  };
165
190
 
166
191
  /** Create an event ticket order (the step before start-payment). */
@@ -1,22 +1,60 @@
1
- import type { MembershipTier } from "../../types/models";
1
+ import type {
2
+ ApplyForMembershipResult,
3
+ MembershipApplication,
4
+ MembershipInviteInfo,
5
+ MembershipTier,
6
+ QuestionnaireAnswer,
7
+ } from "../../types/models";
2
8
  import { useForge } from "../../provider/ForgeProvider";
3
- import { useQuery } from "@tanstack/react-query";
9
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
10
+
11
+ /**
12
+ * The membership invite token this visitor arrived holding, read from the
13
+ * `invite` search param of the CURRENT url (the invite email links to
14
+ * `/i/membership/checkout?membershipTierId=X&invite=TOKEN`).
15
+ *
16
+ * Deliberately not stored anywhere: it is a bearer credential, and the durable
17
+ * copy is the email. Returns undefined on the server and when absent.
18
+ */
19
+ export const readMembershipInviteFromUrl = (search?: string): string | undefined => {
20
+ const raw = search ?? (typeof window === "undefined" ? "" : window.location.search);
21
+ if (!raw) return undefined;
22
+ const token = new URLSearchParams(raw).get("invite")?.trim();
23
+ return token ? token : undefined;
24
+ };
25
+
26
+ export interface UseGetMembershipTiersOptions {
27
+ enabled?: boolean;
28
+ /**
29
+ * An invite token to forward, so an invite-only tier it unlocks is listed.
30
+ * Omitted (undefined), the `invite` search param of the current url is used;
31
+ * `null` forwards nothing even when the url carries one.
32
+ */
33
+ inviteToken?: string | null;
34
+ }
4
35
 
5
36
  /**
6
37
  * `opts.enabled` gates the request on top of the usual profile/client checks —
7
38
  * for a caller that only needs tier NAMES when something is actually gated, and
8
39
  * must not add a request to every ungated storefront. Defaults to on, so every
9
40
  * existing caller is unchanged.
41
+ *
42
+ * Invite-only tiers are omitted by the server unless the request carries a
43
+ * live invite token for them, so the token is part of the query key.
10
44
  */
11
- export function useGetMembershipTiers(opts: { enabled?: boolean } = {}) {
45
+ export function useGetMembershipTiers(opts: UseGetMembershipTiersOptions = {}) {
12
46
  const { client, profileId } = useForge();
47
+ const inviteToken = opts.inviteToken === undefined ? readMembershipInviteFromUrl() : (opts.inviteToken ?? undefined);
13
48
 
14
49
  return useQuery<MembershipTier[]>({
15
- queryKey: ["membership-tiers", profileId],
50
+ // The token is appended only when present, so the no-invite key stays the
51
+ // one every existing cache seed (SSR, specs) already writes.
52
+ queryKey: inviteToken ? ["membership-tiers", profileId, inviteToken] : ["membership-tiers", profileId],
16
53
  queryFn: async () => {
17
54
  const res = await client.get("/public/membership-tiers", {
18
55
  params: {
19
56
  profileId: profileId,
57
+ ...(inviteToken ? { inviteToken } : {}),
20
58
  },
21
59
  });
22
60
  return res.data;
@@ -24,3 +62,137 @@ export function useGetMembershipTiers(opts: { enabled?: boolean } = {}) {
24
62
  enabled: !!profileId && !!client && opts.enabled !== false,
25
63
  });
26
64
  }
65
+
66
+ // ---- Invites -------------------------------------------------------------------
67
+
68
+ /**
69
+ * What a membership invite link is for: the tier and the invited email, so the
70
+ * landing page can name both before asking for anything.
71
+ *
72
+ * A dead token (unknown, expired, revoked or already accepted) is a 404, which
73
+ * is an ANSWER rather than a transient failure, so it is not retried. Tell the
74
+ * two apart with `isMembershipInviteDead(error)`.
75
+ */
76
+ export function useMembershipInviteInfo(token?: string | null) {
77
+ const { client } = useForge();
78
+
79
+ return useQuery<MembershipInviteInfo>({
80
+ queryKey: ["membership-invite-info", token ?? null],
81
+ queryFn: async () => {
82
+ const res = await client.get("/public/membership-invites/info", { params: { token } });
83
+ return res.data;
84
+ },
85
+ enabled: !!token && !!client,
86
+ retry: (count, error) => !isMembershipInviteDead(error) && count < 2,
87
+ });
88
+ }
89
+
90
+ /** True when an invite-info error means the invite is gone (404), not that the request failed. */
91
+ export function isMembershipInviteDead(error: unknown): boolean {
92
+ return (error as { response?: { status?: number } } | null)?.response?.status === 404;
93
+ }
94
+
95
+ // ---- Applications ----------------------------------------------------------------
96
+
97
+ export type ApplyForMembershipInput = {
98
+ membershipTierId: string;
99
+ /** Required on a paid tier. */
100
+ billingCycle?: "month" | "year";
101
+ /** Required on a paid tier; same floor/ceiling rules as a subscription. */
102
+ amount?: number;
103
+ /**
104
+ * A free-text note, up to 1000 characters. Still accepted by the server, but
105
+ * no current checkout sends it: the tier's questions replaced it.
106
+ */
107
+ message?: string;
108
+ /** Answers to the tier's questions. Required by the server when the tier has any. */
109
+ questionnaire?: QuestionnaireAnswer[];
110
+ /** Where Stripe returns after a redirect-based card confirmation. */
111
+ returnUrl?: string;
112
+ };
113
+
114
+ /** Invalidate everything an application changes: the tier cards and the fan's list. */
115
+ function useInvalidateApplications() {
116
+ const queryClient = useQueryClient();
117
+ return () =>
118
+ Promise.all([
119
+ queryClient.invalidateQueries({ queryKey: ["membership-tiers"] }),
120
+ queryClient.invalidateQueries({ queryKey: ["my-membership-applications"] }),
121
+ ]);
122
+ }
123
+
124
+ /**
125
+ * Apply to join an `application` tier.
126
+ *
127
+ * A free tier comes back `pending` with nothing more to do. A paid tier comes
128
+ * back `card_pending` with `requiresCard` and a SetupIntent `clientSecret`: the
129
+ * card is saved, not charged, and after the renderer confirms it the caller
130
+ * calls `useConfirmMembershipApplication`. Applying again while one is open
131
+ * returns that one.
132
+ */
133
+ export function useApplyForMembership() {
134
+ const { client, profileId } = useForge();
135
+ const invalidate = useInvalidateApplications();
136
+
137
+ return useMutation<ApplyForMembershipResult, unknown, ApplyForMembershipInput>({
138
+ mutationFn: async (body) => {
139
+ const res = await client.post("/public/membership-applications", { ...body, profileId });
140
+ return res.data;
141
+ },
142
+ onSuccess: () => void invalidate(),
143
+ });
144
+ }
145
+
146
+ /**
147
+ * Tell the server the card step finished. Idempotent. The application comes
148
+ * back `pending` once the provider shows the card saved, still `card_pending`
149
+ * if it does not yet, or `withdrawn` with `closedReason: "tier_full"` when the
150
+ * last seat went in the meantime.
151
+ */
152
+ export function useConfirmMembershipApplication() {
153
+ const { client, profileId } = useForge();
154
+ const invalidate = useInvalidateApplications();
155
+
156
+ return useMutation<{ application: MembershipApplication }, unknown, { applicationId: string }>({
157
+ mutationFn: async ({ applicationId }) => {
158
+ const res = await client.post(`/public/membership-applications/${encodeURIComponent(applicationId)}/confirm`, {
159
+ profileId,
160
+ });
161
+ return res.data;
162
+ },
163
+ onSuccess: () => void invalidate(),
164
+ });
165
+ }
166
+
167
+ /** Withdraw an application that is still `card_pending` or `pending`. */
168
+ export function useWithdrawMembershipApplication() {
169
+ const { client, profileId } = useForge();
170
+ const invalidate = useInvalidateApplications();
171
+
172
+ return useMutation<{ application: MembershipApplication }, unknown, { applicationId: string }>({
173
+ mutationFn: async ({ applicationId }) => {
174
+ const res = await client.post(`/public/membership-applications/${encodeURIComponent(applicationId)}/withdraw`, {
175
+ profileId,
176
+ });
177
+ return res.data;
178
+ },
179
+ onSuccess: () => void invalidate(),
180
+ });
181
+ }
182
+
183
+ /**
184
+ * The signed-in fan's applications on this profile, newest first. Pass the
185
+ * account id so the query waits for sign-in and is keyed per account.
186
+ */
187
+ export function useMyMembershipApplications(accountId?: string) {
188
+ const { client, profileId } = useForge();
189
+
190
+ return useQuery<MembershipApplication[]>({
191
+ queryKey: ["my-membership-applications", accountId, profileId],
192
+ queryFn: async () => {
193
+ const res = await client.get("/public/membership-applications/mine", { params: { profileId } });
194
+ return res.data;
195
+ },
196
+ enabled: !!accountId && !!profileId && !!client,
197
+ });
198
+ }
@@ -108,6 +108,33 @@ export type MyTicketPendingTransfer = {
108
108
  /** How the caller comes to be looking at an order. */
109
109
  export type MyTicketRole = "purchaser" | "holder";
110
110
 
111
+ /**
112
+ * Where a ticket REQUEST stands (curated access, feature 3), per order.
113
+ *
114
+ * pending lodged; the organiser has until `expiresAt` to decide
115
+ * approved approved; on a priced request the charge is settled or
116
+ * settling, and the passes are on the order
117
+ * declined the organiser said no; `decisionNote` carries their words
118
+ * expired the window lapsed undecided; the hold was released
119
+ * payment_failed approved, but the deferred charge could not be settled
120
+ * (`failureReason`); the seat was released
121
+ *
122
+ * `state: null` means the order was never a request. The whole object is
123
+ * optional because it is newer than the endpoint: an older API build sends no
124
+ * `request` at all, and `myTicketRequestState` reads that as "not a request".
125
+ */
126
+ export type MyTicketRequestState = "pending" | "approved" | "declined" | "expired" | "payment_failed";
127
+
128
+ export type MyTicketRequest = {
129
+ state: MyTicketRequestState | null;
130
+ requestedAt: string | null;
131
+ /** The "decide by" instant while `pending`. */
132
+ expiresAt: string | null;
133
+ decidedAt: string | null;
134
+ decisionNote: string | null;
135
+ failureReason: string | null;
136
+ };
137
+
111
138
  export type MyTicketItem = {
112
139
  id: string;
113
140
  quantity: number;
@@ -150,6 +177,8 @@ export type MyTicket = {
150
177
  * `purchaser` reproduces exactly the previous behaviour.
151
178
  */
152
179
  role?: MyTicketRole;
180
+ /** The ticket request behind this order, if it was one. See `MyTicketRequest`. */
181
+ request?: MyTicketRequest | null;
153
182
  /**
154
183
  * The event's `.ics`, both forms — what lets the portal offer Apple Calendar
155
184
  * rather than only the three template providers. Null for a cancelled show.
@@ -163,6 +192,26 @@ export type MyTicket = {
163
192
  passes?: MyTicketPass[];
164
193
  };
165
194
 
195
+ /**
196
+ * The request state of an order, or `null` when it was never a request (or
197
+ * the API build predates requests). One place to read it so no surface has to
198
+ * know the object is optional.
199
+ */
200
+ export function myTicketRequestState(ticket: Pick<MyTicket, "request">): MyTicketRequestState | null {
201
+ return ticket.request?.state ?? null;
202
+ }
203
+
204
+ /**
205
+ * Does this request still have a live decision ahead of it, or did it end
206
+ * without a sale? Both mean "no passes, nothing to cancel, nothing to hand
207
+ * on", which is what the tickets tab reads to draw the row as a request rather
208
+ * than as a purchase. An `approved` request is a purchase by now.
209
+ */
210
+ export function isMyTicketRequestOpenOrEnded(ticket: Pick<MyTicket, "request">): boolean {
211
+ const state = myTicketRequestState(ticket);
212
+ return state === "pending" || state === "declined" || state === "expired" || state === "payment_failed";
213
+ }
214
+
166
215
  /**
167
216
  * Every pass id on an order.
168
217
  *
@@ -152,6 +152,14 @@ export function usePaymentFlow(opts: UsePaymentFlowOptions) {
152
152
  /** PAYSTACK: the hosted page used only when the modal cannot open. */
153
153
  checkoutUrl: result?.checkoutUrl,
154
154
  isPaystack,
155
+ /**
156
+ * `"deferred"` when the charge is committed now and settled only if the
157
+ * organiser approves (an event ticket request). Undefined on every
158
+ * ordinary sale and on an older API build, which renders as before.
159
+ */
160
+ settlement: result?.settlement,
161
+ /** How the provider defers a `settlement: "deferred"` charge. See `PaymentStartResponse`. */
162
+ deferredStrategy: result?.deferredStrategy ?? null,
155
163
  /** True once there is a Paystack session a pay button can open. */
156
164
  canOpenPaystack: hasPaystackSession(session),
157
165
  openPaystackCheckout: openPaystack,
@@ -41,7 +41,20 @@ export function useFeaturedProducts(productType?: ProductType) {
41
41
  });
42
42
  }
43
43
 
44
- export function useGetProducts(params?: GetProductsParams, enabled = true) {
44
+ export function useGetProducts(
45
+ params?: GetProductsParams,
46
+ enabled = true,
47
+ options?: {
48
+ /**
49
+ * The page a server render already fetched.
50
+ *
51
+ * Seeds the cache so the first client render matches the markup and nothing
52
+ * is refetched. Must be built with the SAME params as the key below, or the
53
+ * seed belongs to a different query and is ignored.
54
+ */
55
+ initialData?: PaginatedData<IPublicProduct>;
56
+ },
57
+ ) {
45
58
  const { client, profileId } = useForge();
46
59
 
47
60
  return useQuery<PaginatedData<IPublicProduct>>({
@@ -64,6 +77,7 @@ export function useGetProducts(params?: GetProductsParams, enabled = true) {
64
77
  return res.data;
65
78
  },
66
79
  enabled: !!profileId && !!client && enabled,
80
+ initialData: options?.initialData,
67
81
  });
68
82
  }
69
83
 
@@ -1,6 +1,7 @@
1
1
  import { useForge } from "../../provider/ForgeProvider";
2
2
  import { useMutation } from "@tanstack/react-query";
3
3
  import { usePublicAuth } from "../../contexts/PublicAuthContext";
4
+ import type { QuestionnaireAnswer } from "../../types/models";
4
5
  import {
5
6
  getMembershipAccess,
6
7
  getMembershipStatusMessage,
@@ -14,6 +15,13 @@ export type CreateSubscriptionInput = {
14
15
  membershipTierId: string;
15
16
  returnUrl: string;
16
17
  attributionRefId?: string;
18
+ /**
19
+ * A membership invite token. Required by the server on an `invite` tier (the
20
+ * invite must be for the signed-in email); ignored on an open tier.
21
+ */
22
+ inviteToken?: string;
23
+ /** Answers to the tier's questions. Required by the server when the tier has any. */
24
+ questionnaire?: QuestionnaireAnswer[];
17
25
  };
18
26
 
19
27
  /**
@@ -41,6 +49,12 @@ export type CreateSubscriptionResult = {
41
49
  currentPeriodEnd?: string;
42
50
  requiresPayment?: boolean;
43
51
  changed?: boolean;
52
+ /**
53
+ * Set when this subscription starts with a free trial. `clientSecret` is then
54
+ * a Stripe SetupIntent secret (`seti_...`): the card is saved, nothing is
55
+ * charged today, and the first charge is on `endsAt`.
56
+ */
57
+ trial?: { days: number; endsAt: string } | null;
44
58
  };
45
59
 
46
60
  /** Subscribe to a paid tier, or move an existing subscription onto another one. */
@@ -59,7 +73,13 @@ export function useCreateSubscription() {
59
73
  export function useCreateFreeSubscription() {
60
74
  const { client, profileId } = useForge();
61
75
 
62
- return useMutation<unknown, unknown, { membershipTierId: string; attributionRefId?: string }>({
76
+ return useMutation<unknown, unknown, {
77
+ membershipTierId: string;
78
+ attributionRefId?: string;
79
+ inviteToken?: string;
80
+ /** Answers to the tier's questions. Required by the server when the tier has any. */
81
+ questionnaire?: QuestionnaireAnswer[];
82
+ }>({
63
83
  mutationFn: async (body) => {
64
84
  const res = await client.post("/public/payments/subscriptions/free", { ...body, profileId });
65
85
  return res.data;