@tribe-nest/forge 3.20.0 → 3.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/data/queries/_tests/passTransfers.spec.ts +100 -4
- package/src/data/queries/useAccountSettings.ts +15 -2
- package/src/data/queries/useAuthActions.ts +51 -7
- package/src/data/queries/useEvents.ts +66 -4
- package/src/data/queries/useMembership.ts +8 -2
- package/src/data/queries/useMyBookings.ts +12 -0
- package/src/data/queries/useMyTickets.ts +112 -0
- package/src/data/queries/useOrders.ts +10 -0
- package/src/data/queries/usePassTransfers.ts +73 -13
- package/src/server/index.ts +52 -0
- package/src/types/models.ts +151 -0
- package/src/ui/format/_tests/attendees.spec.ts +231 -0
- package/src/ui/format/_tests/membershipGate.spec.ts +220 -0
- package/src/ui/format/attendees.ts +187 -0
- package/src/ui/format/membershipGate.ts +209 -0
- package/src/ui/headless/calendar/_tests/useAddToCalendar.spec.ts +83 -0
- package/src/ui/headless/calendar/useAddToCalendar.ts +46 -5
- package/src/ui/headless/checkout/_tests/inventoryHold.spec.ts +111 -0
- package/src/ui/headless/checkout/inventoryHold.ts +83 -0
- package/src/ui/headless/checkout/useCheckout.ts +72 -0
- package/src/ui/headless/checkout/useInventoryHold.ts +104 -0
- package/src/ui/headless/event/useEventCheckout.ts +133 -2
- package/src/ui/headless/event/usePresaleCode.ts +181 -0
- package/src/ui/headless/index.ts +25 -0
- package/src/ui/headless/membership/useMembershipGateNotice.ts +83 -0
- package/src/ui/headless/offer/OfferContext.tsx +55 -0
- package/src/ui/index.ts +42 -0
- package/src/ui/styled/AccountDashboard.tsx +70 -8
- package/src/ui/styled/AddToCalendar.tsx +34 -10
- package/src/ui/styled/Checkout.tsx +18 -1
- package/src/ui/styled/CoachingConfirmation.tsx +4 -0
- package/src/ui/styled/CourseDetail.tsx +30 -1
- package/src/ui/styled/EventConfirmation.tsx +2 -0
- package/src/ui/styled/EventDetail.tsx +53 -22
- package/src/ui/styled/EventTickets.tsx +156 -5
- package/src/ui/styled/HoldNotice.tsx +192 -0
- package/src/ui/styled/MembershipGateNotice.tsx +159 -0
- package/src/ui/styled/OfferButton.tsx +23 -0
- package/src/ui/styled/PresaleCode.tsx +174 -0
- package/src/ui/styled/ProductDetail.tsx +75 -5
- package/src/ui/styled/ProductGrid.tsx +26 -0
- package/src/ui/styled/TicketTransfer.tsx +69 -40
- package/src/ui/styled/_tests/AddToCalendar.spec.tsx +88 -0
- package/src/ui/styled/_tests/EventConfirmation.spec.tsx +5 -1
- package/src/ui/styled/_tests/PresaleCode.spec.tsx +106 -0
- package/src/utils/_tests/presaleCode.spec.ts +168 -0
- package/src/utils/_tests/structuredData.spec.ts +275 -0
- package/src/utils/presaleCode.ts +96 -0
- package/src/utils/structuredData.ts +361 -27
package/package.json
CHANGED
|
@@ -3,18 +3,19 @@ import {
|
|
|
3
3
|
forgetPassTransfer,
|
|
4
4
|
getRememberedPassTransfers,
|
|
5
5
|
isPendingPassTransfer,
|
|
6
|
+
mergePendingTransfer,
|
|
6
7
|
rememberPassTransfer,
|
|
7
8
|
} from "../usePassTransfers";
|
|
8
|
-
import { myTicketPassIds } from "../useMyTickets";
|
|
9
|
+
import { myTicketHeldPassIds, myTicketPassIds, type MyTicket } from "../useMyTickets";
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Events 2.2 — the pure decisions behind the buyer-facing transfer surface.
|
|
12
13
|
*
|
|
13
14
|
* Only the total functions are exercised; the hooks themselves are thin
|
|
14
15
|
* transport whose behaviour belongs to the API's own specs. The local store is
|
|
15
|
-
* the one thing here with real logic
|
|
16
|
-
*
|
|
17
|
-
*
|
|
16
|
+
* the one thing here with real logic; it used to be the ONLY record a buyer had
|
|
17
|
+
* of their own send, and is now demoted to a bridge across the refetch that
|
|
18
|
+
* follows one, with `my-tickets`' `pendingTransfer` as the source of truth.
|
|
18
19
|
*/
|
|
19
20
|
|
|
20
21
|
const clear = () => {
|
|
@@ -87,3 +88,98 @@ describe("the ids a transfer can be addressed by", () => {
|
|
|
87
88
|
expect(myTicketPassIds({ items: [{ id: "i", quantity: 1, price: 10, ticketTitle: null }] })).toEqual([]);
|
|
88
89
|
});
|
|
89
90
|
});
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* One live transfer per pass is a DATABASE guarantee (a partial unique index),
|
|
95
|
+
* so exactly one row may ever be drawn. Two would be a lie about the ticket's
|
|
96
|
+
* state and would offer two "withdraw" buttons for one thing.
|
|
97
|
+
*/
|
|
98
|
+
describe("mergePendingTransfer", () => {
|
|
99
|
+
const remembered = {
|
|
100
|
+
passId: "TN-1",
|
|
101
|
+
transferId: "local-1",
|
|
102
|
+
toEmail: "local@example.com",
|
|
103
|
+
expiresAt: "2026-04-01T00:00:00.000Z",
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const fromServer = {
|
|
107
|
+
id: "server-1",
|
|
108
|
+
status: "pending",
|
|
109
|
+
toName: null,
|
|
110
|
+
toEmail: "server@example.com",
|
|
111
|
+
expiresAt: "2026-04-02T00:00:00.000Z",
|
|
112
|
+
createdAt: "2026-03-30T00:00:00.000Z",
|
|
113
|
+
window: { open: true, secondsRemaining: 3600, expiresAt: "2026-04-02T00:00:00.000Z" },
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
it("is null when nothing is in flight anywhere", () => {
|
|
117
|
+
expect(mergePendingTransfer(null, [])).toBeNull();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("THE POINT: the server's row is what a second device sees", () => {
|
|
121
|
+
// `localStorage` is empty here — this browser never sent anything. Before
|
|
122
|
+
// the API carried `pendingTransfer` this returned nothing, which is why a
|
|
123
|
+
// sender on another device had no transfer to withdraw.
|
|
124
|
+
const merged = mergePendingTransfer(fromServer, [])!;
|
|
125
|
+
|
|
126
|
+
expect(merged.transferId).toBe("server-1");
|
|
127
|
+
expect(merged.toEmail).toBe("server@example.com");
|
|
128
|
+
expect(merged.fromServer).toBe(true);
|
|
129
|
+
expect(merged.open).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("the server WINS over a stale local record, rather than drawing both", () => {
|
|
133
|
+
const merged = mergePendingTransfer(fromServer, [remembered])!;
|
|
134
|
+
expect(merged.transferId).toBe("server-1");
|
|
135
|
+
expect(merged.fromServer).toBe(true);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("falls back to the local bridge before the refetch lands", () => {
|
|
139
|
+
const merged = mergePendingTransfer(null, [remembered])!;
|
|
140
|
+
expect(merged.transferId).toBe("local-1");
|
|
141
|
+
expect(merged.fromServer).toBe(false);
|
|
142
|
+
// Optimistic: a just-sent transfer is live by construction, and the server
|
|
143
|
+
// corrects it on the very next read.
|
|
144
|
+
expect(merged.open).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("reports a lapsed-but-unswept transfer as closed, and still withdrawable", () => {
|
|
148
|
+
const lapsed = { ...fromServer, window: { open: false, secondsRemaining: 0, expiresAt: fromServer.expiresAt } };
|
|
149
|
+
const merged = mergePendingTransfer(lapsed, [])!;
|
|
150
|
+
|
|
151
|
+
// Hiding it would put the sender back where they started: a live row they
|
|
152
|
+
// cannot address. The row stays, the copy changes.
|
|
153
|
+
expect(merged.open).toBe(false);
|
|
154
|
+
expect(merged.transferId).toBe("server-1");
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Which passes may an affordance be drawn on?
|
|
160
|
+
*
|
|
161
|
+
* Every pass-scoped endpoint — transfer, wallet, QR — resolves the current
|
|
162
|
+
* HOLDER, and a purchaser sees passes they do not hold: named guests at
|
|
163
|
+
* checkout, and (until the read was fixed) tickets already claimed by somebody
|
|
164
|
+
* else. A control on one of those could only ever answer 404.
|
|
165
|
+
*/
|
|
166
|
+
describe("myTicketHeldPassIds", () => {
|
|
167
|
+
const ticket = (passes: { id: string; isHolder?: boolean }[]) =>
|
|
168
|
+
({ items: [{ id: "item-1", quantity: passes.length, price: 10, ticketTitle: "GA", passes }] }) as unknown as MyTicket;
|
|
169
|
+
|
|
170
|
+
it("keeps only the passes the caller holds", () => {
|
|
171
|
+
const t = ticket([
|
|
172
|
+
{ id: "TN-1", isHolder: true },
|
|
173
|
+
{ id: "TN-2", isHolder: false },
|
|
174
|
+
]);
|
|
175
|
+
|
|
176
|
+
expect(myTicketHeldPassIds(t)).toEqual(["TN-1"]);
|
|
177
|
+
// The unfiltered helper still reports everything on the order — the wallet
|
|
178
|
+
// and transfer controls are the things that need narrowing, not the list.
|
|
179
|
+
expect(myTicketPassIds(t)).toEqual(["TN-1", "TN-2"]);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("treats an ABSENT flag as held, so an older API build is unchanged", () => {
|
|
183
|
+
expect(myTicketHeldPassIds(ticket([{ id: "TN-9" }]))).toEqual(["TN-9"]);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -69,13 +69,26 @@ export function useUpdateAccount() {
|
|
|
69
69
|
});
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* Change the member's password.
|
|
74
|
+
*
|
|
75
|
+
* Tenant-scoped (`/public/sessions/password`), because that is where a fan's
|
|
76
|
+
* credential lives — see the note at the top of `useAuthActions.ts`. The global
|
|
77
|
+
* `/public/accounts/password` this used to call wrote a hash that login never
|
|
78
|
+
* reads: it returned 200, invalidated the session, and left the old password
|
|
79
|
+
* working.
|
|
80
|
+
*
|
|
81
|
+
* Requires an association-scoped session (the endpoint is behind
|
|
82
|
+
* `requireAssociation`). Every current login path mints one; a member still
|
|
83
|
+
* holding a pre-cutover account-scoped token gets a 401 and needs to sign in
|
|
84
|
+
* again — honest, where the old call was silently a no-op.
|
|
85
|
+
*/
|
|
73
86
|
export function useChangePassword() {
|
|
74
87
|
const { client } = useForge();
|
|
75
88
|
|
|
76
89
|
return useMutation<unknown, unknown, { currentPassword: string; newPassword: string }>({
|
|
77
90
|
mutationFn: async (body) => {
|
|
78
|
-
const res = await client.put("/public/
|
|
91
|
+
const res = await client.put("/public/sessions/password", body);
|
|
79
92
|
return res.data;
|
|
80
93
|
},
|
|
81
94
|
});
|
|
@@ -2,26 +2,70 @@ import { useForge } from "../../provider/ForgeProvider";
|
|
|
2
2
|
import { useMutation } from "@tanstack/react-query";
|
|
3
3
|
|
|
4
4
|
// Stateless account actions (no session state) — kept out of PublicAuthContext.
|
|
5
|
+
//
|
|
6
|
+
// ## Why these hit `/public/sessions/*` and not `/public/accounts/*`
|
|
7
|
+
//
|
|
8
|
+
// A member's credential for a tenant lives on their `account_associations` row,
|
|
9
|
+
// not on `accounts` — that is the hash `POST /public/sessions` checks at login
|
|
10
|
+
// (see `resolveAssociationLogin` on the backend). `accounts.password` is the
|
|
11
|
+
// CREATOR/admin-dashboard credential; for a fan it is at most a lazy-heal
|
|
12
|
+
// fallback, gated on the account having some other relationship to the profile.
|
|
13
|
+
//
|
|
14
|
+
// These three calls used to point at the global `/public/accounts/*` endpoints,
|
|
15
|
+
// which write `accounts.password`. Every one of them returned 200 and changed
|
|
16
|
+
// nothing that login reads:
|
|
17
|
+
//
|
|
18
|
+
// - "Change password" logged the member out (a credential change invalidates
|
|
19
|
+
// sessions), then refused the new password and kept accepting the old one.
|
|
20
|
+
// - "Forgot password" was worse: it reset a hash login never consults, so the
|
|
21
|
+
// member — who by definition no longer knows the old password — was locked
|
|
22
|
+
// out with a reset link that appeared to work.
|
|
23
|
+
//
|
|
24
|
+
// The tenant-scoped endpoints below are the ones the credential actually lives
|
|
25
|
+
// behind, and they invalidate only this context's sessions.
|
|
5
26
|
|
|
6
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* Request a password-reset email for this tenant.
|
|
29
|
+
*
|
|
30
|
+
* `profileId` comes from the provider (one deploy = one tenant); pass it
|
|
31
|
+
* explicitly only when rendering outside a pinned context.
|
|
32
|
+
*/
|
|
7
33
|
export function useForgotPassword() {
|
|
8
|
-
const { client } = useForge();
|
|
34
|
+
const { client, profileId } = useForge();
|
|
9
35
|
|
|
10
|
-
return useMutation<unknown, unknown, { email: string; origin?: string }>({
|
|
11
|
-
mutationFn: async ({ email, origin }) => {
|
|
12
|
-
const
|
|
36
|
+
return useMutation<unknown, unknown, { email: string; origin?: string; profileId?: string }>({
|
|
37
|
+
mutationFn: async ({ email, origin, profileId: overrideProfileId }) => {
|
|
38
|
+
const contextId = overrideProfileId ?? profileId;
|
|
39
|
+
if (!contextId) {
|
|
40
|
+
// Better than posting without it: the endpoint would 400 on schema
|
|
41
|
+
// validation and the form would show "something went wrong".
|
|
42
|
+
throw new Error("useForgotPassword: no profileId — pass one or render inside a ForgeProvider with profileId");
|
|
43
|
+
}
|
|
44
|
+
const res = await client.post("/public/sessions/forgot-password", {
|
|
45
|
+
profileId: contextId,
|
|
46
|
+
email,
|
|
47
|
+
// The reset link is built server-side from this, and the association
|
|
48
|
+
// endpoint requires it (the global one treated it as optional).
|
|
49
|
+
origin: origin ?? (typeof window !== "undefined" ? window.location.origin : undefined),
|
|
50
|
+
});
|
|
13
51
|
return res.data;
|
|
14
52
|
},
|
|
15
53
|
});
|
|
16
54
|
}
|
|
17
55
|
|
|
18
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* Complete a password reset with the emailed token.
|
|
58
|
+
*
|
|
59
|
+
* The token is looked up on the association row, so it must be redeemed against
|
|
60
|
+
* the same surface that issued it — a token minted by the hook above will not
|
|
61
|
+
* resolve on `/public/accounts/reset-password`, and vice versa.
|
|
62
|
+
*/
|
|
19
63
|
export function useResetPassword() {
|
|
20
64
|
const { client } = useForge();
|
|
21
65
|
|
|
22
66
|
return useMutation<unknown, unknown, { token: string; password: string }>({
|
|
23
67
|
mutationFn: async ({ token, password }) => {
|
|
24
|
-
const res = await client.post("/public/
|
|
68
|
+
const res = await client.post("/public/sessions/reset-password", { token, password });
|
|
25
69
|
return res.data;
|
|
26
70
|
},
|
|
27
71
|
});
|
|
@@ -25,19 +25,48 @@ export function useEvents() {
|
|
|
25
25
|
* seed the query — the hook then returns data on first render with no loading
|
|
26
26
|
* state, so the page can be server-rendered for SEO with no client spinner.
|
|
27
27
|
*/
|
|
28
|
-
export function useEvent(
|
|
28
|
+
export function useEvent(
|
|
29
|
+
id?: string,
|
|
30
|
+
options?: {
|
|
31
|
+
initialData?: IEvent;
|
|
32
|
+
/**
|
|
33
|
+
* A presale code the buyer holds (1.2). A correct one reveals the hidden
|
|
34
|
+
* tiers it unlocks, in this same response, and sets `presale.codeAccepted`.
|
|
35
|
+
*
|
|
36
|
+
* Part of the query KEY, so entering a code refetches rather than serving
|
|
37
|
+
* the unrevealed cache — and so the seeded `initialData` (fetched without a
|
|
38
|
+
* code) is not mistaken for a coded response.
|
|
39
|
+
*/
|
|
40
|
+
accessCode?: string | null;
|
|
41
|
+
},
|
|
42
|
+
) {
|
|
29
43
|
const { client, profileId } = useForge();
|
|
44
|
+
const accessCode = options?.accessCode || undefined;
|
|
45
|
+
// Only callers that pass the option at all — presale-aware ones — get the
|
|
46
|
+
// hold-the-last-page behaviour below. Everyone else keeps the old semantics
|
|
47
|
+
// exactly, including "the event goes undefined while a DIFFERENT event
|
|
48
|
+
// loads", which is what a router navigation wants.
|
|
49
|
+
const presaleAware = !!options && "accessCode" in options;
|
|
30
50
|
|
|
31
51
|
return useQuery<IEvent>({
|
|
32
|
-
queryKey: ["event", id, profileId],
|
|
52
|
+
queryKey: ["event", id, profileId, accessCode ?? null],
|
|
33
53
|
queryFn: async () => {
|
|
34
54
|
const res = await client.get(`/public/events/${id}`, {
|
|
35
|
-
params: { profileId },
|
|
55
|
+
params: { profileId, ...(accessCode ? { accessCode } : {}) },
|
|
36
56
|
});
|
|
37
57
|
return res.data;
|
|
38
58
|
},
|
|
39
59
|
enabled: !!id && !!profileId && !!client,
|
|
40
|
-
|
|
60
|
+
// Seeding only applies to the uncoded query: `initialData` came from a
|
|
61
|
+
// loader that had no code, so handing it to a coded key would show an
|
|
62
|
+
// unrevealed tier list and claim the code was applied.
|
|
63
|
+
initialData: accessCode ? undefined : options?.initialData,
|
|
64
|
+
// Applying a code changes the query key, which would otherwise blank the
|
|
65
|
+
// whole event page back to a spinner while the coded read is in flight —
|
|
66
|
+
// the buyer presses Apply and the show they were reading disappears. Keep
|
|
67
|
+
// the last event on screen and let `isFetching` drive the field's own
|
|
68
|
+
// "Checking…".
|
|
69
|
+
placeholderData: presaleAware ? (previous?: IEvent) => previous : undefined,
|
|
41
70
|
});
|
|
42
71
|
}
|
|
43
72
|
|
|
@@ -49,6 +78,21 @@ export type CreateEventOrderInput = {
|
|
|
49
78
|
* the tier's own `price` — it can raise what is charged, never lower it.
|
|
50
79
|
*/
|
|
51
80
|
amounts?: Record<string, number>;
|
|
81
|
+
/**
|
|
82
|
+
* Who is actually coming, keyed by ticket id exactly as `items` is, one entry
|
|
83
|
+
* per seat of that tier in issue order (Events 2.1).
|
|
84
|
+
*
|
|
85
|
+
* Send it only when the event has `collectAttendeeDetails` on — the server
|
|
86
|
+
* does not read it otherwise. A list whose length does not equal that tier's
|
|
87
|
+
* quantity is REFUSED (a short list would leave a silent extra pass in the
|
|
88
|
+
* purchaser's name), so build it with `attendeesPayload` from
|
|
89
|
+
* `forge/ui`'s attendee helpers rather than by hand.
|
|
90
|
+
*
|
|
91
|
+
* `email` is accepted per attendee and deliberately not sent by either
|
|
92
|
+
* storefront: it is the pass's OWNER, so naming another address hands that
|
|
93
|
+
* seat away. Handing a ticket to somebody else is what transfer is for.
|
|
94
|
+
*/
|
|
95
|
+
attendees?: Record<string, { name: string; email?: string }[]>;
|
|
52
96
|
email: string;
|
|
53
97
|
firstName?: string;
|
|
54
98
|
lastName?: string;
|
|
@@ -60,6 +104,16 @@ export type CreateEventOrderInput = {
|
|
|
60
104
|
* the reason as its message and no order is created.
|
|
61
105
|
*/
|
|
62
106
|
couponCode?: string;
|
|
107
|
+
/**
|
|
108
|
+
* The PRESALE code (1.2) — not the discount code above. It buys access to a
|
|
109
|
+
* tier, it does not change a price.
|
|
110
|
+
*
|
|
111
|
+
* The sell guard runs against the tier's own column on every order, so this
|
|
112
|
+
* has to travel with the purchase and not merely with the read that revealed
|
|
113
|
+
* the tier: a buyer who unlocks a presale and then checks out without it gets
|
|
114
|
+
* their order refused after entering their card details.
|
|
115
|
+
*/
|
|
116
|
+
accessCode?: string;
|
|
63
117
|
};
|
|
64
118
|
|
|
65
119
|
export type CreateEventOrderResult = {
|
|
@@ -83,6 +137,14 @@ export type CreateEventOrderResult = {
|
|
|
83
137
|
couponId?: string | null;
|
|
84
138
|
/** Every discount that applied, entered OR automatic. */
|
|
85
139
|
appliedCoupons?: AppliedDiscountCoupon[];
|
|
140
|
+
/**
|
|
141
|
+
* When these tickets stop being reserved, ISO-8601.
|
|
142
|
+
*
|
|
143
|
+
* `null` for a free order — its hold has already converted by the time this
|
|
144
|
+
* returns, so a countdown on it would be counting something that no longer
|
|
145
|
+
* exists — and for a profile with inventory holds switched off.
|
|
146
|
+
*/
|
|
147
|
+
holdExpiresAt?: string | null;
|
|
86
148
|
};
|
|
87
149
|
|
|
88
150
|
/** Create an event ticket order (the step before start-payment). */
|
|
@@ -2,7 +2,13 @@ import type { MembershipTier } from "../../types/models";
|
|
|
2
2
|
import { useForge } from "../../provider/ForgeProvider";
|
|
3
3
|
import { useQuery } from "@tanstack/react-query";
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* `opts.enabled` gates the request on top of the usual profile/client checks —
|
|
7
|
+
* for a caller that only needs tier NAMES when something is actually gated, and
|
|
8
|
+
* must not add a request to every ungated storefront. Defaults to on, so every
|
|
9
|
+
* existing caller is unchanged.
|
|
10
|
+
*/
|
|
11
|
+
export function useGetMembershipTiers(opts: { enabled?: boolean } = {}) {
|
|
6
12
|
const { client, profileId } = useForge();
|
|
7
13
|
|
|
8
14
|
return useQuery<MembershipTier[]>({
|
|
@@ -15,6 +21,6 @@ export function useGetMembershipTiers() {
|
|
|
15
21
|
});
|
|
16
22
|
return res.data;
|
|
17
23
|
},
|
|
18
|
-
enabled: !!profileId && !!client,
|
|
24
|
+
enabled: !!profileId && !!client && opts.enabled !== false,
|
|
19
25
|
});
|
|
20
26
|
}
|
|
@@ -111,6 +111,18 @@ export type MyBooking = {
|
|
|
111
111
|
/** The server's answer. Never recomputed here. */
|
|
112
112
|
canReschedule: boolean;
|
|
113
113
|
rescheduleReason: RescheduleRefusal | null;
|
|
114
|
+
/**
|
|
115
|
+
* The session's `.ics`, both forms — what lets the portal offer Apple
|
|
116
|
+
* Calendar rather than only Google/Outlook/Microsoft 365. `null` unless the
|
|
117
|
+
* booking is confirmed.
|
|
118
|
+
*
|
|
119
|
+
* The address is stable across a RESCHEDULE (it is HMAC(kind:id) and the
|
|
120
|
+
* booking id does not change), which is the mechanism by which a re-fetch
|
|
121
|
+
* moves the entry already in the buyer's calendar instead of adding a second
|
|
122
|
+
* one. Optional: an older API build does not emit it.
|
|
123
|
+
*/
|
|
124
|
+
calendarUrl?: string | null;
|
|
125
|
+
calendarWebcalUrl?: string | null;
|
|
114
126
|
};
|
|
115
127
|
|
|
116
128
|
export type CancelBookingResult = {
|
|
@@ -49,8 +49,65 @@ export type MyTicketPass = {
|
|
|
49
49
|
ownerEmail?: string | null;
|
|
50
50
|
/** Set once the pass has been scanned at the door. */
|
|
51
51
|
checkedInAt?: string | null;
|
|
52
|
+
/**
|
|
53
|
+
* Does the CALLER hold this pass right now?
|
|
54
|
+
*
|
|
55
|
+
* Not the same question as "is it on my order", and the difference is the one
|
|
56
|
+
* that trips people up. A purchaser who named guests at checkout owns passes
|
|
57
|
+
* carrying their guests' addresses from the moment they are written, and a
|
|
58
|
+
* transfer moves the holder without touching the order. Every pass-scoped
|
|
59
|
+
* endpoint — transfer, wallet, QR — resolves the HOLDER, so a control drawn
|
|
60
|
+
* on a pass the caller does not hold can only ever 404.
|
|
61
|
+
*
|
|
62
|
+
* Optional because it is newer than the endpoint. `myTicketHeldPassIds`
|
|
63
|
+
* treats its absence as "assume held", which is the old behaviour.
|
|
64
|
+
*/
|
|
65
|
+
isHolder?: boolean;
|
|
66
|
+
/**
|
|
67
|
+
* The caller's OWN live transfer on this pass, straight from the API.
|
|
68
|
+
*
|
|
69
|
+
* Only ever a transfer THIS reader sent (the server matches `from_email`) and
|
|
70
|
+
* only while it is `pending`, so it never exposes the operator-only chain of
|
|
71
|
+
* previous holders. `null` when nothing is in flight.
|
|
72
|
+
*
|
|
73
|
+
* This is what replaced `localStorage` as the source of truth for "you have a
|
|
74
|
+
* transfer out on this ticket": the id used to exist nowhere but the response
|
|
75
|
+
* to the sender's own send, so the same person on a second device had nothing
|
|
76
|
+
* to withdraw.
|
|
77
|
+
*/
|
|
78
|
+
pendingTransfer?: MyTicketPendingTransfer | null;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** The live claim window, computed server-side on every read. */
|
|
82
|
+
export type MyTicketTransferWindow = {
|
|
83
|
+
/** The claim link still works right now. */
|
|
84
|
+
open: boolean;
|
|
85
|
+
/** Whole seconds left when the server answered. `null` when there is no window. */
|
|
86
|
+
secondsRemaining: number | null;
|
|
87
|
+
/** The instant the link dies. */
|
|
88
|
+
expiresAt: string | null;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A pending transfer as the SENDER may see it — deliberately narrow.
|
|
93
|
+
*
|
|
94
|
+
* An id to address the withdraw endpoint with, the recipient THIS caller typed
|
|
95
|
+
* themselves, and the two timestamps that make "sent 2 hours ago, expires in
|
|
96
|
+
* 46" renderable. No token, no previous holder, no account ids.
|
|
97
|
+
*/
|
|
98
|
+
export type MyTicketPendingTransfer = {
|
|
99
|
+
id: string;
|
|
100
|
+
status: string;
|
|
101
|
+
toName: string | null;
|
|
102
|
+
toEmail: string;
|
|
103
|
+
expiresAt: string;
|
|
104
|
+
createdAt: string;
|
|
105
|
+
window: MyTicketTransferWindow;
|
|
52
106
|
};
|
|
53
107
|
|
|
108
|
+
/** How the caller comes to be looking at an order. */
|
|
109
|
+
export type MyTicketRole = "purchaser" | "holder";
|
|
110
|
+
|
|
54
111
|
export type MyTicketItem = {
|
|
55
112
|
id: string;
|
|
56
113
|
quantity: number;
|
|
@@ -78,6 +135,27 @@ export type MyTicket = {
|
|
|
78
135
|
eventStatus: string | null;
|
|
79
136
|
items: MyTicketItem[];
|
|
80
137
|
cancellation: TicketCancellation;
|
|
138
|
+
/**
|
|
139
|
+
* `purchaser` — they paid for this order. `holder` — a pass out of it was
|
|
140
|
+
* TRANSFERRED to them and they bought nothing.
|
|
141
|
+
*
|
|
142
|
+
* A holder's row is scrubbed server-side: `totalAmount` and every line price
|
|
143
|
+
* are null, lines they hold no pass on are gone, and `cancellation.canCancel`
|
|
144
|
+
* is false (cancelling refunds the sender's money and voids every seat, which
|
|
145
|
+
* is not a gift recipient's call). Render the money block only for a
|
|
146
|
+
* purchaser — `Number(null)` is `0`, and a confident "$0.00" on somebody
|
|
147
|
+
* else's purchase is worse than saying nothing.
|
|
148
|
+
*
|
|
149
|
+
* Optional: an older API build emits no role, and treating that as
|
|
150
|
+
* `purchaser` reproduces exactly the previous behaviour.
|
|
151
|
+
*/
|
|
152
|
+
role?: MyTicketRole;
|
|
153
|
+
/**
|
|
154
|
+
* The event's `.ics`, both forms — what lets the portal offer Apple Calendar
|
|
155
|
+
* rather than only the three template providers. Null for a cancelled show.
|
|
156
|
+
*/
|
|
157
|
+
calendarUrl?: string | null;
|
|
158
|
+
calendarWebcalUrl?: string | null;
|
|
81
159
|
/**
|
|
82
160
|
* Not emitted by the API, which nests passes under each ITEM. Tolerated by
|
|
83
161
|
* `myTicketPassIds` so an order-level shape would not need a frontend change.
|
|
@@ -111,6 +189,40 @@ export function myTicketPassIds(ticket: Pick<MyTicket, "passes" | "items">): str
|
|
|
111
189
|
return [...seen];
|
|
112
190
|
}
|
|
113
191
|
|
|
192
|
+
/** Every pass on an order, flattened — the objects, not just the ids. */
|
|
193
|
+
export function myTicketPasses(ticket: Pick<MyTicket, "passes" | "items">): MyTicketPass[] {
|
|
194
|
+
const seen = new Map<string, MyTicketPass>();
|
|
195
|
+
const collect = (passes?: MyTicketPass[]) => {
|
|
196
|
+
for (const pass of passes ?? []) {
|
|
197
|
+
if (typeof pass?.id === "string" && /^TN-\d+$/.test(pass.id) && !seen.has(pass.id)) {
|
|
198
|
+
seen.set(pass.id, pass);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
collect(ticket.passes);
|
|
204
|
+
for (const item of ticket.items ?? []) collect(item.passes);
|
|
205
|
+
|
|
206
|
+
return [...seen.values()];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The passes the caller can actually ACT on — transfer, wallet, QR.
|
|
211
|
+
*
|
|
212
|
+
* Every pass-scoped endpoint resolves the current HOLDER, so drawing those
|
|
213
|
+
* controls on a pass the caller merely paid for (a named guest's seat, a ticket
|
|
214
|
+
* already claimed by somebody else) produced a 404 with no explanation. The
|
|
215
|
+
* affordance is now drawn from the server's own `isHolder`.
|
|
216
|
+
*
|
|
217
|
+
* A pass with no `isHolder` at all is treated as held: that is an older API
|
|
218
|
+
* build, and the previous behaviour was to offer the control for every pass.
|
|
219
|
+
*/
|
|
220
|
+
export function myTicketHeldPassIds(ticket: Pick<MyTicket, "passes" | "items">): string[] {
|
|
221
|
+
return myTicketPasses(ticket)
|
|
222
|
+
.filter((pass) => pass.isHolder !== false)
|
|
223
|
+
.map((pass) => pass.id);
|
|
224
|
+
}
|
|
225
|
+
|
|
114
226
|
/**
|
|
115
227
|
* The buyer is resolved from the SESSION server-side — `accountId` here only
|
|
116
228
|
* gates the query on being signed in and keys the cache. It is never sent, and
|
|
@@ -56,6 +56,16 @@ export type CreateOrderResult = {
|
|
|
56
56
|
totalAmount: number;
|
|
57
57
|
subTotal: number;
|
|
58
58
|
shippingCosts?: { deliveryGroupId: string; amount: number; currency: string }[];
|
|
59
|
+
/**
|
|
60
|
+
* When the stock reserved for this cart goes back on sale, ISO-8601.
|
|
61
|
+
*
|
|
62
|
+
* `null` when nothing was reserved — an order that already settled (a free
|
|
63
|
+
* one), a cart of digital/service items, or a profile with inventory holds
|
|
64
|
+
* switched off. `start-payment` restarts this clock and reports the newer
|
|
65
|
+
* instant, so treat that one as authoritative once it arrives; this is what
|
|
66
|
+
* bridges the gap while it is in flight.
|
|
67
|
+
*/
|
|
68
|
+
holdExpiresAt?: string | null;
|
|
59
69
|
};
|
|
60
70
|
|
|
61
71
|
/** Create a cart/product order (the step before start-payment). */
|
|
@@ -37,21 +37,27 @@ import { useForge } from "../../provider/ForgeProvider";
|
|
|
37
37
|
* cancelled transfer are all its answers to give, each with a distinct
|
|
38
38
|
* message.
|
|
39
39
|
*
|
|
40
|
-
* ##
|
|
40
|
+
* ## Where a pending transfer comes from — the SERVER, now
|
|
41
41
|
*
|
|
42
42
|
* `GET /public/events/passes/:passId/transfers` is gated on the operator
|
|
43
|
-
* permission `events.read`
|
|
44
|
-
* current one has no business reading
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
43
|
+
* permission `events.read` and stays that way: the chain names every previous
|
|
44
|
+
* holder, which the current one has no business reading. For a while that left
|
|
45
|
+
* a buyer with no endpoint listing their own pending transfer back to them —
|
|
46
|
+
* the id existed nowhere but the response to their own send — so this file
|
|
47
|
+
* remembered it in `localStorage`, exactly as `useEventWaitlist` does for an
|
|
48
|
+
* anonymous joiner's entry id.
|
|
48
49
|
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
50
|
+
* `/public/events/my-tickets` now carries `pendingTransfer` on each pass:
|
|
51
|
+
* `status = 'pending'` AND a `from_email` match on the reader, so the only row
|
|
52
|
+
* that can surface is one the reader sent. **That is the source of truth.** A
|
|
53
|
+
* sender who signed in on a second device can see and withdraw a transfer they
|
|
54
|
+
* started on the first, which is the case `localStorage` could never serve.
|
|
55
|
+
*
|
|
56
|
+
* The local store is KEPT, demoted to what it always really was: a same-render
|
|
57
|
+
* bridge. `my-tickets` is a paginated query that has to round-trip before the
|
|
58
|
+
* new row appears, and a just-sent transfer vanishing for a second reads as a
|
|
59
|
+
* failed send. `mergePendingTransfers` below prefers the server's copy whenever
|
|
60
|
+
* it has one, so the two can never both draw a row.
|
|
55
61
|
*/
|
|
56
62
|
|
|
57
63
|
/** The lifecycle, restated (Forge cannot import from the API). */
|
|
@@ -202,13 +208,67 @@ export const forgetPassTransfer = (transferId: string) => {
|
|
|
202
208
|
* The remembered pending sends, as a REACTIVE read.
|
|
203
209
|
*
|
|
204
210
|
* There is no query to invalidate, so a plain read would leave a just-sent
|
|
205
|
-
* transfer invisible until
|
|
211
|
+
* transfer invisible until `my-tickets` refetches.
|
|
206
212
|
*/
|
|
207
213
|
export function usePendingPassTransfers(passId?: string): RememberedPassTransfer[] {
|
|
208
214
|
const all = useSyncExternalStore(subscribe, snapshot, serverSnapshot);
|
|
209
215
|
return useMemo(() => (passId ? all.filter((entry) => entry.passId === passId) : all), [all, passId]);
|
|
210
216
|
}
|
|
211
217
|
|
|
218
|
+
/** One pending transfer, however the caller came by it. */
|
|
219
|
+
export type PendingTransferView = {
|
|
220
|
+
/** The id the withdraw endpoint takes. */
|
|
221
|
+
transferId: string;
|
|
222
|
+
toEmail: string;
|
|
223
|
+
/** The instant the claim link dies. `null` when unknown (a local-only row). */
|
|
224
|
+
expiresAt: string | null;
|
|
225
|
+
/** False once the window has lapsed — server-computed, never re-derived. */
|
|
226
|
+
open: boolean;
|
|
227
|
+
/** True when this came off `my-tickets` rather than out of `localStorage`. */
|
|
228
|
+
fromServer: boolean;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The pending transfer to DRAW for one pass — the server's if it has one, the
|
|
233
|
+
* local bridge otherwise.
|
|
234
|
+
*
|
|
235
|
+
* De-duplication matters more than it looks: one live transfer per pass is a
|
|
236
|
+
* DATABASE guarantee (a partial unique index), so two rows on screen would be a
|
|
237
|
+
* lie about the ticket's state and would offer two "withdraw" buttons for one
|
|
238
|
+
* thing. The server wins whenever it answers, because it also knows whether the
|
|
239
|
+
* window is still open.
|
|
240
|
+
*
|
|
241
|
+
* `null` means nothing is in flight — a pass with no outstanding transfer, on
|
|
242
|
+
* any device.
|
|
243
|
+
*/
|
|
244
|
+
export function mergePendingTransfer(
|
|
245
|
+
serverTransfer: { id: string; toEmail: string; expiresAt: string; window?: { open: boolean } } | null | undefined,
|
|
246
|
+
remembered: RememberedPassTransfer[],
|
|
247
|
+
): PendingTransferView | null {
|
|
248
|
+
if (serverTransfer) {
|
|
249
|
+
return {
|
|
250
|
+
transferId: serverTransfer.id,
|
|
251
|
+
toEmail: serverTransfer.toEmail,
|
|
252
|
+
expiresAt: serverTransfer.expiresAt ?? null,
|
|
253
|
+
// A lapsed-but-unswept transfer is still `pending` and still withdrawable;
|
|
254
|
+
// the server says `open: false` and the UI says so too rather than hiding
|
|
255
|
+
// a live row the sender cannot otherwise address.
|
|
256
|
+
open: serverTransfer.window?.open ?? true,
|
|
257
|
+
fromServer: true,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const local = remembered[0];
|
|
262
|
+
if (!local) return null;
|
|
263
|
+
return {
|
|
264
|
+
transferId: local.transferId,
|
|
265
|
+
toEmail: local.toEmail,
|
|
266
|
+
expiresAt: local.expiresAt,
|
|
267
|
+
open: true,
|
|
268
|
+
fromServer: false,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
212
272
|
// ── Hooks ───────────────────────────────────────────────────────────────────
|
|
213
273
|
|
|
214
274
|
/**
|