@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.
- package/package.json +1 -1
- package/src/contexts/AppAuthContext.tsx +3 -2
- package/src/contexts/AudioPlayerContext.tsx +3 -2
- package/src/contexts/CartContext.tsx +3 -2
- package/src/contexts/PublicAuthContext.tsx +3 -2
- package/src/data/queries/useCoachingProducts.ts +15 -1
- package/src/data/queries/useCourses.ts +15 -1
- package/src/data/queries/useEvents.ts +26 -1
- package/src/data/queries/useMembership.ts +176 -4
- package/src/data/queries/useMyTickets.ts +49 -0
- package/src/data/queries/usePaymentFlow.ts +8 -0
- package/src/data/queries/useProducts.ts +15 -1
- package/src/data/queries/useSubscriptions.ts +21 -1
- package/src/i18n/de.json +116 -1
- package/src/i18n/en.json +116 -1
- package/src/i18n/index.ts +3 -2
- package/src/index.ts +22 -0
- package/src/provider/ForgeAppProvider.tsx +8 -1
- package/src/provider/ForgeProvider.tsx +3 -2
- package/src/provider/SiteConfigProvider.tsx +3 -2
- package/src/runtime/RemotePage.tsx +110 -0
- package/src/runtime/hostRuntime.ts +84 -0
- package/src/runtime/pages.spec.ts +41 -0
- package/src/runtime/pages.ts +183 -0
- package/src/runtime/pagesClient.ts +127 -0
- package/src/runtime/registry.spec.ts +45 -0
- package/src/runtime/registry.ts +102 -0
- package/src/server/index.ts +74 -0
- package/src/server/jobs.ts +4 -1
- package/src/server/platformEvents.generated.ts +152 -0
- package/src/types/models.ts +185 -0
- package/src/ui/headless/event/_tests/ticketApproval.spec.ts +137 -0
- package/src/ui/headless/event/_tests/useEventCheckoutApproval.spec.tsx +250 -0
- package/src/ui/headless/event/ticketApproval.ts +109 -0
- package/src/ui/headless/event/useEventCheckout.ts +74 -4
- package/src/ui/headless/index.ts +34 -0
- package/src/ui/headless/membership/_tests/membershipApplication.spec.ts +85 -0
- package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +2 -0
- package/src/ui/headless/membership/_tests/membershipTrial.spec.ts +61 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +253 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +241 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +165 -0
- package/src/ui/headless/membership/membershipApplication.ts +109 -0
- package/src/ui/headless/membership/membershipQuestionnaire.ts +44 -0
- package/src/ui/headless/membership/membershipTrial.ts +53 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +227 -8
- package/src/ui/index.ts +27 -0
- package/src/ui/payment/ForgePaymentProvider.tsx +29 -0
- package/src/ui/payment/ForgeStripePayment.tsx +110 -5
- package/src/ui/payment/_tests/ForgeStripePaymentTrial.spec.tsx +101 -0
- package/src/ui/payment/_tests/stripeConfirmOutcome.spec.ts +61 -0
- package/src/ui/payment/_tests/stripeIntentKind.spec.ts +43 -0
- package/src/ui/payment/stripeConfirmOutcome.ts +50 -0
- package/src/ui/payment/stripeIntentKind.ts +32 -0
- package/src/ui/shell/TribeNestApp.tsx +6 -0
- package/src/ui/styled/AccountDashboard.tsx +239 -15
- package/src/ui/styled/DocumentSigningPage.tsx +516 -0
- package/src/ui/styled/EventConfirmation.tsx +106 -9
- package/src/ui/styled/EventTickets.tsx +107 -13
- package/src/ui/styled/LoginForm.tsx +5 -3
- package/src/ui/styled/MembershipCheckout.tsx +596 -256
- package/src/ui/styled/MembershipTierCallout.tsx +17 -3
- package/src/ui/styled/MembershipTiers.tsx +148 -6
- package/src/ui/styled/ProductGrid.tsx +14 -2
- package/src/ui/styled/ReviewRequestPage.tsx +214 -0
- package/src/ui/styled/SignupForm.tsx +4 -2
- package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +105 -0
- package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +106 -0
- package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +139 -0
- package/src/ui/styled/_tests/membershipTiersTrial.spec.tsx +97 -0
- package/src/ui/styled/forge-utilities.css +310 -0
- package/src/ui/theme/ForgeThemeProvider.tsx +3 -2
- package/src/utils/_tests/ticketOrderOutcome.spec.ts +82 -0
- package/src/utils/ticketOrderOutcome.ts +57 -1
package/package.json
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import {
|
|
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 =
|
|
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 {
|
|
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 =
|
|
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 {
|
|
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 =
|
|
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 {
|
|
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 =
|
|
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(
|
|
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(
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
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(
|
|
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, {
|
|
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;
|