Skip to content

Commit 69acd11

Browse files
authored
Prefill public checkout from URL query params (name/email + optional lock) (#1234)
1 parent 6eb1709 commit 69acd11

3 files changed

Lines changed: 106 additions & 8 deletions

File tree

frontend/src/components/routes/product-widget/CollectInformation/index.tsx

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import countries from "../../../../../data/countries.json";
3333
import classes from "./CollectInformation.module.scss";
3434
import {trackEvent, AnalyticsEvents} from "../../../../utilites/analytics.ts";
3535
import {clearWaitlistJoinedForEvent} from "../../../../hooks/useWaitlistJoined.ts";
36+
import {useCheckoutPrefill, CheckoutPrefill} from "../../../../hooks/useCheckoutPrefill.ts";
3637

3738
const LoadingSkeleton = () =>
3839
(
@@ -48,6 +49,8 @@ export const CollectInformation = () => {
4849
const navigate = useNavigate();
4950
const [searchParams] = useSearchParams();
5051
const isFromWaitlist = searchParams.get('waitlist') === 'true';
52+
const {prefill, lock} = useCheckoutPrefill();
53+
const isLocked = (field: keyof CheckoutPrefill) => lock && prefill[field] !== undefined;
5154
const {
5255
isFetched: isOrderFetched,
5356
data: order,
@@ -297,18 +300,38 @@ export const CollectInformation = () => {
297300

298301
useEffect(() => {
299302
if (isEventFetched && isOrderFetched && isQuestionsFetched && productQuestions && orderQuestions) {
300-
const products = createProductsAndQuestions(createProductIdToQuestionMap());
303+
const builtProducts = createProductsAndQuestions(createProductIdToQuestionMap());
301304
const formOrderQuestions = createFormOrderQuestions();
302305

306+
const orderPrefill = {
307+
...(prefill.first_name !== undefined && {first_name: prefill.first_name}),
308+
...(prefill.last_name !== undefined && {last_name: prefill.last_name}),
309+
...(prefill.email !== undefined && {email: prefill.email, email_confirmation: prefill.email}),
310+
};
311+
312+
const ticketProductIds = new Set(
313+
(products ?? [])
314+
.filter(product => product && product.product_type === 'TICKET')
315+
.map(product => product!.id)
316+
);
317+
318+
const prefilledProducts = builtProducts.map((product: any) =>
319+
(!isPerOrderCollection && ticketProductIds.has(product.product_id))
320+
? {...product, ...orderPrefill}
321+
: product
322+
);
323+
303324
form.setValues({
304325
...form.values,
305-
products: products,
326+
products: prefilledProducts,
306327
order: {
307328
...form.values.order,
329+
...orderPrefill,
308330
questions: formOrderQuestions,
309331
},
310332
});
311333
}
334+
// prefill/lock intentionally omitted: they're memoized off query params that don't change during the page's lifetime
312335
}, [isEventFetched, isOrderFetched, isQuestionsFetched]);
313336

314337
useEffect(() => {
@@ -434,12 +457,14 @@ export const CollectInformation = () => {
434457
withAsterisk
435458
label={t`First Name`}
436459
placeholder={t`First name`}
460+
disabled={isLocked('first_name')}
437461
{...form.getInputProps("order.first_name")}
438462
/>
439463
<TextInput
440464
withAsterisk
441465
label={t`Last Name`}
442466
placeholder={t`Last Name`}
467+
disabled={isLocked('last_name')}
443468
{...form.getInputProps("order.last_name")}
444469
/>
445470
</InputGroup>
@@ -450,6 +475,7 @@ export const CollectInformation = () => {
450475
type={"email"}
451476
label={t`Email Address`}
452477
placeholder={t`Email Address`}
478+
disabled={isLocked('email')}
453479
rightSection={isEmailValid(form.values.order.email) ? <EmailCheckIcon/> : null}
454480
{...form.getInputProps("order.email")}
455481
/>
@@ -458,12 +484,13 @@ export const CollectInformation = () => {
458484
type={"email"}
459485
label={t`Confirm Email Address`}
460486
placeholder={t`Confirm Email Address`}
487+
disabled={isLocked('email')}
461488
rightSection={isEmailValid(form.values.order.email_confirmation) ? <EmailCheckIcon/> : null}
462489
{...form.getInputProps("order.email_confirmation")}
463490
/>
464491
</InputGroup>
465492

466-
{orderRequiresAttendeeDetails && !isPerOrderCollection && totalTicketAttendees > 0 && (
493+
{orderRequiresAttendeeDetails && !isPerOrderCollection && totalTicketAttendees > 0 && !lock && (
467494
<div className={classes.copyDetailsSection}>
468495
{totalTicketAttendees === 1 ? (
469496
<Tooltip
@@ -646,12 +673,14 @@ export const CollectInformation = () => {
646673
withAsterisk
647674
label={t`First Name`}
648675
placeholder={t`First name`}
676+
disabled={isLocked('first_name')}
649677
{...form.getInputProps(`products.${currentProductIndex}.first_name`)}
650678
/>
651679
<TextInput
652680
withAsterisk
653681
label={t`Last Name`}
654682
placeholder={t`Last Name`}
683+
disabled={isLocked('last_name')}
655684
{...form.getInputProps(`products.${currentProductIndex}.last_name`)}
656685
/>
657686
</InputGroup>
@@ -662,6 +691,7 @@ export const CollectInformation = () => {
662691
type={"email"}
663692
label={t`Email Address`}
664693
placeholder={t`Email Address`}
694+
disabled={isLocked('email')}
665695
rightSection={isEmailValid(form.values.products[currentProductIndex]?.email || '') ?
666696
<EmailCheckIcon/> : null}
667697
{...form.getInputProps(`products.${currentProductIndex}.email`)}
@@ -671,6 +701,7 @@ export const CollectInformation = () => {
671701
type={"email"}
672702
label={t`Confirm Email Address`}
673703
placeholder={t`Confirm Email Address`}
704+
disabled={isLocked('email')}
674705
rightSection={isEmailValid(form.values.products[currentProductIndex]?.email_confirmation || '') ?
675706
<EmailCheckIcon/> : null}
676707
{...form.getInputProps(`products.${currentProductIndex}.email_confirmation`)}

frontend/src/components/routes/product-widget/SelectProducts/index.tsx

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {IconChevronRight, IconX} from "@tabler/icons-react"
3737
import {getSessionIdentifier} from "../../../../utilites/sessionIdentifier.ts";
3838
import {Constants} from "../../../../constants.ts";
3939
import {clearWaitlistJoinedForEvent} from "../../../../hooks/useWaitlistJoined.ts";
40+
import {CHECKOUT_PREFILL_PARAM_KEYS} from "../../../../hooks/useCheckoutPrefill.ts";
4041

4142
const AFFILIATE_EXPIRY_DAYS = 30;
4243

@@ -150,16 +151,29 @@ const SelectProducts = (props: SelectProductsProps) => {
150151
onSuccess: (data) => queryClient.invalidateQueries()
151152
.then(() => {
152153
const url = '/checkout/' + eventId + '/' + data.data.short_id + '/details';
154+
155+
// Forward checkout-prefill params (name/email/lock) from the event page
156+
// to the details step, since this navigation would otherwise drop them.
157+
const sourceParams = new URLSearchParams(window.location.search);
158+
const prefillParams = new URLSearchParams();
159+
CHECKOUT_PREFILL_PARAM_KEYS.forEach((key) => {
160+
const value = sourceParams.get(key);
161+
if (value !== null) {
162+
prefillParams.set(key, value);
163+
}
164+
});
165+
const prefillQuery = prefillParams.toString();
166+
153167
if (props.widgetMode === 'embedded') {
154-
window.open(
155-
url + '?session_identifier=' + data.data.session_identifier + '&utm_source=embedded_widget',
156-
'_blank'
157-
);
168+
const embeddedQuery = 'session_identifier=' + data.data.session_identifier
169+
+ '&utm_source=embedded_widget'
170+
+ (prefillQuery ? '&' + prefillQuery : '');
171+
window.open(url + '?' + embeddedQuery, '_blank');
158172
setOrderInProcessOverlayVisible(true);
159173
return;
160174
}
161175

162-
return navigate(url);
176+
return navigate(url + (prefillQuery ? '?' + prefillQuery : ''));
163177
}),
164178

165179
onError: (error: any) => {
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import {useMemo} from "react";
2+
import {useSearchParams} from "react-router";
3+
4+
export interface CheckoutPrefill {
5+
first_name?: string;
6+
last_name?: string;
7+
email?: string;
8+
}
9+
10+
export interface UseCheckoutPrefillResult {
11+
prefill: CheckoutPrefill;
12+
lock: boolean;
13+
}
14+
15+
// Query params the checkout details form reads to prefill itself. Also forwarded
16+
// from the event page to the details step so prefill survives order creation
17+
// (see SelectProducts).
18+
export const CHECKOUT_PREFILL_PARAM_KEYS = ["first_name", "last_name", "email", "lock"] as const;
19+
20+
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
21+
22+
export const useCheckoutPrefill = (): UseCheckoutPrefillResult => {
23+
const [searchParams] = useSearchParams();
24+
25+
const firstNameParam = searchParams.get("first_name");
26+
const lastNameParam = searchParams.get("last_name");
27+
const emailParam = searchParams.get("email");
28+
const lockParam = searchParams.get("lock");
29+
30+
return useMemo(() => {
31+
const prefill: CheckoutPrefill = {};
32+
33+
const firstName = firstNameParam?.trim();
34+
if (firstName) {
35+
prefill.first_name = firstName;
36+
}
37+
38+
const lastName = lastNameParam?.trim();
39+
if (lastName) {
40+
prefill.last_name = lastName;
41+
}
42+
43+
const email = emailParam?.trim();
44+
if (email && EMAIL_REGEX.test(email)) {
45+
prefill.email = email;
46+
}
47+
48+
const hasPrefill = Object.keys(prefill).length > 0;
49+
const lock = hasPrefill && (lockParam === "true" || lockParam === "1");
50+
51+
return {prefill, lock};
52+
}, [firstNameParam, lastNameParam, emailParam, lockParam]);
53+
};

0 commit comments

Comments
 (0)