From 4ce0a3642462ca48af6ccf1157825f7c2b789a46 Mon Sep 17 00:00:00 2001 From: skvost Date: Wed, 1 Jul 2026 15:26:45 +0200 Subject: [PATCH 1/4] feat(checkout): add useCheckoutPrefill hook for query-param prefill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parses first_name/last_name/email/lock from the URL, validates email, returns a stable { prefill, lock } for the checkout details form. 🤖 Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/hooks/useCheckoutPrefill.ts | 48 ++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 frontend/src/hooks/useCheckoutPrefill.ts diff --git a/frontend/src/hooks/useCheckoutPrefill.ts b/frontend/src/hooks/useCheckoutPrefill.ts new file mode 100644 index 0000000000..898e1b63e7 --- /dev/null +++ b/frontend/src/hooks/useCheckoutPrefill.ts @@ -0,0 +1,48 @@ +import {useMemo} from "react"; +import {useSearchParams} from "react-router"; + +export interface CheckoutPrefill { + first_name?: string; + last_name?: string; + email?: string; +} + +export interface UseCheckoutPrefillResult { + prefill: CheckoutPrefill; + lock: boolean; +} + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export const useCheckoutPrefill = (): UseCheckoutPrefillResult => { + const [searchParams] = useSearchParams(); + + const firstNameParam = searchParams.get("first_name"); + const lastNameParam = searchParams.get("last_name"); + const emailParam = searchParams.get("email"); + const lockParam = searchParams.get("lock"); + + return useMemo(() => { + const prefill: CheckoutPrefill = {}; + + const firstName = firstNameParam?.trim(); + if (firstName) { + prefill.first_name = firstName; + } + + const lastName = lastNameParam?.trim(); + if (lastName) { + prefill.last_name = lastName; + } + + const email = emailParam?.trim(); + if (email && EMAIL_REGEX.test(email)) { + prefill.email = email; + } + + const hasPrefill = Object.keys(prefill).length > 0; + const lock = hasPrefill && (lockParam === "true" || lockParam === "1"); + + return {prefill, lock}; + }, [firstNameParam, lastNameParam, emailParam, lockParam]); +}; From 5cfe3f9a9bfebfb2f270cf600508d0982a85a017 Mon Sep 17 00:00:00 2001 From: skvost Date: Wed, 1 Jul 2026 15:26:45 +0200 Subject: [PATCH 2/4] feat(checkout): prefill details form from query params with optional lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merges useCheckoutPrefill values into the details setValues effect (buyer + ticket attendees, email_confirmation derived from email) and disables prefilled fields when lock is set. 🤖 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../CollectInformation/index.tsx | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/routes/product-widget/CollectInformation/index.tsx b/frontend/src/components/routes/product-widget/CollectInformation/index.tsx index 671cf19412..2f3b396961 100644 --- a/frontend/src/components/routes/product-widget/CollectInformation/index.tsx +++ b/frontend/src/components/routes/product-widget/CollectInformation/index.tsx @@ -33,6 +33,7 @@ import countries from "../../../../../data/countries.json"; import classes from "./CollectInformation.module.scss"; import {trackEvent, AnalyticsEvents} from "../../../../utilites/analytics.ts"; import {clearWaitlistJoinedForEvent} from "../../../../hooks/useWaitlistJoined.ts"; +import {useCheckoutPrefill, CheckoutPrefill} from "../../../../hooks/useCheckoutPrefill.ts"; const LoadingSkeleton = () => ( @@ -48,6 +49,8 @@ export const CollectInformation = () => { const navigate = useNavigate(); const [searchParams] = useSearchParams(); const isFromWaitlist = searchParams.get('waitlist') === 'true'; + const {prefill, lock} = useCheckoutPrefill(); + const isLocked = (field: keyof CheckoutPrefill) => lock && prefill[field] !== undefined; const { isFetched: isOrderFetched, data: order, @@ -296,14 +299,33 @@ export const CollectInformation = () => { useEffect(() => { if (isEventFetched && isOrderFetched && isQuestionsFetched && productQuestions && orderQuestions) { - const products = createProductsAndQuestions(createProductIdToQuestionMap()); + const builtProducts = createProductsAndQuestions(createProductIdToQuestionMap()); const formOrderQuestions = createFormOrderQuestions(); + const orderPrefill = { + ...(prefill.first_name !== undefined && {first_name: prefill.first_name}), + ...(prefill.last_name !== undefined && {last_name: prefill.last_name}), + ...(prefill.email !== undefined && {email: prefill.email, email_confirmation: prefill.email}), + }; + + const ticketProductIds = new Set( + (products ?? []) + .filter(product => product && product.product_type === 'TICKET') + .map(product => product!.id) + ); + + const prefilledProducts = builtProducts.map((product: any) => + ticketProductIds.has(product.product_id) + ? {...product, ...orderPrefill} + : product + ); + form.setValues({ ...form.values, - products: products, + products: prefilledProducts, order: { ...form.values.order, + ...orderPrefill, questions: formOrderQuestions, }, }); @@ -433,12 +455,14 @@ export const CollectInformation = () => { withAsterisk label={t`First Name`} placeholder={t`First name`} + disabled={isLocked('first_name')} {...form.getInputProps("order.first_name")} /> @@ -449,6 +473,7 @@ export const CollectInformation = () => { type={"email"} label={t`Email Address`} placeholder={t`Email Address`} + disabled={isLocked('email')} rightSection={isEmailValid(form.values.order.email) ? : null} {...form.getInputProps("order.email")} /> @@ -457,6 +482,7 @@ export const CollectInformation = () => { type={"email"} label={t`Confirm Email Address`} placeholder={t`Confirm Email Address`} + disabled={isLocked('email')} rightSection={isEmailValid(form.values.order.email_confirmation) ? : null} {...form.getInputProps("order.email_confirmation")} /> @@ -645,12 +671,14 @@ export const CollectInformation = () => { withAsterisk label={t`First Name`} placeholder={t`First name`} + disabled={isLocked('first_name')} {...form.getInputProps(`products.${currentProductIndex}.first_name`)} /> @@ -661,6 +689,7 @@ export const CollectInformation = () => { type={"email"} label={t`Email Address`} placeholder={t`Email Address`} + disabled={isLocked('email')} rightSection={isEmailValid(form.values.products[currentProductIndex]?.email || '') ? : null} {...form.getInputProps(`products.${currentProductIndex}.email`)} @@ -670,6 +699,7 @@ export const CollectInformation = () => { type={"email"} label={t`Confirm Email Address`} placeholder={t`Confirm Email Address`} + disabled={isLocked('email')} rightSection={isEmailValid(form.values.products[currentProductIndex]?.email_confirmation || '') ? : null} {...form.getInputProps(`products.${currentProductIndex}.email_confirmation`)} From 35ef667c7302fce1b49c479d0d8ef487c8c2eca0 Mon Sep 17 00:00:00 2001 From: skvost Date: Wed, 1 Jul 2026 15:26:45 +0200 Subject: [PATCH 3/4] fix(checkout): don't let copy control wipe locked attendee fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hide the copy-details control when lock is set (locked attendee inputs are disabled, so resetting them was unrecoverable and dropped locked data). Also skip attendee prefill under PER_ORDER collection and document the effect-deps omission. 🤖 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../routes/product-widget/CollectInformation/index.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/routes/product-widget/CollectInformation/index.tsx b/frontend/src/components/routes/product-widget/CollectInformation/index.tsx index 2f3b396961..a864cc6b39 100644 --- a/frontend/src/components/routes/product-widget/CollectInformation/index.tsx +++ b/frontend/src/components/routes/product-widget/CollectInformation/index.tsx @@ -315,7 +315,7 @@ export const CollectInformation = () => { ); const prefilledProducts = builtProducts.map((product: any) => - ticketProductIds.has(product.product_id) + (!isPerOrderCollection && ticketProductIds.has(product.product_id)) ? {...product, ...orderPrefill} : product ); @@ -330,6 +330,7 @@ export const CollectInformation = () => { }, }); } + // prefill/lock intentionally omitted: they're memoized off query params that don't change during the page's lifetime }, [isEventFetched, isOrderFetched, isQuestionsFetched]); useEffect(() => { @@ -488,7 +489,7 @@ export const CollectInformation = () => { /> - {orderRequiresAttendeeDetails && !isPerOrderCollection && totalTicketAttendees > 0 && ( + {orderRequiresAttendeeDetails && !isPerOrderCollection && totalTicketAttendees > 0 && !lock && (
{totalTicketAttendees === 1 ? ( Date: Wed, 1 Jul 2026 15:26:45 +0200 Subject: [PATCH 4/4] feat(checkout): carry prefill params from event page to details step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SelectProducts forwards first_name/last_name/email/lock from the event page URL into the order-creation navigation, so query-param prefill survives to the checkout details step (not just direct deep-links). Adds CHECKOUT_PREFILL_PARAM_KEYS as the single source of truth. 🤖 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../product-widget/SelectProducts/index.tsx | 24 +++++++++++++++---- frontend/src/hooks/useCheckoutPrefill.ts | 5 ++++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/routes/product-widget/SelectProducts/index.tsx b/frontend/src/components/routes/product-widget/SelectProducts/index.tsx index 95d9ae43b0..68fdd9fdb5 100644 --- a/frontend/src/components/routes/product-widget/SelectProducts/index.tsx +++ b/frontend/src/components/routes/product-widget/SelectProducts/index.tsx @@ -37,6 +37,7 @@ import {IconChevronRight, IconX} from "@tabler/icons-react" import {getSessionIdentifier} from "../../../../utilites/sessionIdentifier.ts"; import {Constants} from "../../../../constants.ts"; import {clearWaitlistJoinedForEvent} from "../../../../hooks/useWaitlistJoined.ts"; +import {CHECKOUT_PREFILL_PARAM_KEYS} from "../../../../hooks/useCheckoutPrefill.ts"; const AFFILIATE_EXPIRY_DAYS = 30; @@ -150,16 +151,29 @@ const SelectProducts = (props: SelectProductsProps) => { onSuccess: (data) => queryClient.invalidateQueries() .then(() => { const url = '/checkout/' + eventId + '/' + data.data.short_id + '/details'; + + // Forward checkout-prefill params (name/email/lock) from the event page + // to the details step, since this navigation would otherwise drop them. + const sourceParams = new URLSearchParams(window.location.search); + const prefillParams = new URLSearchParams(); + CHECKOUT_PREFILL_PARAM_KEYS.forEach((key) => { + const value = sourceParams.get(key); + if (value !== null) { + prefillParams.set(key, value); + } + }); + const prefillQuery = prefillParams.toString(); + if (props.widgetMode === 'embedded') { - window.open( - url + '?session_identifier=' + data.data.session_identifier + '&utm_source=embedded_widget', - '_blank' - ); + const embeddedQuery = 'session_identifier=' + data.data.session_identifier + + '&utm_source=embedded_widget' + + (prefillQuery ? '&' + prefillQuery : ''); + window.open(url + '?' + embeddedQuery, '_blank'); setOrderInProcessOverlayVisible(true); return; } - return navigate(url); + return navigate(url + (prefillQuery ? '?' + prefillQuery : '')); }), onError: (error: any) => { diff --git a/frontend/src/hooks/useCheckoutPrefill.ts b/frontend/src/hooks/useCheckoutPrefill.ts index 898e1b63e7..167464944b 100644 --- a/frontend/src/hooks/useCheckoutPrefill.ts +++ b/frontend/src/hooks/useCheckoutPrefill.ts @@ -12,6 +12,11 @@ export interface UseCheckoutPrefillResult { lock: boolean; } +// Query params the checkout details form reads to prefill itself. Also forwarded +// from the event page to the details step so prefill survives order creation +// (see SelectProducts). +export const CHECKOUT_PREFILL_PARAM_KEYS = ["first_name", "last_name", "email", "lock"] as const; + const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; export const useCheckoutPrefill = (): UseCheckoutPrefillResult => {