@tribe-nest/forge 3.4.0 → 3.9.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/eventWaitlist.spec.ts +122 -0
- package/src/data/queries/_tests/passTransfers.spec.ts +89 -0
- package/src/data/queries/_tests/walletPass.spec.ts +159 -0
- package/src/data/queries/useEventWaitlist.ts +429 -0
- package/src/data/queries/useMyBookings.ts +211 -0
- package/src/data/queries/useMyTickets.ts +154 -0
- package/src/data/queries/usePassTransfers.ts +318 -0
- package/src/data/queries/useWalletPass.ts +236 -0
- package/src/index.ts +5 -0
- package/src/server/_tests/siteBootstrap.spec.ts +131 -0
- package/src/server/index.ts +122 -6
- package/src/types/diagnostics.ts +49 -0
- package/src/ui/index.ts +19 -0
- package/src/ui/shell/PoweredBy.tsx +6 -4
- package/src/ui/shell/PreviewDiagnostics.tsx +80 -0
- package/src/ui/shell/TribeNestApp.tsx +24 -0
- package/src/ui/shell/diagnosticsGating.spec.ts +102 -0
- package/src/ui/shell/diagnosticsGating.ts +90 -0
- package/src/ui/shell/shellGating.spec.ts +40 -1
- package/src/ui/shell/shellGating.ts +33 -0
- package/src/ui/styled/AccountDashboard.tsx +598 -1
- package/src/ui/styled/EventDetail.tsx +6 -0
- package/src/ui/styled/EventWaitlist.tsx +448 -0
- package/src/ui/styled/TicketTransfer.tsx +393 -0
- package/src/ui/styled/WalletPassButtons.tsx +208 -0
- package/src/ui/styled/_tests/WalletPassButtons.spec.tsx +223 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { useForge } from "../../provider/ForgeProvider";
|
|
2
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import type { PaginatedData } from "../../types/models";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The signed-in buyer's own event tickets, and cancelling one (S.6).
|
|
7
|
+
*
|
|
8
|
+
* Lives in Forge rather than in either app because both rendering surfaces —
|
|
9
|
+
* the client PWA and code websites — need exactly this, and a second copy is
|
|
10
|
+
* how the two drift.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Why the buyer cannot cancel, when they cannot. */
|
|
14
|
+
export type TicketCancellationReason = "no_policy" | "not_allowed" | "window_closed" | "event_started";
|
|
15
|
+
|
|
16
|
+
export type TicketCancellation = {
|
|
17
|
+
policy: "none" | "until" | "anytime" | null;
|
|
18
|
+
cutoffHours: number | null;
|
|
19
|
+
terms: string | null;
|
|
20
|
+
/** One sentence, already phrased for a buyer. Null when no policy was set. */
|
|
21
|
+
description: string | null;
|
|
22
|
+
canCancel: boolean;
|
|
23
|
+
reason: TicketCancellationReason | null;
|
|
24
|
+
/** The instant the window shuts. */
|
|
25
|
+
deadline: string | null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* One admitted person — an `event_passes` row, which is what a QR code, a
|
|
30
|
+
* transfer and a wallet pass are each *of*.
|
|
31
|
+
*
|
|
32
|
+
* The distinction matters because it is the one that trips people up: an order
|
|
33
|
+
* is a purchase, an ITEM is a purchase line ("2 × General Admission"), and a
|
|
34
|
+
* PASS is a single human being at a door. `MyTicket.id` is an order id and
|
|
35
|
+
* `MyTicketItem.id` is an order-item id, and NEITHER is accepted by any
|
|
36
|
+
* pass-scoped endpoint — `/public/events/passes/:passId/wallet` and
|
|
37
|
+
* `/public/events/passes/:passId/transfers` both take a `TN-…` id and answer
|
|
38
|
+
* 404 for anything else.
|
|
39
|
+
*
|
|
40
|
+
* Optional because the field is newer than the endpoint that reads it, and
|
|
41
|
+
* because a site pinned to an older API build would not have it. Always read it
|
|
42
|
+
* through `myTicketPassIds`, which tolerates its absence and yields `[]` — the
|
|
43
|
+
* "draw nothing" answer the wallet affordance needs anyway.
|
|
44
|
+
*/
|
|
45
|
+
export type MyTicketPass = {
|
|
46
|
+
/** `event_passes.id` — the string `TN-` followed by digits. */
|
|
47
|
+
id: string;
|
|
48
|
+
ownerName?: string | null;
|
|
49
|
+
ownerEmail?: string | null;
|
|
50
|
+
/** Set once the pass has been scanned at the door. */
|
|
51
|
+
checkedInAt?: string | null;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type MyTicketItem = {
|
|
55
|
+
id: string;
|
|
56
|
+
quantity: number;
|
|
57
|
+
price: string | number;
|
|
58
|
+
ticketTitle: string | null;
|
|
59
|
+
/** The individual admissions on this line — where the API puts them. */
|
|
60
|
+
passes?: MyTicketPass[];
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export type MyTicket = {
|
|
64
|
+
id: string;
|
|
65
|
+
status: string;
|
|
66
|
+
totalAmount: string | number;
|
|
67
|
+
currency: string | null;
|
|
68
|
+
createdAt: string;
|
|
69
|
+
refundState: string;
|
|
70
|
+
refundedAmountCents: string | number;
|
|
71
|
+
selfCancelledAt: string | null;
|
|
72
|
+
eventId: string;
|
|
73
|
+
eventTitle: string;
|
|
74
|
+
eventSlug: string | null;
|
|
75
|
+
eventDateTime: string;
|
|
76
|
+
eventEndDateTime: string | null;
|
|
77
|
+
eventTimezone: string | null;
|
|
78
|
+
eventStatus: string | null;
|
|
79
|
+
items: MyTicketItem[];
|
|
80
|
+
cancellation: TicketCancellation;
|
|
81
|
+
/**
|
|
82
|
+
* Not emitted by the API, which nests passes under each ITEM. Tolerated by
|
|
83
|
+
* `myTicketPassIds` so an order-level shape would not need a frontend change.
|
|
84
|
+
*/
|
|
85
|
+
passes?: MyTicketPass[];
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Every pass id on an order.
|
|
90
|
+
*
|
|
91
|
+
* Reads the item-level passes the API emits, and tolerates an order-level array
|
|
92
|
+
* as well — de-duplicated, because a payload carrying both would otherwise draw
|
|
93
|
+
* each ticket's wallet buttons twice.
|
|
94
|
+
*
|
|
95
|
+
* Anything that is not a `TN-…` id is dropped rather than sent: the pass-scoped
|
|
96
|
+
* endpoints 404 on the wrong shape, and a request that cannot succeed is not
|
|
97
|
+
* worth making. An older API build with no passes in the payload yields `[]`,
|
|
98
|
+
* which callers must treat as "draw nothing" rather than as a failure.
|
|
99
|
+
*/
|
|
100
|
+
export function myTicketPassIds(ticket: Pick<MyTicket, "passes" | "items">): string[] {
|
|
101
|
+
const seen = new Set<string>();
|
|
102
|
+
const collect = (passes?: MyTicketPass[]) => {
|
|
103
|
+
for (const pass of passes ?? []) {
|
|
104
|
+
if (typeof pass?.id === "string" && /^TN-\d+$/.test(pass.id)) seen.add(pass.id);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
collect(ticket.passes);
|
|
109
|
+
for (const item of ticket.items ?? []) collect(item.passes);
|
|
110
|
+
|
|
111
|
+
return [...seen];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The buyer is resolved from the SESSION server-side — `accountId` here only
|
|
116
|
+
* gates the query on being signed in and keys the cache. It is never sent, and
|
|
117
|
+
* sending it would not help: the API ignores any buyer identity from input.
|
|
118
|
+
*/
|
|
119
|
+
export function useMyTickets(accountId?: string, page = 1, limit = 20) {
|
|
120
|
+
const { client, profileId } = useForge();
|
|
121
|
+
|
|
122
|
+
return useQuery<PaginatedData<MyTicket>>({
|
|
123
|
+
queryKey: ["my-tickets", accountId, profileId, page, limit],
|
|
124
|
+
queryFn: async () => {
|
|
125
|
+
const res = await client.get("/public/events/my-tickets", {
|
|
126
|
+
params: { profileId, page, limit },
|
|
127
|
+
});
|
|
128
|
+
return res.data;
|
|
129
|
+
},
|
|
130
|
+
enabled: !!accountId && !!profileId && !!client,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Cancel one of the buyer's own tickets.
|
|
136
|
+
*
|
|
137
|
+
* Invalidates the list on success so the row reflects its new state rather than
|
|
138
|
+
* offering a button that would now be refused — the server is the authority on
|
|
139
|
+
* eligibility, and re-reading is cheaper than mirroring its rules here.
|
|
140
|
+
*/
|
|
141
|
+
export function useCancelMyTicket() {
|
|
142
|
+
const { client, profileId } = useForge();
|
|
143
|
+
const queryClient = useQueryClient();
|
|
144
|
+
|
|
145
|
+
return useMutation<{ orderId: string; refunded: boolean }, unknown, { orderId: string }>({
|
|
146
|
+
mutationFn: async ({ orderId }) => {
|
|
147
|
+
const res = await client.post(`/public/events/my-tickets/${orderId}/cancel`, { profileId });
|
|
148
|
+
return res.data;
|
|
149
|
+
},
|
|
150
|
+
onSuccess: () => {
|
|
151
|
+
void queryClient.invalidateQueries({ queryKey: ["my-tickets"] });
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { useMemo, useSyncExternalStore } from "react";
|
|
2
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import { useForge } from "../../provider/ForgeProvider";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Events 2.2 — handing a ticket to somebody else, and taking one.
|
|
7
|
+
*
|
|
8
|
+
* Lives in Forge rather than in either app because both rendering surfaces —
|
|
9
|
+
* the client PWA and code websites — need exactly this, and a second copy is
|
|
10
|
+
* how the two drift (the same reason `useMyTickets` and `useEventWaitlist` are
|
|
11
|
+
* here).
|
|
12
|
+
*
|
|
13
|
+
* ## The endpoints
|
|
14
|
+
*
|
|
15
|
+
* ```
|
|
16
|
+
* POST /public/events/passes/:passId/transfers send → PassTransfer (201)
|
|
17
|
+
* POST /public/events/transfers/claim claim → PassTransfer & { eventPassId }
|
|
18
|
+
* POST /public/events/transfers/:id/cancel cancel → PassTransfer
|
|
19
|
+
* GET /public/events/passes/:passId/transfers history → OPERATOR ONLY (see below)
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* `:passId` is an `event_passes` id — the string `TN-` followed by digits, NOT
|
|
23
|
+
* an order or order-item id. `myTicketPassIds()` in `useMyTickets` is how a
|
|
24
|
+
* buyer's portal gets one.
|
|
25
|
+
*
|
|
26
|
+
* ## Three things the server owns, which this file deliberately does not mirror
|
|
27
|
+
*
|
|
28
|
+
* 1. **Who may send.** The current holder, resolved from the SESSION against
|
|
29
|
+
* the pass's `owner_email`. No identity is ever sent from here; a `fromEmail`
|
|
30
|
+
* parameter is exactly the vulnerability the endpoint is shaped to avoid.
|
|
31
|
+
* 2. **Whether a pass may be handed on at all.** Checked-in, refunded, cancelled
|
|
32
|
+
* show, finished show — all refused server-side, at SEND and again at CLAIM
|
|
33
|
+
* because days pass in between. The UI draws the affordance and surfaces the
|
|
34
|
+
* server's own message on refusal; it never pre-judges the answer.
|
|
35
|
+
* 3. **Whether a claim succeeds.** The token is the capability and the claim is
|
|
36
|
+
* one atomic conditional UPDATE. A second click, an expired window and a
|
|
37
|
+
* cancelled transfer are all its answers to give, each with a distinct
|
|
38
|
+
* message.
|
|
39
|
+
*
|
|
40
|
+
* ## Why there is a local store for pending sends
|
|
41
|
+
*
|
|
42
|
+
* `GET /public/events/passes/:passId/transfers` is gated on the operator
|
|
43
|
+
* permission `events.read` — the chain names every previous holder, which the
|
|
44
|
+
* current one has no business reading — and nothing on `/public/events/my-tickets`
|
|
45
|
+
* carries transfer state. So a BUYER has no endpoint that lists their own
|
|
46
|
+
* pending transfer back to them: the only place a transfer id ever appears is
|
|
47
|
+
* the response to their own send.
|
|
48
|
+
*
|
|
49
|
+
* That id is remembered in `localStorage`, exactly as `useEventWaitlist` does
|
|
50
|
+
* for an anonymous joiner's entry id and for the same reason — it is the only
|
|
51
|
+
* way the browser that performed an action can show it again. It is a
|
|
52
|
+
* convenience, not a source of truth: the durable copy is the recipient's email,
|
|
53
|
+
* and every cancel is still decided by the server. A buyer on another device
|
|
54
|
+
* sees no pending transfer, which is a gap in the API rather than in this file.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/** The lifecycle, restated (Forge cannot import from the API). */
|
|
58
|
+
export type PassTransferStatus = "pending" | "claimed" | "cancelled" | "expired";
|
|
59
|
+
|
|
60
|
+
/** The live claim window, computed server-side on every read. */
|
|
61
|
+
export type PassTransferWindow = {
|
|
62
|
+
/** The claim link still works right now. */
|
|
63
|
+
open: boolean;
|
|
64
|
+
/** Whole seconds left when the server answered. `null` when there is no window. */
|
|
65
|
+
secondsRemaining: number | null;
|
|
66
|
+
/** The instant the link dies. */
|
|
67
|
+
expiresAt: string | null;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export type PassTransfer = {
|
|
71
|
+
id: string;
|
|
72
|
+
/** The `TN-…` pass this transfer is of. */
|
|
73
|
+
eventPassId: string;
|
|
74
|
+
eventId: string;
|
|
75
|
+
status: PassTransferStatus | string;
|
|
76
|
+
/** Denormalised at send time — the pass's own columns have moved on by now. */
|
|
77
|
+
fromName: string | null;
|
|
78
|
+
fromEmail: string;
|
|
79
|
+
toName: string | null;
|
|
80
|
+
toEmail: string;
|
|
81
|
+
expiresAt: string;
|
|
82
|
+
claimedAt: string | null;
|
|
83
|
+
cancelledAt: string | null;
|
|
84
|
+
createdAt: string;
|
|
85
|
+
window: PassTransferWindow;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export type SendPassTransferInput = {
|
|
89
|
+
/** The pass to hand over — a `TN-…` id from `myTicketPassIds()`. */
|
|
90
|
+
passId: string;
|
|
91
|
+
/** Where it goes. The only address the endpoint accepts. */
|
|
92
|
+
toEmail: string;
|
|
93
|
+
toName?: string;
|
|
94
|
+
/**
|
|
95
|
+
* How long the claim link lives, in hours. Bounded [1, 720] server-side and
|
|
96
|
+
* defaulted to 72 there; omit it and take the server's default rather than
|
|
97
|
+
* restating a number that would then have two definitions.
|
|
98
|
+
*/
|
|
99
|
+
expiresInHours?: number;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export type ClaimPassTransferInput = {
|
|
103
|
+
/** 64 hex characters, out of the recipient's email link. */
|
|
104
|
+
token: string;
|
|
105
|
+
/** What the claimant is called at the door. Optional; the server falls back. */
|
|
106
|
+
name?: string;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/** Still live — the only status a cancel can act on. */
|
|
110
|
+
export const isPendingPassTransfer = (transfer: Pick<PassTransfer, "status">): boolean =>
|
|
111
|
+
transfer.status === "pending";
|
|
112
|
+
|
|
113
|
+
// ── The sender's own pending transfers, remembered locally ──────────────────
|
|
114
|
+
//
|
|
115
|
+
// See the file docblock: there is no buyer-readable list endpoint, so this is
|
|
116
|
+
// the only way the browser that sent a transfer can offer to cancel it.
|
|
117
|
+
|
|
118
|
+
const STORAGE_KEY = "tn.pass-transfers.pending";
|
|
119
|
+
|
|
120
|
+
export type RememberedPassTransfer = {
|
|
121
|
+
passId: string;
|
|
122
|
+
transferId: string;
|
|
123
|
+
toEmail: string;
|
|
124
|
+
/** The server's window end, kept so the row can say when the link dies. */
|
|
125
|
+
expiresAt: string | null;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const isRemembered = (item: unknown): item is RememberedPassTransfer =>
|
|
129
|
+
!!item &&
|
|
130
|
+
typeof (item as RememberedPassTransfer).passId === "string" &&
|
|
131
|
+
typeof (item as RememberedPassTransfer).transferId === "string" &&
|
|
132
|
+
typeof (item as RememberedPassTransfer).toEmail === "string";
|
|
133
|
+
|
|
134
|
+
const readRemembered = (): RememberedPassTransfer[] => {
|
|
135
|
+
if (typeof window === "undefined") return [];
|
|
136
|
+
try {
|
|
137
|
+
const raw = window.localStorage.getItem(STORAGE_KEY);
|
|
138
|
+
if (!raw) return [];
|
|
139
|
+
const parsed: unknown = JSON.parse(raw);
|
|
140
|
+
if (!Array.isArray(parsed)) return [];
|
|
141
|
+
return parsed.filter(isRemembered);
|
|
142
|
+
} catch {
|
|
143
|
+
return [];
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Cached so `useSyncExternalStore` gets a STABLE snapshot — returning a fresh
|
|
149
|
+
* array from every read would re-render forever. Invalidated on every write.
|
|
150
|
+
*/
|
|
151
|
+
let cache: RememberedPassTransfer[] | null = null;
|
|
152
|
+
const listeners = new Set<() => void>();
|
|
153
|
+
|
|
154
|
+
const snapshot = (): RememberedPassTransfer[] => {
|
|
155
|
+
if (!cache) cache = readRemembered();
|
|
156
|
+
return cache;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/** The server-rendered pass has no browser store; it must be empty and stable. */
|
|
160
|
+
const EMPTY: RememberedPassTransfer[] = [];
|
|
161
|
+
const serverSnapshot = (): RememberedPassTransfer[] => EMPTY;
|
|
162
|
+
|
|
163
|
+
const subscribe = (listener: () => void) => {
|
|
164
|
+
listeners.add(listener);
|
|
165
|
+
return () => {
|
|
166
|
+
listeners.delete(listener);
|
|
167
|
+
};
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const writeRemembered = (entries: RememberedPassTransfer[]) => {
|
|
171
|
+
cache = entries;
|
|
172
|
+
if (typeof window !== "undefined") {
|
|
173
|
+
try {
|
|
174
|
+
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
|
|
175
|
+
} catch {
|
|
176
|
+
/* private mode / quota — the recipient's email is still the durable copy. */
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
listeners.forEach((listener) => listener());
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
export const getRememberedPassTransfers = (passId?: string): RememberedPassTransfer[] => {
|
|
183
|
+
const all = snapshot();
|
|
184
|
+
return passId ? all.filter((entry) => entry.passId === passId) : all;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
export const rememberPassTransfer = (entry: RememberedPassTransfer) => {
|
|
188
|
+
const all = snapshot();
|
|
189
|
+
// One live transfer per pass is a DATABASE guarantee (a partial unique index),
|
|
190
|
+
// so a second remembered row for the same pass could only ever be stale.
|
|
191
|
+
const next = all.filter((item) => item.passId !== entry.passId && item.transferId !== entry.transferId);
|
|
192
|
+
writeRemembered([...next, entry]);
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
export const forgetPassTransfer = (transferId: string) => {
|
|
196
|
+
const all = snapshot();
|
|
197
|
+
if (!all.some((item) => item.transferId === transferId)) return;
|
|
198
|
+
writeRemembered(all.filter((item) => item.transferId !== transferId));
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The remembered pending sends, as a REACTIVE read.
|
|
203
|
+
*
|
|
204
|
+
* There is no query to invalidate, so a plain read would leave a just-sent
|
|
205
|
+
* transfer invisible until a reload.
|
|
206
|
+
*/
|
|
207
|
+
export function usePendingPassTransfers(passId?: string): RememberedPassTransfer[] {
|
|
208
|
+
const all = useSyncExternalStore(subscribe, snapshot, serverSnapshot);
|
|
209
|
+
return useMemo(() => (passId ? all.filter((entry) => entry.passId === passId) : all), [all, passId]);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ── Hooks ───────────────────────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Hand a pass to an email address.
|
|
216
|
+
*
|
|
217
|
+
* A pass that already has a live transfer is refused (the partial unique index
|
|
218
|
+
* is the double-spend guard), as is a pass the caller does not currently hold —
|
|
219
|
+
* which answers 404, not 403, so the endpoint cannot be used to probe pass ids.
|
|
220
|
+
* Both come back as the server's own message; render it rather than replacing
|
|
221
|
+
* it with a guess at which rule fired.
|
|
222
|
+
*
|
|
223
|
+
* The created transfer is remembered locally so this browser can offer to
|
|
224
|
+
* cancel it, and `my-tickets` is invalidated because a claim will move the
|
|
225
|
+
* pass's owner out from under the buyer's list.
|
|
226
|
+
*/
|
|
227
|
+
export function useSendPassTransfer() {
|
|
228
|
+
const { client, profileId } = useForge();
|
|
229
|
+
const queryClient = useQueryClient();
|
|
230
|
+
|
|
231
|
+
return useMutation<PassTransfer, unknown, SendPassTransferInput>({
|
|
232
|
+
mutationFn: async ({ passId, toEmail, toName, expiresInHours }) => {
|
|
233
|
+
const res = await client.post(`/public/events/passes/${passId}/transfers`, {
|
|
234
|
+
profileId,
|
|
235
|
+
toEmail,
|
|
236
|
+
// Sent only when there is one — an empty string is not the same as "unset"
|
|
237
|
+
// to a `.max()`-bounded optional, and the server falls back sensibly.
|
|
238
|
+
...(toName ? { toName } : {}),
|
|
239
|
+
...(expiresInHours ? { expiresInHours } : {}),
|
|
240
|
+
});
|
|
241
|
+
return res.data;
|
|
242
|
+
},
|
|
243
|
+
onSuccess: (transfer) => {
|
|
244
|
+
rememberPassTransfer({
|
|
245
|
+
passId: transfer.eventPassId,
|
|
246
|
+
transferId: transfer.id,
|
|
247
|
+
toEmail: transfer.toEmail,
|
|
248
|
+
expiresAt: transfer.expiresAt ?? null,
|
|
249
|
+
});
|
|
250
|
+
void queryClient.invalidateQueries({ queryKey: ["my-tickets"] });
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Withdraw an unclaimed transfer. Current holder only.
|
|
257
|
+
*
|
|
258
|
+
* `profileId` goes in the QUERY here, not the body — the endpoint validates
|
|
259
|
+
* `req.query`, and a body would be ignored and the request refused.
|
|
260
|
+
*
|
|
261
|
+
* A transfer that was claimed in the meantime is a 400 with the server's own
|
|
262
|
+
* wording, which is the correct outcome: the sender no longer holds the ticket,
|
|
263
|
+
* so there is nothing left to withdraw. The local record is dropped either way —
|
|
264
|
+
* whatever became of it, it is no longer pending.
|
|
265
|
+
*/
|
|
266
|
+
export function useCancelPassTransfer() {
|
|
267
|
+
const { client, profileId } = useForge();
|
|
268
|
+
const queryClient = useQueryClient();
|
|
269
|
+
|
|
270
|
+
return useMutation<PassTransfer, unknown, { transferId: string }>({
|
|
271
|
+
mutationFn: async ({ transferId }) => {
|
|
272
|
+
const res = await client.post(
|
|
273
|
+
`/public/events/transfers/${transferId}/cancel`,
|
|
274
|
+
{},
|
|
275
|
+
{ params: { profileId } },
|
|
276
|
+
);
|
|
277
|
+
return res.data;
|
|
278
|
+
},
|
|
279
|
+
onSuccess: (transfer) => {
|
|
280
|
+
forgetPassTransfer(transfer.id);
|
|
281
|
+
void queryClient.invalidateQueries({ queryKey: ["my-tickets"] });
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Take a ticket somebody sent you. **Deliberately usable while signed out.**
|
|
288
|
+
*
|
|
289
|
+
* The recipient is a stranger who may have no account here; requiring one would
|
|
290
|
+
* strand most transfers. The token IS the capability — a signed-in claimant is
|
|
291
|
+
* merely recorded against the row. So this hook sends no identity and must
|
|
292
|
+
* never be gated on `user`.
|
|
293
|
+
*
|
|
294
|
+
* Every refusal (already claimed, expired, cancelled, the show was called off,
|
|
295
|
+
* the sender walked through the door) arrives as a distinct server message.
|
|
296
|
+
* Surface it verbatim: the states are not enumerable from here, and "expired"
|
|
297
|
+
* versus "already claimed" is a materially different thing to somebody standing
|
|
298
|
+
* outside a venue.
|
|
299
|
+
*/
|
|
300
|
+
export function useClaimPassTransfer() {
|
|
301
|
+
const { client, profileId } = useForge();
|
|
302
|
+
const queryClient = useQueryClient();
|
|
303
|
+
|
|
304
|
+
return useMutation<PassTransfer & { eventPassId: string }, unknown, ClaimPassTransferInput>({
|
|
305
|
+
mutationFn: async ({ token, name }) => {
|
|
306
|
+
const res = await client.post("/public/events/transfers/claim", {
|
|
307
|
+
profileId,
|
|
308
|
+
token,
|
|
309
|
+
...(name ? { name } : {}),
|
|
310
|
+
});
|
|
311
|
+
return res.data;
|
|
312
|
+
},
|
|
313
|
+
onSuccess: () => {
|
|
314
|
+
// The claimant may also be a ticket-holder here; the pass just moved.
|
|
315
|
+
void queryClient.invalidateQueries({ queryKey: ["my-tickets"] });
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
}
|