@tribe-nest/forge 3.21.0 → 3.23.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 +2 -1
- package/src/data/queries/_tests/eventWaitlist.spec.ts +38 -0
- package/src/data/queries/_tests/passTransfers.spec.ts +100 -4
- package/src/data/queries/useAuthActions.ts +1 -1
- package/src/data/queries/useCheckouts.ts +5 -0
- package/src/data/queries/useEventWaitlist.ts +123 -5
- package/src/data/queries/useEvents.ts +82 -4
- package/src/data/queries/useMembership.ts +8 -2
- package/src/data/queries/useMyBookings.ts +37 -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/data/queries/usePaymentFlow.ts +84 -18
- package/src/data/queries/useShipping.ts +5 -0
- package/src/data/queries/useSubscriptions.ts +50 -4
- package/src/index.ts +5 -0
- package/src/server/index.ts +52 -0
- package/src/server/platform.ts +1 -1
- package/src/server/platformEvents.generated.ts +33 -33
- package/src/server/pwa.ts +2 -2
- package/src/types/models.ts +203 -5
- package/src/types/paystack-inline.d.ts +45 -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/_tests/ticketAvailability.spec.ts +283 -0
- package/src/ui/format/attendees.ts +187 -0
- package/src/ui/format/membershipGate.ts +209 -0
- package/src/ui/format/ticketAvailability.ts +226 -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/bundleCoupon.ts +14 -6
- package/src/ui/headless/checkout/inventoryHold.ts +83 -0
- package/src/ui/headless/checkout/useCheckout.ts +144 -8
- package/src/ui/headless/checkout/useInventoryHold.ts +104 -0
- package/src/ui/headless/coaching/useCoachingBooking.ts +4 -0
- package/src/ui/headless/course/useCourseCheckout.ts +4 -0
- package/src/ui/headless/donation/Donation.tsx +10 -0
- package/src/ui/headless/donation/DonationContext.tsx +27 -1
- package/src/ui/headless/event/useEventCheckout.ts +203 -2
- package/src/ui/headless/event/usePresaleCode.ts +181 -0
- package/src/ui/headless/index.ts +25 -0
- package/src/ui/headless/invoice/useInvoicePayment.ts +7 -2
- package/src/ui/headless/membership/useMembershipCheckout.ts +63 -2
- package/src/ui/headless/membership/useMembershipGateNotice.ts +83 -0
- package/src/ui/headless/offer/Offer.tsx +19 -1
- package/src/ui/headless/offer/OfferContext.tsx +69 -1
- package/src/ui/headless/paymentLink/usePaymentLinkPayment.ts +7 -1
- package/src/ui/headless/work/useWorkPortal.ts +3 -3
- package/src/ui/index.ts +64 -0
- package/src/ui/shell/diagnosticsGating.ts +3 -3
- package/src/ui/styled/AccountDashboard.tsx +143 -12
- package/src/ui/styled/AddToCalendar.tsx +34 -10
- package/src/ui/styled/AudioPlayer.tsx +1 -1
- package/src/ui/styled/BundleConfirmation.tsx +1 -1
- package/src/ui/styled/Checkout.tsx +42 -32
- package/src/ui/styled/CheckoutConfirmation.tsx +2 -2
- package/src/ui/styled/CoachingBooking.tsx +11 -2
- package/src/ui/styled/CoachingConfirmation.tsx +7 -3
- package/src/ui/styled/CohortPage.tsx +1 -1
- package/src/ui/styled/ContactForm.tsx +1 -1
- package/src/ui/styled/CourseCheckout.tsx +10 -1
- package/src/ui/styled/CourseConfirmation.tsx +2 -2
- package/src/ui/styled/CourseDetail.tsx +30 -1
- package/src/ui/styled/DiscountCode.tsx +1 -1
- package/src/ui/styled/EmailListForm.tsx +1 -1
- package/src/ui/styled/EventConfirmation.tsx +3 -1
- package/src/ui/styled/EventDetail.tsx +53 -22
- package/src/ui/styled/EventSeriesDetail.tsx +1 -1
- package/src/ui/styled/EventTickets.tsx +284 -19
- package/src/ui/styled/EventWaitlist.tsx +23 -3
- package/src/ui/styled/HoldNotice.tsx +192 -0
- package/src/ui/styled/InvoicePayment.tsx +14 -5
- package/src/ui/styled/MembershipCheckout.tsx +20 -11
- package/src/ui/styled/MembershipGateNotice.tsx +159 -0
- package/src/ui/styled/OfferButton.tsx +24 -1
- package/src/ui/styled/PaymentLinkPayment.tsx +12 -4
- package/src/ui/styled/PaystackPayButton.tsx +66 -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/ReviewForm.tsx +2 -2
- package/src/ui/styled/TicketTransfer.tsx +71 -42
- 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/ui/styled/community/CommunityFeed.tsx +1 -1
- package/src/ui/styled/community/CommunityPostDetail.tsx +1 -1
- package/src/utils/_tests/paystackCheckout.spec.ts +266 -0
- package/src/utils/_tests/paystackCheckoutBlocked.spec.ts +51 -0
- package/src/utils/_tests/presaleCode.spec.ts +168 -0
- package/src/utils/_tests/structuredData.spec.ts +275 -0
- package/src/utils/membershipAccess.ts +3 -3
- package/src/utils/paystackCheckout.ts +277 -0
- package/src/utils/presaleCode.ts +96 -0
- package/src/utils/structuredData.ts +361 -27
- package/src/utils/ticketOrderOutcome.ts +1 -1
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/// <reference path="../types/paystack-inline.d.ts" />
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Paystack inline checkout: the ONE place in Forge that knows the popup exists.
|
|
5
|
+
*
|
|
6
|
+
* The fan pays in a modal on the creator's own site instead of being sent to a
|
|
7
|
+
* Paystack-branded page and back. Every pillar (cart, tickets, courses,
|
|
8
|
+
* coaching, invoices, payment links, donations, offers, memberships) reaches
|
|
9
|
+
* Paystack through this function, so there is exactly one implementation of the
|
|
10
|
+
* lazy load, the fallback and the success navigation to reason about.
|
|
11
|
+
*
|
|
12
|
+
* Four properties are load-bearing:
|
|
13
|
+
*
|
|
14
|
+
* 1. **SSR safety.** Forge sites are TanStack Start, so this module is evaluated
|
|
15
|
+
* on the server. Nothing here touches `window`, `document` or the Paystack
|
|
16
|
+
* bundle at module scope; the browser check happens before any of it.
|
|
17
|
+
* 2. **Lazy load.** `@paystack/inline-js` is reached through a dynamic
|
|
18
|
+
* `import()`, so it never enters the initial bundle and a blocked script
|
|
19
|
+
* cannot break page render: it becomes a rejected promise we can handle.
|
|
20
|
+
* 3. **One runtime fallback, not a version shim.** When the modal cannot open
|
|
21
|
+
* (import rejected, `resumeTransaction` threw, or nothing appeared within
|
|
22
|
+
* `MODAL_OPEN_TIMEOUT_MS`), the fan goes to the hosted page carried on
|
|
23
|
+
* `checkoutUrl`. The sale survives a blocked CDN, a CSP, or an extension.
|
|
24
|
+
* This is about browser conditions, never about which Forge version a site
|
|
25
|
+
* compiled against.
|
|
26
|
+
* 4. **Dismissal is not failure.** Closing the modal without paying resolves
|
|
27
|
+
* `"dismissed"`. Nothing is marked failed and the caller can open it again.
|
|
28
|
+
*
|
|
29
|
+
* Stripe is untouched by any of this: it keeps reading `paymentSecret` as the
|
|
30
|
+
* PaymentIntent client secret, exactly as live sites already do.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** How a checkout attempt ended. */
|
|
34
|
+
export type PaystackCheckoutOutcome =
|
|
35
|
+
/** Paid in the modal. */
|
|
36
|
+
| "succeeded"
|
|
37
|
+
/** The fan closed the modal without paying. Retryable, not a failure. */
|
|
38
|
+
| "dismissed"
|
|
39
|
+
/** Paystack reported a genuine failure after the modal opened. */
|
|
40
|
+
| "failed"
|
|
41
|
+
/** The modal could not be used, so the fan was sent to the hosted page. */
|
|
42
|
+
| "redirected"
|
|
43
|
+
/** Nothing could be done here at all (server render, or no session to open). */
|
|
44
|
+
| "unavailable";
|
|
45
|
+
|
|
46
|
+
/** The bits of a completed Paystack transaction the return leg needs. */
|
|
47
|
+
export type PaystackTransactionResult = {
|
|
48
|
+
reference?: string;
|
|
49
|
+
trxref?: string;
|
|
50
|
+
transaction?: string;
|
|
51
|
+
status?: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A started Paystack charge, exactly as `start-payment` reports it.
|
|
56
|
+
*
|
|
57
|
+
* `accessCode` and `checkoutUrl` arrive in the SAME `/transaction/initialize`
|
|
58
|
+
* response, which is why carrying the fallback costs one field rather than a
|
|
59
|
+
* second round trip.
|
|
60
|
+
*/
|
|
61
|
+
export type PaystackCheckoutSession = {
|
|
62
|
+
/** Checkout-session access code. Carries the session; no public key needed. */
|
|
63
|
+
accessCode?: string | null;
|
|
64
|
+
/** Hosted checkout URL. The runtime fallback. */
|
|
65
|
+
checkoutUrl?: string | null;
|
|
66
|
+
/** Where the redirect leg would have landed. Used on success by default. */
|
|
67
|
+
returnUrl?: string | null;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export type PaystackCheckoutHandlers = {
|
|
71
|
+
/**
|
|
72
|
+
* Paid. When omitted, the helper navigates to `returnUrl` with Paystack's own
|
|
73
|
+
* `reference`/`trxref` appended, byte for byte what the hosted redirect did,
|
|
74
|
+
* so every finalise page keeps working untouched. Supply this only when the
|
|
75
|
+
* surface finalises in place (a modal that shows its own receipt).
|
|
76
|
+
*/
|
|
77
|
+
onSuccess?: (result: PaystackTransactionResult) => void;
|
|
78
|
+
/** The fan closed the modal. Re-enable the pay button; change nothing else. */
|
|
79
|
+
onDismiss?: () => void;
|
|
80
|
+
/** A real failure, or no usable session at all. */
|
|
81
|
+
onError?: (message?: string) => void;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* How long to wait for the modal to show itself before deciding it will not.
|
|
86
|
+
*
|
|
87
|
+
* Long enough that a slow phone on a bad connection is not thrown out of a
|
|
88
|
+
* working modal, short enough that a fan staring at a dead button gives up
|
|
89
|
+
* before we do.
|
|
90
|
+
*/
|
|
91
|
+
const MODAL_OPEN_TIMEOUT_MS = 4000;
|
|
92
|
+
const MODAL_POLL_MS = 200;
|
|
93
|
+
|
|
94
|
+
const inBrowser = (): boolean => typeof window !== "undefined" && typeof document !== "undefined";
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Is Paystack's checkout iframe actually on screen right now?
|
|
98
|
+
*
|
|
99
|
+
* This decides whether the 4s watchdog falls back to the hosted page, so a
|
|
100
|
+
* false positive strands a fan on a dead button. The obvious selector,
|
|
101
|
+
* `iframe[src*="paystack"]`, produces one in two ways that both happen in
|
|
102
|
+
* practice:
|
|
103
|
+
*
|
|
104
|
+
* 1. A DISMISSED modal's iframe is hidden, not removed. On a second attempt it
|
|
105
|
+
* still matches, so the watchdog concludes the modal opened when nothing
|
|
106
|
+
* did. Hence the visibility check rather than mere presence.
|
|
107
|
+
* 2. Any iframe whose src merely CONTAINS the string, including third-party
|
|
108
|
+
* frames that echo the current page URL back in their own src, which match
|
|
109
|
+
* on any host with "paystack" in it. Hence matching the parsed HOST rather
|
|
110
|
+
* than a substring of the whole URL.
|
|
111
|
+
*/
|
|
112
|
+
const modalIsPresent = (): boolean => {
|
|
113
|
+
const frames = Array.from(document.querySelectorAll<HTMLIFrameElement>("iframe[src]"));
|
|
114
|
+
return frames.some((frame) => {
|
|
115
|
+
let host: string;
|
|
116
|
+
try {
|
|
117
|
+
host = new URL(frame.src, window.location.href).hostname.toLowerCase();
|
|
118
|
+
} catch {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
if (host !== "paystack.com" && !host.endsWith(".paystack.com")) return false;
|
|
122
|
+
|
|
123
|
+
// Visibility, not layout size. `getBoundingClientRect` would be the more
|
|
124
|
+
// thorough test, but the checks that matter are the ones Paystack actually
|
|
125
|
+
// uses to put a dismissed modal away, and this keeps the rule assertable.
|
|
126
|
+
if (frame.hidden) return false;
|
|
127
|
+
const style = window.getComputedStyle(frame);
|
|
128
|
+
return style.display !== "none" && style.visibility !== "hidden";
|
|
129
|
+
});
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const errorMessageOf = (error: unknown): string | undefined => {
|
|
133
|
+
if (typeof error === "string") return error;
|
|
134
|
+
const message = (error as { message?: unknown } | null)?.message;
|
|
135
|
+
return typeof message === "string" ? message : undefined;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The URL the hosted redirect would have landed on.
|
|
140
|
+
*
|
|
141
|
+
* Paystack appends `reference` and `trxref` to the callback URL, and finalise
|
|
142
|
+
* pages have always been able to read them. Appending the same two params after
|
|
143
|
+
* a modal success is what makes "nothing downstream changes" literally true.
|
|
144
|
+
*/
|
|
145
|
+
export function paystackReturnUrl(returnUrl: string, result: PaystackTransactionResult): string {
|
|
146
|
+
const reference = result.reference || result.trxref;
|
|
147
|
+
if (!reference) return returnUrl;
|
|
148
|
+
try {
|
|
149
|
+
const base = inBrowser() ? window.location.href : undefined;
|
|
150
|
+
const url = new URL(returnUrl, base);
|
|
151
|
+
url.searchParams.set("reference", reference);
|
|
152
|
+
url.searchParams.set("trxref", reference);
|
|
153
|
+
return url.toString();
|
|
154
|
+
} catch {
|
|
155
|
+
// A relative URL with no base to resolve against. The caller's own return
|
|
156
|
+
// path is still the right destination; it just carries no reference.
|
|
157
|
+
return returnUrl;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** True when a start-payment result is a Paystack session this helper can act on. */
|
|
162
|
+
export function hasPaystackSession(session: PaystackCheckoutSession | null | undefined): boolean {
|
|
163
|
+
return !!(session && (session.accessCode || session.checkoutUrl));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Open the Paystack modal for a started charge.
|
|
168
|
+
*
|
|
169
|
+
* Resolves once the attempt has ended one way or another; it never rejects, so
|
|
170
|
+
* a caller can `await` it from a click handler without a try/catch.
|
|
171
|
+
*/
|
|
172
|
+
export async function openPaystackCheckout(
|
|
173
|
+
session: PaystackCheckoutSession,
|
|
174
|
+
handlers: PaystackCheckoutHandlers = {},
|
|
175
|
+
): Promise<PaystackCheckoutOutcome> {
|
|
176
|
+
// Server render: do nothing at all, and say so rather than throwing.
|
|
177
|
+
if (!inBrowser()) return "unavailable";
|
|
178
|
+
|
|
179
|
+
const accessCode = session.accessCode || undefined;
|
|
180
|
+
const checkoutUrl = session.checkoutUrl || undefined;
|
|
181
|
+
|
|
182
|
+
if (!accessCode && !checkoutUrl) {
|
|
183
|
+
handlers.onError?.("This payment could not be started. Please try again.");
|
|
184
|
+
return "unavailable";
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The one runtime fallback: the hosted page the same initialize call returned. */
|
|
188
|
+
const goToHostedPage = (): PaystackCheckoutOutcome => {
|
|
189
|
+
if (!checkoutUrl) {
|
|
190
|
+
handlers.onError?.("The payment window could not be opened. Please try again.");
|
|
191
|
+
return "unavailable";
|
|
192
|
+
}
|
|
193
|
+
window.location.href = checkoutUrl;
|
|
194
|
+
return "redirected";
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
if (!accessCode) return goToHostedPage();
|
|
198
|
+
|
|
199
|
+
let PaystackPop: typeof import("@paystack/inline-js").default;
|
|
200
|
+
try {
|
|
201
|
+
// Lazily imported so a blocked CDN, an offline chunk or a CSP is a rejected
|
|
202
|
+
// promise here instead of a page that will not render.
|
|
203
|
+
PaystackPop = (await import("@paystack/inline-js")).default;
|
|
204
|
+
} catch {
|
|
205
|
+
return goToHostedPage();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return new Promise<PaystackCheckoutOutcome>((resolve) => {
|
|
209
|
+
let settled = false;
|
|
210
|
+
let opened = false;
|
|
211
|
+
let poll: ReturnType<typeof setInterval> | undefined;
|
|
212
|
+
let watchdog: ReturnType<typeof setTimeout> | undefined;
|
|
213
|
+
|
|
214
|
+
const stopWatching = () => {
|
|
215
|
+
if (poll) clearInterval(poll);
|
|
216
|
+
if (watchdog) clearTimeout(watchdog);
|
|
217
|
+
poll = undefined;
|
|
218
|
+
watchdog = undefined;
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const settle = (outcome: PaystackCheckoutOutcome) => {
|
|
222
|
+
if (settled) return false;
|
|
223
|
+
settled = true;
|
|
224
|
+
stopWatching();
|
|
225
|
+
resolve(outcome);
|
|
226
|
+
return true;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const succeed = (result: PaystackTransactionResult) => {
|
|
230
|
+
if (!settle("succeeded")) return;
|
|
231
|
+
if (handlers.onSuccess) {
|
|
232
|
+
handlers.onSuccess(result);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
// No handler: continue down the exact path the redirect used.
|
|
236
|
+
if (session.returnUrl) window.location.href = paystackReturnUrl(session.returnUrl, result);
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
new PaystackPop().resumeTransaction(accessCode, {
|
|
241
|
+
onSuccess: (transaction) => succeed(transaction ?? {}),
|
|
242
|
+
// Closing the popup leaves the charge exactly where it was. Nothing is
|
|
243
|
+
// failed, nothing is cancelled server-side, and the same access code
|
|
244
|
+
// still opens the same session on the next click.
|
|
245
|
+
onCancel: () => {
|
|
246
|
+
if (settle("dismissed")) handlers.onDismiss?.();
|
|
247
|
+
},
|
|
248
|
+
// The iframe is up, so the watchdog has nothing left to rescue.
|
|
249
|
+
onLoad: () => {
|
|
250
|
+
opened = true;
|
|
251
|
+
stopWatching();
|
|
252
|
+
},
|
|
253
|
+
onError: (error) => {
|
|
254
|
+
if (settle("failed")) handlers.onError?.(errorMessageOf(error));
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
} catch {
|
|
258
|
+
// `resumeTransaction` threw synchronously, so the modal never got started.
|
|
259
|
+
settle(goToHostedPage());
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
poll = setInterval(() => {
|
|
264
|
+
if (!modalIsPresent()) return;
|
|
265
|
+
opened = true;
|
|
266
|
+
stopWatching();
|
|
267
|
+
}, MODAL_POLL_MS);
|
|
268
|
+
|
|
269
|
+
watchdog = setTimeout(() => {
|
|
270
|
+
stopWatching();
|
|
271
|
+
if (settled || opened) return;
|
|
272
|
+
// The modal never appeared. Send the fan to the hosted page rather than
|
|
273
|
+
// leaving them looking at a button that did nothing.
|
|
274
|
+
settle(goToHostedPage());
|
|
275
|
+
}, MODAL_OPEN_TIMEOUT_MS);
|
|
276
|
+
});
|
|
277
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a buyer's presale code lives between typing it and paying with it.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this is a module-level store and not component state
|
|
5
|
+
*
|
|
6
|
+
* The code has to be in three places at once, in components that do not share a
|
|
7
|
+
* parent: the event page's own `useEvent` (to reveal the hidden tiers), the
|
|
8
|
+
* ticket modal's `useEventCheckout` (its own separate `useEvent`), and the
|
|
9
|
+
* order body (the sell guard re-checks the code on every purchase, so a buyer
|
|
10
|
+
* who unlocks a tier and then checks out without the code is refused AFTER
|
|
11
|
+
* entering their card). Threading it through props would work for one host app
|
|
12
|
+
* and break for every code-site that composes the Forge pieces differently.
|
|
13
|
+
*
|
|
14
|
+
* ## Why sessionStorage and not localStorage
|
|
15
|
+
*
|
|
16
|
+
* A presale code is a right to buy now, not a saved preference. Session scope
|
|
17
|
+
* survives the reload, the Stripe redirect and the back button — everything
|
|
18
|
+
* inside one purchase — and does not silently re-apply a spent code to a
|
|
19
|
+
* different show weeks later.
|
|
20
|
+
*
|
|
21
|
+
* Keyed per event, because two presales are two different codes.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const KEY_PREFIX = "tn:presale:";
|
|
25
|
+
|
|
26
|
+
/** eventKey → code, mirroring sessionStorage so reads are synchronous. */
|
|
27
|
+
const memory = new Map<string, string>();
|
|
28
|
+
const listeners = new Set<() => void>();
|
|
29
|
+
|
|
30
|
+
/** Loaded lazily so a server render never touches storage. */
|
|
31
|
+
const hydrated = new Set<string>();
|
|
32
|
+
|
|
33
|
+
const storageKey = (eventKey: string) => `${KEY_PREFIX}${eventKey}`;
|
|
34
|
+
|
|
35
|
+
function hydrate(eventKey: string): void {
|
|
36
|
+
if (hydrated.has(eventKey) || typeof window === "undefined") return;
|
|
37
|
+
hydrated.add(eventKey);
|
|
38
|
+
try {
|
|
39
|
+
const stored = window.sessionStorage.getItem(storageKey(eventKey));
|
|
40
|
+
if (stored) memory.set(eventKey, stored);
|
|
41
|
+
} catch {
|
|
42
|
+
// Private mode / disabled storage. The code still works for this render;
|
|
43
|
+
// it just will not survive a reload.
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function readPresaleCode(eventKey: string): string | null {
|
|
48
|
+
if (!eventKey) return null;
|
|
49
|
+
hydrate(eventKey);
|
|
50
|
+
return memory.get(eventKey) ?? null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function writePresaleCode(eventKey: string, code: string | null): void {
|
|
54
|
+
if (!eventKey) return;
|
|
55
|
+
hydrated.add(eventKey);
|
|
56
|
+
const trimmed = code?.trim();
|
|
57
|
+
if (trimmed) memory.set(eventKey, trimmed);
|
|
58
|
+
else memory.delete(eventKey);
|
|
59
|
+
|
|
60
|
+
if (typeof window !== "undefined") {
|
|
61
|
+
try {
|
|
62
|
+
if (trimmed) window.sessionStorage.setItem(storageKey(eventKey), trimmed);
|
|
63
|
+
else window.sessionStorage.removeItem(storageKey(eventKey));
|
|
64
|
+
} catch {
|
|
65
|
+
// As above — in-memory only.
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
for (const listener of listeners) listener();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function subscribePresaleCode(listener: () => void): () => void {
|
|
73
|
+
listeners.add(listener);
|
|
74
|
+
return () => {
|
|
75
|
+
listeners.delete(listener);
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* `?accessCode=PRESALE24` on the URL, so the link an artist mails to their list
|
|
81
|
+
* unlocks the page on arrival instead of asking the recipient to retype what
|
|
82
|
+
* they just clicked.
|
|
83
|
+
*
|
|
84
|
+
* The parameter is left ON the URL deliberately, unlike the attribution ref: a
|
|
85
|
+
* presale link is meant to be forwardable, and stripping it would break the
|
|
86
|
+
* one thing the recipient is most likely to do with it.
|
|
87
|
+
*/
|
|
88
|
+
export function readPresaleCodeFromUrl(): string | null {
|
|
89
|
+
if (typeof window === "undefined") return null;
|
|
90
|
+
try {
|
|
91
|
+
const value = new URL(window.location.href).searchParams.get("accessCode");
|
|
92
|
+
return value?.trim() || null;
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|